Compare commits

...

43 Commits

Author SHA1 Message Date
Nikhil Soni
f7bee46933 chore: make create-api-key.sh source-aware to set env in current shell 2026-09-09 18:10:50 +05:30
Nikhil Soni
a88e173a9a chore: split key creation into a python script, use make target, eval-able output 2026-09-09 15:04:04 +05:30
Nikhil Soni
b3d2c3f079 chore: create api keys via sql instead of extracting them 2026-09-09 14:07:55 +05:30
Nikhil Soni
ff475d98c3 chore: refine signoz-testing-apis skill with preview endpoint and api key extraction 2026-09-09 13:37:58 +05:30
Nikhil Soni
d660aaaf25 chore: add signoz-testing-apis agent skill 2026-09-09 13:24:28 +05:30
Nikhil Soni
c36722a26f fix(http): drop dead websocket cors headers
The relocated cors middleware still allowed X-SIGNOZ-QUERY-ID and
Sec-WebSocket-Protocol, both removed with query progress tracking in #12784.

Assisted-by: Claude Opus 4.8
2026-09-08 18:03:32 +05:30
Nikhil Soni
d5854902d3 Merge remote-tracking branch 'origin/main' into ns/harness
# Conflicts:
#	ee/query-service/app/server.go
#	pkg/query-service/app/server.go
2026-09-08 17:25:09 +05:30
Pandey
fe8c9d8cf4 chore: remove query progress tracking (#12784)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- The frontend websocket client for query progress
(`api/common/getQueryStats.ts`) was deleted in #9183, so
`/api/v3/query_progress` and `/ws/query_progress` have had no consumers
since.
- Removes the `query_progress` tracker package, the
`GetQueryProgressUpdates` handler, the `RegisterWebSocketPaths` route
group, and the `websocket.Upgrader` on `APIHandler`.
- Removes the clickhouse reader hooks that reported progress
(`ReportQueryStartForProgressTracking`, `SubscribeToQueryProgress`, and
the `clickhouse.WithProgress` callback in `GetTimeSeriesResultV3`),
along with the untyped `"queryId"` context value they relied on.
- Removes the `X-SIGNOZ-QUERY-ID` header the logs explorer still sent,
plus the now-dead `Sec-WebSocket-Protocol` entries in the CORS allowlist
and the `identn` tokenizer defaults. Livetail streams over SSE, so no
websocket endpoint remains.

#### Additional Information

- `gorilla/websocket` moves from a direct to an indirect dependency
(still pulled in by opamp-go).
- Dropping `Sec-WebSocket-Protocol` from `identn.tokenizer.headers`
changes a default in `conf/example.yaml`. It is only a token-extraction
source for websocket handshakes, of which there are none left, but
calling it out since it is a config-surface change.
2026-09-08 10:56:58 +00:00
dependabot[bot]
2ad3057fa6 chore(deps): bump google.golang.org/grpc from 1.82.1 to 1.83.1 in /scripts/promqltestcorpus (#12758)
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from
1.82.1 to 1.83.1.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/grpc/grpc-go/releases">google.golang.org/grpc's
releases</a>.</em></p>
<blockquote>
<h2>Release 1.83.1</h2>
<h1>Security</h1>
<ul>
<li>xds/rbac: Fix a bug where nested <code>Principal</code> or
<code>Permission</code> rules with <code>:scheme</code> or
<code>grpc-</code> prefixed header matchers were not rejected, which
could cause DENY rules to fail open. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9258">#9258</a>)
<ul>
<li>Special Thanks: <a
href="https://github.com/nvxbug"><code>@​nvxbug</code></a></li>
</ul>
</li>
<li>xds/rbac: Fix a bug where the <code>host</code> header matcher was
not being replaced with <code>:authority</code> in nested
<code>Principal</code> or <code>Permission</code> rules. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9258">#9258</a>)
<ul>
<li>Special Thanks: <a
href="https://github.com/nvxbug"><code>@​nvxbug</code></a></li>
</ul>
</li>
<li>xds/rbac: Fix a bug where a header matcher whose name was not
lowercase, such as <code>X-Role</code>, matched no header, which could
cause DENY rules to fail open. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9332">#9332</a>)
<ul>
<li>Special Thanks: <a
href="https://github.com/alimony"><code>@​alimony</code></a></li>
</ul>
</li>
<li>xds/rbac: Fix a bug where a <code>:scheme</code> or
<code>grpc-</code> prefixed header matcher was accepted when its name
was not lowercase. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9332">#9332</a>)
<ul>
<li>Special Thanks: <a
href="https://github.com/alimony"><code>@​alimony</code></a></li>
</ul>
</li>
<li>xds/rbac: Fix a bug where a <code>Host</code> header matcher was not
replaced with <code>:authority</code>. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9332">#9332</a>)
<ul>
<li>Special Thanks: <a
href="https://github.com/alimony"><code>@​alimony</code></a></li>
</ul>
</li>
</ul>
<h1>Performance</h1>
<ul>
<li>transport: Restrict memory overhead of buffering small data frames.
(<a
href="https://redirect.github.com/grpc/grpc-go/issues/9331">#9331</a>)</li>
</ul>
<h2>Release 1.83.0</h2>
<h1>Security</h1>
<ul>
<li>server: Stop reading from connections when flooded by HTTP/2 frames
to mitigate resource exhaustion. The default value for this limit is 100
frames, excluding DATA and HEADERS, and may be changed by setting
environment variable
<code>GRPC_GO_EXPERIMENTAL_CONTROL_BUFFER_THROTTLE_LIMIT</code>.</li>
<li>xds/rbac: Support <code>Metadata</code> and
<code>RequestedServerName</code> permissions matcher fields. If present
in a DENY rule, previously these would be ignored and fail-open.</li>
<li>xds/rbac: Fix panic when parsing unsupported fields in
<code>NotRule</code>/<code>NotId</code> permissions.</li>
<li>xds/rbac: Support the deprecated <code>source_ip</code> principal
identifier by treating it as equivalent to
<code>direct_remote_ip</code>.</li>
<li>xds: Fix panic when parsing route header matchers configured with
empty <code>exact_match</code>, <code>prefix_match</code>, or
<code>suffix_match</code> strings. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9223">#9223</a>)</li>
</ul>
<h1>New Features</h1>
<ul>
<li>xds/googlec2p: Enable DirectPath over Interconnect support for
on-premises clients via the <code>force-xds</code> target URI query
parameter. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9133">#9133</a>)</li>
<li>xds: Enable xDS configuration to control which fields get propagated
from ORCA backend metric reports to LRS load reports. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9145">#9145</a>)</li>
<li>authz: Add <code>OnPolicyUpdate</code> callback to
<code>FileWatcherOptions</code> to notify when an authz policy is loaded
or updated. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9142">#9142</a>)
<ul>
<li>Special Thanks: <a
href="https://github.com/hnefatl"><code>@​hnefatl</code></a></li>
</ul>
</li>
<li>xds: Add support for the GCP Authentication HTTP Filter, which
automatically fetches and attaches GCP Service Account Identity JWT
tokens to outgoing RPCs.
<ul>
<li>This feature can be enabled by setting environment variable
<code>GRPC_EXPERIMENTAL_XDS_GCP_AUTHENTICATION_FILTER=true</code>. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9119">#9119</a>)</li>
</ul>
</li>
<li>xds: Add support for xDS-based HTTP CONNECT proxies.
<ul>
<li>This feature can be enabled by setting environment variable
<code>GRPC_EXPERIMENTAL_XDS_HTTP_CONNECT=true</code>. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9151">#9151</a>)</li>
</ul>
</li>
<li>xds: Add support for <code>contains_match</code> in route header
matchers. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9223">#9223</a>)</li>
</ul>
<h1>Bug Fixes</h1>
<ul>
<li>credentials/alts: Fix panic when processing malformed frames by
validating that the message frame length exceeds the message type field
size. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9197">#9197</a>)</li>
<li>grpc: Fix compilation on Plan 9 targets (<code>GOOS=plan9</code>),
broken since v1.81.0. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9255">#9255</a>)
<ul>
<li>Special Thanks: <a
href="https://github.com/Yusufihsangorgel"><code>@​Yusufihsangorgel</code></a></li>
</ul>
</li>
</ul>
<h2>Release 1.82.2</h2>
<h1>Security</h1>
<ul>
<li>server: Reject requests missing both <code>:authority</code> and
<code>Host</code> headers with HTTP 400 and status
<code>Internal</code>. (<a
href="https://redirect.github.com/grpc/grpc-go/pull/9365">grpc/grpc-go#9365</a>)</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="1550d9e0cd"><code>1550d9e</code></a>
Change version to 1.83.1 (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9336">#9336</a>)</li>
<li><a
href="ebba6f3f1b"><code>ebba6f3</code></a>
Cherry-pick <a
href="https://redirect.github.com/grpc/grpc-go/issues/9258">#9258</a>
and <a
href="https://redirect.github.com/grpc/grpc-go/issues/9332">#9332</a>
into v1.83.x (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9335">#9335</a>)</li>
<li><a
href="8cfeca0e1e"><code>8cfeca0</code></a>
Cherry-pick <a
href="https://redirect.github.com/grpc/grpc-go/issues/9331">#9331</a> to
v1.83.x (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9333">#9333</a>)</li>
<li><a
href="dec6951305"><code>dec6951</code></a>
Change version to 1.83.1-dev (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9229">#9229</a>)</li>
<li><a
href="4c226daff8"><code>4c226da</code></a>
Change version to 1.83.0 (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9228">#9228</a>)</li>
<li><a
href="c198988aa9"><code>c198988</code></a>
Cherrypick 9223 into v1.83.x (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9279">#9279</a>)</li>
<li><a
href="8ce3ebf24a"><code>8ce3ebf</code></a>
Cherrypick PR 9255 into v1.83.x (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9263">#9263</a>)</li>
<li><a
href="e39384978c"><code>e393849</code></a>
Cherry-pick recent changes from master (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9240">#9240</a>)</li>
<li><a
href="2a112a82f5"><code>2a112a8</code></a>
authz: add onPolicyUpdate callback to authz file watcher (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9142">#9142</a>)</li>
<li><a
href="1a80fca960"><code>1a80fca</code></a>
vet: adds a check to disallow usage of regex.Compile in xDS code (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9216">#9216</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/grpc/grpc-go/compare/v1.82.1...v1.83.1">compare
view</a></li>
</ul>
</details>
<br />

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 09:33:33 +00:00
Aditya Singh
9fc73fc0b1 feat(quick-filters): migrate keys + save/load to new field APIs (#12717)
#### Description

- quick filter keys now come from fields/keys for all pages (logs,
traces, exceptions, api monitoring, meter)..replaces the old v3
filter_suggestions and aggregate attribute_keys calls
- save/load moved to the new v2 /me/filters api that stores
TelemetryFieldKey (name, fieldContext, fieldDataType). removed the old
hand written v1 clients and types
- filters are now identified by name + fieldContext + fieldDataType so
same name fields with different context/datatype don't clash. kept this
contained to the quick filter settings
- values still use the old v3 path for now..that migration (checkboxv2 /
fields/values) is the next PR

#### Screen recording


https://github.com/user-attachments/assets/130c8054-9f7d-4d8d-b224-62da7ccaf41e



#### Issues closed by this PR

Part of https://github.com/SigNoz/engineering-pod/issues/5979
2026-09-08 08:41:06 +00:00
Vikrant Gupta
3931c7163f chore(licensing): remove the legacy billing endpoints (#12771)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Removes the legacy `/api/v1/checkout`, `/api/v1/billing`, and
`/api/v1/portal` routes and the licensing API layer that served them,
now that billing goes through the subscription endpoints.

#### Issues closed by this PR

Closes SigNoz/platform-pod#3091
2026-09-07 18:58:03 +00:00
Vikrant Gupta
fdfbf77d2b feat(billing): gate billing on subscription permissions (#12769)
#### Description

- Billing is now authorized through the `subscription` permissions
instead of the admin role, so custom roles can be granted billing
access.
- Denied users keep the page, tab, and controls in view; usage shows the
denied callout and checkout/portal actions are disabled with a tooltip.
- Billing calls the `/api/v1/subscriptions` endpoints added in #12767
through the generated client.

#### Additional Information

Part of SigNoz/platform-pod#3091. Stacked on #12767; retarget to `main`
once that merges.
2026-09-07 17:29:43 +00:00
Abhi kumar
56e7388677 fix(dashboards-v2): keep auto refresh running in full screen and the table page size sticky (#12776)
#### Description

- Auto refresh stopped in full screen: the timer lived inside the time
selector, which the full-screen branch unmounts along with the toolbar,
so the global time window never advanced and panels never refetched. The
timer now lives in a hook plus a headless `AutoRefreshTicker`, mounted
in the full-screen branch so exactly one timer runs in either mode.
- Table panel page size always snapped back to 10: the height-derived
page size was passed straight into antd's controlled
`pagination.pageSize` and `onChange` dropped the new size. A size picked
from the size changer now wins; panels that were never touched still
auto-fit to the panel height on resize.

Closes https://github.com/SigNoz/pulse-pod/issues/329
2026-09-07 16:29:46 +00:00
Nikhil Soni
98821e7f06 fix(signoz): build openapi apiserver with the default config
Moving the apiserver.address validation into newProvider broke openapi
spec generation, which constructs the apiserver with an empty config
only to walk its routes and never starts the server. Use the default
config from the config factory instead.

Assisted-by: kimi-k3
2026-09-07 19:26:36 +05:30
Nikhil Soni
8592b8afea refactor(apiserver): reuse http/server for the apiserver lifecycle
http/server timeout is configurable; apiserver opts out (0) to keep
long-lived routes (websockets, livetail, export) unbounded, pprof keeps 10s.

Assisted-by: Claude Opus 4.8
2026-09-07 19:00:30 +05:30
Nikhil Soni
4b97648b74 refactor(http): extract cors, compress and otel middlewares
Otel middleware excludes the health endpoints from tracing by default.

Assisted-by: Claude Opus 4.8
2026-09-07 18:54:55 +05:30
Nikhil Soni
a5817a890f chore(query-service): remove unused healthcheck package
Assisted-by: Claude Opus 4.8
2026-09-07 18:53:41 +05:30
Nikhil Soni
e567d33d54 Merge remote-tracking branch 'origin/main' into ns/harness
# Conflicts:
#	pkg/apiserver/signozapiserver/provider.go
#	pkg/signoz/openapi.go
#	pkg/signoz/provider.go
#	pkg/signoz/provider_test.go
#	pkg/signoz/signoz.go
2026-09-07 17:35:44 +05:30
Naman Verma
861380dc65 feat: add create v2 api for notification channels (#12638)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

This PR adds one endpoint, `POST /api/v2/notification_channels`, whose
typed `{name, displayName, config:{kind, spec}}` body replaces v1's
pass-through Alertmanager receiver JSON. v1 routes are deliberately
untouched, so the diff is near-purely additive.
List/get/update/delete/test come in the next PR.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/296

<!--Anything reviewers should keep in mind while reviewing -->

Eventually references (rules, routing policies) migrate onto
internal_name, freeing name to become a user-editable display name. But
that will happen post rules migration so that all rules are on v2

<!--Please delete paragraphs that you did not use before submitting.-->
2026-09-07 11:36:12 +00:00
Gaurav Tewari
391f685e57 feat(llm-observability): AI explorer QB and builder_ai_query wiring (#12683)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Adds the `builder_ai_query` envelope type, seeded by the AI explorer
via `initialQueryAIWithType`.
- AI queries pull filter-bar keys from
`/api/v1/ai_observability/fields/keys` and values from
`/api/v1/ai_observability/fields/values`.
- `QuerySection` drops the builder's custom order-by (`ExplorerOrderBy`
via `renderOrderBy`) for the list and trace panels, along with
`showTraceOperator`. Ordering for those views is the list's own concern,
not the builder's — the trace view supplies its own control in #12688.
`limit` and `having` are now rendered disabled on every panel instead of
hidden on list.
- Hides the span-scope selector for AI queries — "Root / Entrypoint
Spans" ANDs badly with the gate, since GenAI attributes sit on nested
spans. Derived from the query itself, so no new prop on the shared
builder.
- `createNewBuilderQuery` inherits `builderQueryType` from the first
query, the way it already inherits `source`.
- Response conversion reads aggregation metadata from both builder
envelope types, not just `builder_query`.

TDD -
https://app.notion.com/p/signoz/Query-Builder-Changes-3b4fcc6bcd1980889c7aed782665e78d

#### Issues closed by this PR

Closes -
https://github.com/orgs/SigNoz/projects/39/views/20?pane=issue&itemId=226748421&issue=SigNoz%7Cengineering-pod%7C5889

#### Screenshots / Screen Recordings


https://github.com/user-attachments/assets/985cb178-ffb0-405c-b0a6-82ec242c3d68

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-09-07 07:29:31 +00:00
Nikhil Soni
e99263d52e refactor(apiserver): own the http server lifecycle in the signozapiserver provider
The apiserver was constructed via the provider factory but its http
server was started by the query-service outside the factory registry,
so it never showed up in /api/v2/healthz.

- signozapiserver provider now implements factory.ServiceWithHealthy:
  it builds the middleware chain, cors, compression and external-path
  handling, binds the listener in Start (closing healthyC once bound)
  and shuts down gracefully in Stop
- apiserver.APIServer embeds factory.ServiceWithHealthy
- registry gains Add() so the apiserver, which depends on the
  registry-backed health handler, can be registered after construction
- query-service (community and ee) registers its legacy routes on the
  apiserver router and keeps only the opamp websocket server
2026-09-02 18:01:12 +05:30
Nikhil Soni
aa46493d8a chore: add opencode config to use with OPENCODE_CONFIG in sandbox 2026-09-02 15:35:30 +05:30
Nikhil Soni
7fb1feec9d chore: pick a rule from HN post for commenting
Link: https://fabiensanglard.net/agent.md/index.html
2026-08-24 11:33:06 +05:30
Nikhil Soni
60e949d8d8 chore: add rule for avoid commit amend 2026-08-19 22:34:30 +05:30
Nikhil Soni
474aabfd36 chore: make commands for running on worktree discoverable 2026-08-18 15:31:10 +05:30
Nikhil Soni
887b8afd60 docs(contributing): document running multiple signoz instances 2026-08-18 15:21:00 +05:30
Nikhil Soni
37364f611b Revert "chore(hooks): replace husky with worktree-safe .githooks installed automatically"
This reverts commit 1accf43a95.
2026-08-18 15:13:44 +05:30
Nikhil Soni
2b4e1ba382 chore: add rule for running gofmt 2026-08-18 15:13:44 +05:30
Nikhil Soni
9ba63c0a06 chore(hooks): replace husky with worktree-safe .githooks installed automatically
Husky wired hook installation to pnpm install, which go-only contributors
never run. The installer is plain shell so both the Makefile and the frontend
postinstall can call it, and each check is gated on staged paths so neither
side needs the other's toolchain.
2026-08-18 15:13:44 +05:30
Nikhil Soni
206c6f4627 revert(hooks): drop root .husky move and the staged-go gofmt step
Hook installation strategy still needs deciding; reverting to keep it out of
this PR.
2026-08-18 15:13:44 +05:30
Nikhil Soni
342465e715 chore(makefile): poll go-stop once a second so the timeout reads as one number 2026-08-18 15:13:44 +05:30
Nikhil Soni
3bf6aca021 fix(makefile): make go-stop wait for the server to actually exit
kill only signals; the process keeps every port bound while it shuts down
gracefully, so an immediate restart failed on the prometheus exporter port.
2026-08-18 15:13:44 +05:30
Nikhil Soni
0680edd23f chore(makefile): rely on env inheritance for signoz overrides in go-run targets 2026-08-18 15:13:44 +05:30
Nikhil Soni
fd71c555ed chore(hooks): move git hooks from frontend/.husky to root .husky 2026-08-18 15:13:44 +05:30
Nikhil Soni
8bb9fa1a49 chore(makefile): hint at overriding SIGNOZ_APISERVER_ADDRESS when go-stop finds nothing 2026-08-18 15:13:44 +05:30
Nikhil Soni
572fc3e2e2 chore(makefile): rename HTTP_HOST_PORT to SIGNOZ_APISERVER_ADDRESS 2026-08-18 15:13:44 +05:30
Nikhil Soni
bcb315009d chore(hooks): gofmt staged Go files on commit 2026-08-18 15:13:44 +05:30
Nikhil Soni
371a8459ff revert(config): keep opamp websocket port hardcoded, only apiserver port is configurable 2026-08-18 15:13:44 +05:30
Nikhil Soni
7f77681a2b refactor(config): move signoz port config from raw env vars to structured config 2026-08-18 15:13:44 +05:30
Nikhil Soni
70f192f779 chore: add opinionated contrib rule 2026-08-18 15:13:44 +05:30
Nikhil Soni
45158fa046 chore: include commit message instruction as well 2026-08-18 15:13:44 +05:30
Nikhil Soni
78eee04b18 chore: add local claude settings in ignore 2026-08-18 15:13:43 +05:30
Nikhil Soni
84a9b9f346 chore(dev): make signoz http/private/opamp ports and sqlite path configurable 2026-08-18 15:13:43 +05:30
151 changed files with 6665 additions and 2296 deletions

7
.claude/opencode.json Normal file
View File

@@ -0,0 +1,7 @@
{
"$schema": "https://opencode.ai/config.json",
"lsp": true,
"experimental": {
"disable_paste_summary": true
}
}

View File

@@ -7,5 +7,6 @@ Applies to everything in the repo — code, config, workflows.
- **Rationale goes in prose, not source.** Why a version is pinned, why a job exists, how a subsystem fits together — that belongs in the README or the PR.
- **Never remove pre-existing comments** when editing code. The bar above applies to comments you write, not comments already there.
- **Never talk to the reviewer.** No comments about where a change came from, what was changed, or why the change is correct — that belongs in the PR description and is noise the moment it merges.
- **Less is more.** When writing something intended for human consumption, (comment, commit message, reply to prompt) use as few words as possible. Pick every word meticulously to reduce the volume to a strict minimum. Be down to the point. Less is more.
Language rules build on this one: [`go-comments`](go-comments.md), [`py-comments`](py-comments.md).

View File

@@ -0,0 +1,16 @@
---
paths:
- "**/*.go"
---
# Contribution guidelines
- When making Go changes, always ensure they follow the contributing guildelines in [`docs/contributing/go/`](../../docs/contributing/go/).
- Look for existing patterns in the codebase for any change before implementing the changes.
- If any API contract is modified, generate the OpenAPI specs with `make gen-openapi-specs`.
- Always keep the OpenAPI spec generated in a separate commit, so the whole commit can be dropped in case of conflicts during merge. Do not try to resolve conflict in generated files, instead just generate them again.
- Avoid breaking function calls unncessarily into multilines for couple of arguments.
- Try to keep most computational only logic in types package itself related to a domain type, use modules as the orchestraction layer cordinating different layers and all db queries in store layer. Check the serviceaccount modules for inspiration when confused.
- When defining types, keep the structure of file to have any constants and variables first, then exported types and exported methods and then finally the unexported types and methods.
- Never import types or other modules in migration files, duplicate the required type or method to keep migration free from changes.
- Always run the gofmt tool for formating beforing commiting any changes.

View File

@@ -2,6 +2,10 @@
- **Follow the template** (`.github/pull_request_template.md`): fill in its headings (Description / Issues closed by this PR / Screenshots / Additional Information). Don't add sections the template doesn't have.
- **Keep only the headings that apply.** Delete every heading that has nothing under it, along with its `<!--...-->` placeholder comment. The body must never contain an empty heading — if only Description applies, the body has exactly that one heading.
- **Keep the description concise and human-readable.** A few plain bullets saying what changed and why, for a reviewer skimming it — not a wall of text, not a restatement of the diff, not generated boilerplate.
- **Keep the description concise and human-readable.** A few non repetitive bullets saying what changed and why, for a reviewer skimming it — not a wall of text, not a restatement of the diff, not generated boilerplate and not the user agent conversation details.
- **Reference issues with `Closes #issue-number`** under "Issues closed by this PR" so they auto-close on merge. This goes in the PR description only — never in commit messages.
- **Breaking changes can be added in additional information section** if any.
- **AI assistance in commits may optionally be disclosed with an `Assisted-by:` trailer** naming the model (e.g. `Assisted-by: Claude Opus 4.5`) — do NOT use a `Co-authored-by:` trailer for this.
- **Keep the commit body short and human readable** focused on decision made if any. Commit body must not re-iterate the changes done, skip if title is sufficient in conveying the change.
- **Use convensional commit format** for commits and PR title.
- **Do not amend the commits once pushed.** Always create a new commit once changes are pushed to remote.

View File

@@ -0,0 +1,56 @@
---
name: signoz-testing-apis
description: Test any SigNoz HTTP API (query_range, dashboards, alerts, fields, etc.) against a local server or a preview deployment. Use when a task requires calling SigNoz APIs to verify API or query builder changes, reproduce behavior, or inspect responses.
---
# Testing SigNoz APIs
## 1. Endpoint and authentication
- Base URL comes from the `SIGNOZ_ENDPOINT` env var. It may point to a preview deployment; use `http://localhost:8080` when unset.
- Auth comes from the `SIGNOZ_API_KEY` env var, sent as the `SIGNOZ-API-KEY` header.
- If `SIGNOZ_API_KEY` is not set:
- If the server runs against a local SQLite DB, create a key directly in the database and set it in the current shell (inserts service account + key + role + authorization tuple; the server must have been started once so the schema exists):
`source .claude/skills/signoz-testing-apis/scripts/create-api-key.sh /path/to/signoz.db`
If authorization fails right after, wait a few seconds and retry (checks are cached briefly).
- Otherwise (remote/preview deployment) STOP and ask the user to provide a key. NEVER try login, register, invite, user/org creation, or token minting flows.
## 2. Calling an API
- Build the call with the helper script (reads the env vars above, prints the curl it runs):
```bash
.claude/skills/signoz-testing-apis/scripts/api-call.sh GET /api/v1/version
.claude/skills/signoz-testing-apis/scripts/api-call.sh POST /api/v5/query_range payload.json
```
- If you need the payload schema of an unfamiliar endpoint, look it up in the checked-in OpenAPI spec at `docs/api/openapi.yml`.
## 3. Testing query builder changes
Prefer the dry-run preview endpoint — it accepts the same payload as `/api/v5/query_range` but does not execute the queries:
```bash
.claude/skills/signoz-testing-apis/scripts/api-call.sh POST '/api/v5/query_range/preview' payload.json
```
- Per query it returns `valid`, `error`, `warnings`, and the **rendered ClickHouse statement(s)** at `compositeQuery.<name>.statements[].db.statement.query` — exactly what you need to verify query builder changes without any data in ClickHouse.
- Default `verbose=true` also attaches EXPLAIN ESTIMATE and granule index analysis per statement. Use `?verbose=false` for a lightweight valid/error verdict with no ClickHouse round trips.
- Per-query errors are reported inside the response instead of failing the whole request — check each query's `valid`/`error` fields.
## 4. Query range payload
Start from [references/query-range-payload.json](references/query-range-payload.json): a full builder-query template (aggregations, filter, groupBy, selectFields, order, having, limit, legend). Adjust `start`/`end` to the current time range in epoch milliseconds and drop fields you don't need. `selectFields` only matters for `raw`/`scalar` request types; it is ignored for `time_series`.
## 5. Running a local server (only when no endpoint is available)
Skip this section entirely if `SIGNOZ_ENDPOINT` already points at a reachable deployment.
1. **ClickHouse**: the Makefile expects ClickHouse at `tcp://127.0.0.1:9000`. Reuse an already-running instance (e.g. from an existing SigNoz docker deployment) — check with `nc -z 127.0.0.1 9000`. Only start one if none is reachable (see `tests/fixtures/clickhouse.py` for how this repo runs one).
2. **Start the server** from the repo root, in the background:
```bash
make go-run-community &
```
This uses `./signoz.db` (fresh if absent) and disables the web assets. To reuse an existing SQLite DB (keeping orgs, users, and API keys): `make go-run-community SIGNOZ_SQLSTORE_SQLITE_PATH=/path/to/signoz.db`. Wait until the server listens on `:8080`; stop it later with `make go-stop`.

View File

@@ -0,0 +1,38 @@
{
"schemaVersion": "v1",
"start": 1757300000000,
"end": 1757303600000,
"requestType": "time_series",
"compositeQuery": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"stepInterval": "60s",
"disabled": false,
"aggregations": [
{ "expression": "count()", "alias": "span_count" },
{ "expression": "avg(duration_nano)", "alias": "avg_duration" }
],
"filter": { "expression": "service.name = 'frontend' AND http.status_code >= 500" },
"groupBy": [
{ "name": "service.name", "fieldContext": "resource" },
{ "name": "http.status_code", "fieldContext": "span", "fieldDataType": "int64" }
],
"selectFields": [
{ "name": "service.name", "fieldContext": "resource" },
{ "name": "http.route", "fieldContext": "span", "fieldDataType": "string" }
],
"order": [
{ "key": { "name": "span_count" }, "direction": "desc" }
],
"having": { "expression": "span_count > 10" },
"limit": 10,
"legend": "{{service.name}}"
}
}
]
}
}

View File

@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Build and run a curl call against a SigNoz deployment (local or preview).
#
# Usage:
# api-call.sh <METHOD> <PATH> [payload.json]
#
# Environment:
# SIGNOZ_ENDPOINT Base URL of the deployment (default: http://localhost:8080)
# SIGNOZ_API_KEY API key sent as the SIGNOZ-API-KEY header (required)
set -euo pipefail
if [[ $# -lt 2 ]]; then
echo "usage: $0 <METHOD> <PATH> [payload.json]" >&2
exit 2
fi
if [[ -z "${SIGNOZ_API_KEY:-}" ]]; then
echo "SIGNOZ_API_KEY is not set. Ask the user for an API key; do not attempt to authenticate yourself." >&2
echo "If the server runs against a local SQLite DB, create one instead:" >&2
echo " source ${BASH_SOURCE[0]%/*}/create-api-key.sh /path/to/signoz.db" >&2
exit 1
fi
METHOD="$1"
API_PATH="$2"
PAYLOAD_FILE="${3:-}"
ENDPOINT="${SIGNOZ_ENDPOINT:-http://localhost:8080}"
cmd=(curl -sS -X "$METHOD"
-H "SIGNOZ-API-KEY: $SIGNOZ_API_KEY"
-H "Content-Type: application/json")
if [[ -n "$PAYLOAD_FILE" ]]; then
cmd+=(--data @"$PAYLOAD_FILE")
fi
cmd+=("$ENDPOINT$API_PATH")
printf '%q ' "${cmd[@]}" >&2; echo >&2
"${cmd[@]}"
echo

View File

@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""Create a SigNoz API key directly in a local SQLite database.
Inserts the full chain needed for the SIGNOZ-API-KEY header to authenticate
and pass authorization:
organizations row (if none) -> managed signoz-admin role (if none)
-> service_account -> factor_api_key (plaintext) -> openfga tuple
(role#assignee for the service account) + changelog row.
The SigNoz server must have been started at least once against this database
so the schema and the openfga store row exist. Safe to run while the server
is up; if you got a 403 right before creating the key, retry after a few
seconds (authorization checks are cached briefly).
Prints only the token to stdout (all messages go to stderr), so callers can
capture it or export it via the create-api-key.sh wrapper:
source create-api-key.sh /path/to/signoz.db
"""
import secrets
import sqlite3
import sys
import time
import uuid
def fail(msg):
sys.stderr.write(msg + "\n")
sys.exit(1)
def main():
if len(sys.argv) != 2:
fail(f"usage: {sys.argv[0]} <path-to-signoz.db>")
conn = sqlite3.connect(sys.argv[1])
cur = conn.cursor()
required = {"organizations", "role", "service_account", "factor_api_key", "store", "tuple", "changelog"}
tables = {r[0] for r in cur.execute("SELECT name FROM sqlite_master WHERE type='table'")}
missing = sorted(required - tables)
if missing:
fail(f"database is missing tables {missing}; start the SigNoz server once before running this script")
row = cur.execute("SELECT id FROM store LIMIT 1").fetchone()
if not row:
fail("openfga store not initialized; start the SigNoz server once before running this script")
store_id = row[0]
now = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime())
# 1. organization (reuse the existing one if present)
row = cur.execute("SELECT id FROM organizations LIMIT 1").fetchone()
if row:
org_id = row[0]
else:
org_id = str(uuid.uuid4())
h = 2166136261 # fnv-1a 32, mirrors NewOrganizationKey
for ch in org_id:
h = ((h ^ ord(ch)) * 16777619) & 0xFFFFFFFF
cur.execute(
"INSERT INTO organizations (id, created_at, updated_at, name, alias, key, display_name) VALUES (?,?,?,?,?,?,?)",
(org_id, now, now, "", "", h, "default"),
)
# 2. managed admin role
role_name = "signoz-admin"
row = cur.execute("SELECT id FROM role WHERE org_id = ? AND name = ?", (org_id, role_name)).fetchone()
if not row:
cur.execute(
"INSERT INTO role (id, created_at, updated_at, name, description, type, org_id) VALUES (?,?,?,?,?,?,?)",
(str(uuid.uuid4()), now, now, role_name, "Managed admin role", "managed", org_id),
)
# 3. service account
suffix = secrets.token_hex(3)
sa_id = str(uuid.uuid4())
cur.execute(
"INSERT INTO service_account (id, created_at, updated_at, name, email, status, org_id) VALUES (?,?,?,?,?,?,?)",
(sa_id, now, now, f"agent-{suffix}", f"agent-{suffix}@example.com", "active", org_id),
)
# 4. api key, stored in plaintext, never expires
key = secrets.token_urlsafe(32)
cur.execute(
"INSERT INTO factor_api_key (id, created_at, updated_at, name, key, expires_at, last_observed_at, service_account_id) VALUES (?,?,?,?,?,?,?,?)",
(str(uuid.uuid4()), now, now, f"agent-{suffix}", key, 0, now, sa_id),
)
# 5. openfga tuple: service account is an assignee of the admin role
enc = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" # Crockford base32
v = ((int(time.time() * 1000) & ((1 << 48) - 1)) << 80) | secrets.randbits(80)
tuple_id = "".join(enc[(v >> (5 * i)) & 31] for i in range(25, -1, -1))
obj = f"organization/{org_id}/role/{role_name}"
user_obj = f"organization/{org_id}/serviceaccount/{sa_id}"
cur.execute(
"INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at) VALUES (?,?,?,?,?,?,?,?,?,?)",
(store_id, "role", obj, "assignee", "serviceaccount", user_obj, "", "user", tuple_id, now),
)
cur.execute(
"INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at) VALUES (?,?,?,?,?,?,?,?,?,?)",
(store_id, "role", obj, "assignee", "serviceaccount", user_obj, "", 0, tuple_id, now),
)
conn.commit()
print(key)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Create a SigNoz API key in a local SQLite database (logic: create-api-key.py).
#
# A child process cannot modify its parent shell's environment, so to get
# SIGNOZ_API_KEY into the current shell, SOURCE this script:
# source create-api-key.sh /path/to/signoz.db
# Executed normally, it only prints the token:
# create-api-key.sh /path/to/signoz.db
_sourced=0
if [[ -n "${BASH_SOURCE:-}" && "${BASH_SOURCE[0]}" != "$0" ]]; then
_sourced=1
fi
key="$(python3 "$(dirname "${BASH_SOURCE[0]:-$0}")/create-api-key.py" "$@")"
rc=$?
if [[ $rc -ne 0 || -z "$key" ]]; then
[[ $rc -eq 0 ]] && rc=1
if [[ $_sourced -eq 1 ]]; then return "$rc"; else exit "$rc"; fi
fi
export SIGNOZ_API_KEY="$key"
echo "$key"
if [[ $_sourced -eq 0 ]]; then
echo "note: SIGNOZ_API_KEY was not persisted (a process can't modify its parent's environment)." >&2
echo "run 'source ${BASH_SOURCE[0]:-$0} $*' to set it in the current shell." >&2
fi

1
.gitignore vendored
View File

@@ -232,3 +232,4 @@ pyrightconfig.json
# dev
.dev/
.claude/worktrees/
.claude/settings.local.json

View File

@@ -81,10 +81,13 @@ devenv-clickhouse-clean: ## Clean all ClickHouse data from filesystem
##############################################################
# go commands
##############################################################
SIGNOZ_SQLSTORE_SQLITE_PATH ?= signoz.db
SIGNOZ_APISERVER_ADDRESS ?= 0.0.0.0:8080
.PHONY: go-run-enterprise
go-run-enterprise: ## Runs the enterprise go backend server
@SIGNOZ_INSTRUMENTATION_LOGS_LEVEL=debug \
SIGNOZ_SQLSTORE_SQLITE_PATH=signoz.db \
SIGNOZ_SQLSTORE_SQLITE_PATH=$(SIGNOZ_SQLSTORE_SQLITE_PATH) \
SIGNOZ_WEB_ENABLED=false \
SIGNOZ_TOKENIZER_JWT_SECRET=secret \
SIGNOZ_ALERTMANAGER_PROVIDER=signoz \
@@ -101,7 +104,7 @@ go-test: ## Runs go unit tests
.PHONY: go-run-community
go-run-community: ## Runs the community go backend server
@SIGNOZ_INSTRUMENTATION_LOGS_LEVEL=debug \
SIGNOZ_SQLSTORE_SQLITE_PATH=signoz.db \
SIGNOZ_SQLSTORE_SQLITE_PATH=$(SIGNOZ_SQLSTORE_SQLITE_PATH) \
SIGNOZ_WEB_ENABLED=false \
SIGNOZ_TOKENIZER_JWT_SECRET=secret \
SIGNOZ_ALERTMANAGER_PROVIDER=signoz \
@@ -111,6 +114,28 @@ go-run-community: ## Runs the community go backend server
go run -race \
$(GO_BUILD_CONTEXT_COMMUNITY)/*.go server
.PHONY: go-stop
go-stop: ## Stops the go backend server listening on SIGNOZ_APISERVER_ADDRESS, waiting for it to release every port it holds
@PORT=$(lastword $(subst :, ,$(SIGNOZ_APISERVER_ADDRESS))); \
PIDS=$$(lsof -ti tcp:$$PORT); \
if [ -z "$$PIDS" ]; then \
echo "No signoz server running on port $$PORT."; \
echo "If it's running on a different port, rerun as: make go-stop SIGNOZ_APISERVER_ADDRESS=host:port"; \
exit 0; \
fi; \
kill $$PIDS 2>/dev/null; \
for i in $$(seq 1 10); do \
alive=$$(for p in $$PIDS; do kill -0 $$p 2>/dev/null && echo $$p; done); \
[ -z "$$alive" ] && break; \
sleep 1; \
done; \
alive=$$(for p in $$PIDS; do kill -0 $$p 2>/dev/null && echo $$p; done); \
if [ -n "$$alive" ]; then \
echo "Graceful shutdown did not finish in 10s, sending SIGKILL to $$alive"; \
kill -9 $$alive 2>/dev/null; \
fi; \
echo "Stopped signoz server on port $$PORT (pid $$PIDS)"
.PHONY: go-build-community $(GO_BUILD_ARCHS_COMMUNITY)
go-build-community: ## Builds the go backend server for community
go-build-community: $(GO_BUILD_ARCHS_COMMUNITY)
@@ -241,3 +266,8 @@ semconv-generate: ## Regenerate semantic-convention families for Go and TypeScri
gen-mocks:
@echo ">> Generating mocks"
@mockery --config .mockery.yml
.PHONY: gen-openapi-specs
gen-openapi-specs:
@go run cmd/enterprise/*.go generate openapi
cd frontend && pnpm generate:api && cd -

View File

@@ -97,6 +97,7 @@ func runGenerateAuthz(_ context.Context) error {
coretypes.NewResourceRef(coretypes.ResourceRole).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceFactorAPIKey).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceLicense).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceSubscription).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceLogs).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceTraces).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMetrics).String(): true,

View File

@@ -138,6 +138,8 @@ sqlstore:
##################### APIServer #####################
apiserver:
# The TCP address the API server listens on, in the form "host:port".
address: 0.0.0.0:8080
timeout:
# Default request timeout.
default: 60s
@@ -397,7 +399,6 @@ identn:
# headers to use for tokenizer identN resolver
headers:
- Authorization
- Sec-WebSocket-Protocol
apikey:
# toggle apikey identN
enabled: true

View File

@@ -25,6 +25,379 @@ components:
- data
- orgId
type: object
AlertmanagertypesChannelConfig:
discriminator:
mapping:
email: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfig'
googlechat: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfig'
incidentio: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfig'
jira: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfig'
jsmops: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfig'
msteams: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfig'
opsgenie: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfig'
pagerduty: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfig'
slack: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfig'
webhook: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfig'
propertyName: kind
oneOf:
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfig'
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfig'
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfig'
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfig'
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfig'
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfig'
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfig'
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfig'
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfig'
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfig'
type: object
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfig:
properties:
kind:
enum:
- email
type: string
spec:
$ref: '#/components/schemas/AlertmanagertypesChannelEmailConfig'
required:
- kind
- spec
type: object
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfig:
properties:
kind:
enum:
- googlechat
type: string
spec:
$ref: '#/components/schemas/AlertmanagertypesChannelGoogleChatConfig'
required:
- kind
- spec
type: object
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfig:
properties:
kind:
enum:
- incidentio
type: string
spec:
$ref: '#/components/schemas/AlertmanagertypesChannelIncidentIOConfig'
required:
- kind
- spec
type: object
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfig:
properties:
kind:
enum:
- jsmops
type: string
spec:
$ref: '#/components/schemas/AlertmanagertypesChannelJSMOpsConfig'
required:
- kind
- spec
type: object
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfig:
properties:
kind:
enum:
- jira
type: string
spec:
$ref: '#/components/schemas/AlertmanagertypesChannelJiraConfig'
required:
- kind
- spec
type: object
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfig:
properties:
kind:
enum:
- msteams
type: string
spec:
$ref: '#/components/schemas/AlertmanagertypesChannelMSTeamsConfig'
required:
- kind
- spec
type: object
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfig:
properties:
kind:
enum:
- opsgenie
type: string
spec:
$ref: '#/components/schemas/AlertmanagertypesChannelOpsgenieConfig'
required:
- kind
- spec
type: object
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfig:
properties:
kind:
enum:
- pagerduty
type: string
spec:
$ref: '#/components/schemas/AlertmanagertypesChannelPagerdutyConfig'
required:
- kind
- spec
type: object
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfig:
properties:
kind:
enum:
- slack
type: string
spec:
$ref: '#/components/schemas/AlertmanagertypesChannelSlackConfig'
required:
- kind
- spec
type: object
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfig:
properties:
kind:
enum:
- webhook
type: string
spec:
$ref: '#/components/schemas/AlertmanagertypesChannelWebhookConfig'
required:
- kind
- spec
type: object
AlertmanagertypesChannelEmailConfig:
properties:
headers:
additionalProperties:
type: string
type: object
html:
type: string
sendResolved:
nullable: true
type: boolean
to:
type: string
required:
- to
type: object
AlertmanagertypesChannelGoogleChatConfig:
properties:
sendResolved:
nullable: true
type: boolean
text:
type: string
title:
type: string
webhookUrl:
type: string
required:
- webhookUrl
type: object
AlertmanagertypesChannelIncidentIOConfig:
properties:
description:
type: string
metadata:
additionalProperties:
type: string
type: object
sendResolved:
nullable: true
type: boolean
title:
type: string
token:
type: string
url:
type: string
required:
- url
- token
type: object
AlertmanagertypesChannelJSMOpsConfig:
properties:
apiKey:
type: string
description:
type: string
message:
type: string
priority:
type: string
sendResolved:
nullable: true
type: boolean
tags:
type: string
required:
- apiKey
type: object
AlertmanagertypesChannelJiraConfig:
properties:
apiToken:
type: string
customFields:
additionalProperties: {}
type: object
description:
type: string
email:
type: string
issueType:
type: string
labels:
items:
type: string
type: array
priority:
type: string
project:
type: string
reopenDuration:
type: string
reopenTransition:
type: string
resolveTransition:
type: string
sendResolved:
nullable: true
type: boolean
site:
type: string
summary:
type: string
wontFixResolution:
type: string
required:
- site
- project
- issueType
- email
- apiToken
type: object
AlertmanagertypesChannelKind:
enum:
- slack
- email
- webhook
- pagerduty
- opsgenie
- msteams
- googlechat
- jira
- jsmops
- incidentio
type: string
AlertmanagertypesChannelMSTeamsConfig:
properties:
sendResolved:
nullable: true
type: boolean
text:
type: string
title:
type: string
webhookUrl:
type: string
required:
- webhookUrl
type: object
AlertmanagertypesChannelOpsgenieConfig:
properties:
apiKey:
type: string
apiUrl:
type: string
description:
type: string
details:
additionalProperties:
type: string
type: object
message:
type: string
priority:
type: string
sendResolved:
nullable: true
type: boolean
source:
type: string
required:
- apiKey
type: object
AlertmanagertypesChannelPagerdutyConfig:
properties:
class:
type: string
client:
type: string
clientUrl:
type: string
component:
type: string
description:
type: string
details:
additionalProperties:
type: string
type: object
group:
type: string
routingKey:
type: string
sendResolved:
nullable: true
type: boolean
severity:
type: string
source:
type: string
url:
type: string
required:
- routingKey
type: object
AlertmanagertypesChannelSlackConfig:
properties:
apiUrl:
type: string
channel:
type: string
sendResolved:
nullable: true
type: boolean
text:
type: string
title:
type: string
required:
- apiUrl
type: object
AlertmanagertypesChannelWebhookConfig:
properties:
bearerToken:
type: string
password:
type: string
sendResolved:
nullable: true
type: boolean
url:
type: string
username:
type: string
required:
- url
type: object
AlertmanagertypesDeprecatedGettableAlert:
properties:
annotations:
@@ -54,6 +427,30 @@ components:
- rule
- policy
type: string
AlertmanagertypesGettableNotificationChannel:
properties:
config:
$ref: '#/components/schemas/AlertmanagertypesChannelConfig'
createdAt:
format: date-time
type: string
displayName:
type: string
id:
type: string
name:
type: string
updatedAt:
format: date-time
type: string
required:
- name
- displayName
- config
- id
- createdAt
- updatedAt
type: object
AlertmanagertypesGettableRoutePolicy:
properties:
channels:
@@ -356,6 +753,19 @@ components:
required:
- name
type: object
AlertmanagertypesPostableNotificationChannel:
properties:
config:
$ref: '#/components/schemas/AlertmanagertypesChannelConfig'
displayName:
type: string
generateName:
type: boolean
name:
type: string
required:
- config
type: object
AlertmanagertypesPostablePlannedMaintenance:
properties:
alertIds:
@@ -19309,6 +19719,69 @@ paths:
summary: Get metrics treemap
tags:
- metrics
/api/v2/notification_channels:
post:
deprecated: false
description: This endpoint creates a notification channel
operationId: CreateNotificationChannel
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/AlertmanagertypesPostableNotificationChannel'
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/AlertmanagertypesGettableNotificationChannel'
status:
type: string
required:
- status
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"409":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Conflict
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- notification-channel:create
- tokenizer:
- notification-channel:create
summary: Create notification channel
tags:
- channels
/api/v2/orgs/me:
get:
deprecated: false

View File

@@ -83,7 +83,13 @@ This command:
You should see: `{"status":"ok"}`
> 💡 **Tip**: The API server runs at `http://localhost:8080/` by default
3. Stop it when you're done:
```bash
make go-stop
```
> 💡 **Tip**: The API server runs at `http://localhost:8080/` by default. You can configure this using `apiserver.address` configuration option. See
> [running more than one instance](#how-do-i-run-more-than-one-instance) if you need that for agentic testing.
### 4. Setting up the Frontend
@@ -119,6 +125,36 @@ To verify everything is working correctly:
3. **Check Backend**: `curl http://localhost:8080/api/v1/health` (should return `{"status":"ok"}`)
4. **Check Frontend**: Open `http://localhost:3301` in your browser
## How do I run more than one instance?
Handy when you keep several branches checked out as separate git worktrees. Every port
and path below is read from the environment, so set them on the `make` call:
```bash
SIGNOZ_APISERVER_ADDRESS=0.0.0.0:8081 \
SIGNOZ_SQLSTORE_SQLITE_PATH=/path/to/main/sqlite.db \
SIGNOZ_INSTRUMENTATION_METRICS_READERS_PULL_EXPORTER_PROMETHEUS_PORT=9091 \
make go-run-community
```
| Variable | Default | Why you'd change it |
| --- | --- | --- |
| `SIGNOZ_APISERVER_ADDRESS` | `0.0.0.0:8080` | Address the API server listens on |
| `SIGNOZ_SQLSTORE_SQLITE_PATH` | `signoz.db` in worktree | To reuse same database |
| `SIGNOZ_INSTRUMENTATION_METRICS_READERS_PULL_EXPORTER_PROMETHEUS_PORT` | `9090` | Bound by the Prometheus metrics exporter on startup |
Point the frontend at whichever backend you want, in `frontend/.env`:
```env
VITE_FRONTEND_API_ENDPOINT=http://localhost:8081
```
Stop an instance using the address it was started on:
```bash
make go-stop SIGNOZ_APISERVER_ADDRESS=0.0.0.0:8081
```
## How to send test data?
You can now send telemetry data to your local SigNoz instance:

View File

@@ -1,85 +0,0 @@
package httplicensing
import (
"context"
"encoding/json"
"net/http"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/licensetypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type licensingAPI struct {
licensing licensing.Licensing
}
func NewLicensingAPI(licensing licensing.Licensing) licensing.API {
return &licensingAPI{licensing: licensing}
}
func (api *licensingAPI) Checkout(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgId is invalid"))
return
}
req := new(licensetypes.PostableSubscription)
if err := json.NewDecoder(r.Body).Decode(req); err != nil {
render.Error(rw, err)
return
}
gettableSubscription, err := api.licensing.Checkout(ctx, orgID, req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusCreated, gettableSubscription)
}
func (api *licensingAPI) Portal(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgId is invalid"))
return
}
req := new(licensetypes.PostableSubscription)
if err := json.NewDecoder(r.Body).Decode(req); err != nil {
render.Error(rw, err)
return
}
gettableSubscription, err := api.licensing.Portal(ctx, orgID, req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusCreated, gettableSubscription)
}

View File

@@ -2,12 +2,9 @@ package httplicensing
import (
"context"
"encoding/json"
"log/slog"
"time"
"github.com/tidwall/gjson"
"github.com/SigNoz/signoz/ee/licensing/licensingstore/sqllicensingstore"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/errors"
@@ -228,47 +225,6 @@ func (provider *provider) Refresh(ctx context.Context, organizationID valuer.UUI
return nil
}
func (provider *provider) Checkout(ctx context.Context, organizationID valuer.UUID, postableSubscription *licensetypes.PostableSubscription) (*licensetypes.GettableSubscription, error) {
activeLicense, err := provider.GetActive(ctx, organizationID)
if err != nil {
return nil, err
}
body, err := json.Marshal(postableSubscription)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to marshal checkout payload")
}
response, err := provider.zeus.GetCheckoutURL(ctx, activeLicense.Key, body)
if err != nil {
if errors.Ast(err, errors.TypeAlreadyExists) {
return nil, errors.WithAdditionalf(err, "checkout has already been completed for this account. Please click 'Refresh Status' to sync your subscription")
}
return nil, err
}
return &licensetypes.GettableSubscription{RedirectURL: gjson.GetBytes(response, "url").String()}, nil
}
func (provider *provider) Portal(ctx context.Context, organizationID valuer.UUID, postableSubscription *licensetypes.PostableSubscription) (*licensetypes.GettableSubscription, error) {
activeLicense, err := provider.GetActive(ctx, organizationID)
if err != nil {
return nil, err
}
body, err := json.Marshal(postableSubscription)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to marshal portal payload")
}
response, err := provider.zeus.GetPortalURL(ctx, activeLicense.Key, body)
if err != nil {
return nil, err
}
return &licensetypes.GettableSubscription{RedirectURL: gjson.GetBytes(response, "url").String()}, nil
}
func (provider *provider) GetFeatureFlags(ctx context.Context, organizationID valuer.UUID) ([]*licensetypes.Feature, error) {
license, err := provider.GetActive(ctx, organizationID)
if err != nil {

View File

@@ -4,7 +4,6 @@ import (
"net/http"
"time"
"github.com/SigNoz/signoz/ee/licensing/httplicensing"
"github.com/SigNoz/signoz/ee/query-service/usage"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/middleware"
@@ -42,7 +41,6 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz, config signoz.
IntegrationsController: opts.IntegrationsController,
LogsParsingPipelineController: opts.LogsParsingPipelineController,
FluxInterval: opts.FluxInterval,
LicensingAPI: httplicensing.NewLicensingAPI(signoz.Licensing),
Signoz: signoz,
QueryParserAPI: queryparser.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.QueryParser),
}, config)
@@ -72,10 +70,6 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
// base overrides
router.HandleFunc("/api/v1/version", am.OpenAccess(ah.getVersion)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/checkout", am.AdminAccess(ah.LicensingAPI.Checkout)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/billing", am.AdminAccess(ah.getBilling)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/portal", am.AdminAccess(ah.LicensingAPI.Portal)).Methods(http.MethodPost)
// v4
router.HandleFunc("/api/v4/query_range", am.ViewAccess(ah.queryRangeV4)).Methods(http.MethodPost)

View File

@@ -1,76 +0,0 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"github.com/SigNoz/signoz/ee/query-service/model"
)
type DayWiseBreakdown struct {
Type string `json:"type"`
Breakdown []DayWiseData `json:"breakdown"`
}
type DayWiseData struct {
Timestamp int64 `json:"timestamp"`
Count float64 `json:"count"`
Size float64 `json:"size"`
UnitPrice float64 `json:"unitPrice"`
Quantity float64 `json:"quantity"`
Total float64 `json:"total"`
}
type tierBreakdown struct {
UnitPrice float64 `json:"unitPrice"`
Quantity float64 `json:"quantity"`
TierStart int64 `json:"tierStart"`
TierEnd int64 `json:"tierEnd"`
TierCost float64 `json:"tierCost"`
}
type usageResponse struct {
Type string `json:"type"`
Unit string `json:"unit"`
Tiers []tierBreakdown `json:"tiers"`
DayWiseBreakdown DayWiseBreakdown `json:"dayWiseBreakdown"`
}
type details struct {
Total float64 `json:"total"`
Breakdown []usageResponse `json:"breakdown"`
BaseFee float64 `json:"baseFee"`
BillTotal float64 `json:"billTotal"`
}
type billingData struct {
BillingPeriodStart int64 `json:"billingPeriodStart"`
BillingPeriodEnd int64 `json:"billingPeriodEnd"`
Details details `json:"details"`
Discount float64 `json:"discount"`
SubscriptionStatus string `json:"subscriptionStatus"`
}
func (ah *APIHandler) getBilling(w http.ResponseWriter, r *http.Request) {
licenseKey := r.URL.Query().Get("licenseKey")
if licenseKey == "" {
RespondError(w, model.BadRequest(fmt.Errorf("license key is required")), nil)
return
}
data, err := ah.Signoz.Zeus.GetMeters(r.Context(), licenseKey)
if err != nil {
RespondError(w, model.InternalError(err), nil)
return
}
var billing billingData
if err := json.Unmarshal(data, &billing); err != nil {
RespondError(w, model.InternalError(err), nil)
return
}
ah.Respond(w, billing)
}

View File

@@ -3,56 +3,30 @@ package app
import (
"context"
"fmt"
"net"
"net/http"
"slices"
"go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux"
"go.opentelemetry.io/otel/propagation"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/gorilla/handlers"
"github.com/rs/cors"
"github.com/soheilhy/cmux"
"github.com/SigNoz/signoz/ee/query-service/app/api"
"github.com/SigNoz/signoz/ee/query-service/usage"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/middleware"
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/web"
"log/slog"
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
baseapp "github.com/SigNoz/signoz/pkg/query-service/app"
"github.com/SigNoz/signoz/pkg/query-service/app/clickhouseReader"
"github.com/SigNoz/signoz/pkg/query-service/app/integrations"
"github.com/SigNoz/signoz/pkg/query-service/app/logparsingpipeline"
"github.com/SigNoz/signoz/pkg/query-service/app/opamp"
opAmpModel "github.com/SigNoz/signoz/pkg/query-service/app/opamp/model"
baseconst "github.com/SigNoz/signoz/pkg/query-service/constants"
"github.com/SigNoz/signoz/pkg/query-service/healthcheck"
"github.com/SigNoz/signoz/pkg/query-service/utils"
)
// Server runs HTTP, Mux and a grpc server
// Server runs auxiliary servers (opamp) alongside the signoz apiserver
type Server struct {
config signoz.Config
signoz *signoz.SigNoz
// public http router
httpConn net.Listener
httpServer *http.Server
httpHostPort string
opampServer *opamp.Server
// Usage manager
usageManager *usage.Manager
unavailableChannel chan healthcheck.Status
}
// NewServer creates and initializes Server
@@ -127,22 +101,25 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
return nil, err
}
// Register the legacy query-service routes on the apiserver router. The
// apiserver owns the HTTP server and applies the middleware chain at serve
// time, so these routes get the same treatment as the apiserver routes.
r := signoz.APIServer.Router()
am := middleware.NewAuthZ(signoz.Instrumentation.Logger(), signoz.Modules.OrgGetter, signoz.Authz)
apiHandler.RegisterRoutes(r, am)
apiHandler.RegisterLogsRoutes(r, am)
apiHandler.RegisterIntegrationRoutes(r, am)
apiHandler.RegisterQueryRangeV3Routes(r, am)
apiHandler.RegisterQueryRangeV4Routes(r, am)
apiHandler.RegisterMessagingQueuesRoutes(r, am)
apiHandler.RegisterThirdPartyApiRoutes(r, am)
apiHandler.RegisterTraceFunnelsRoutes(r, am)
s := &Server{
config: config,
signoz: signoz,
httpHostPort: baseconst.HTTPHostPort,
unavailableChannel: make(chan healthcheck.Status),
usageManager: usageManager,
usageManager: usageManager,
}
httpServer, err := s.createPublicServer(apiHandler, signoz.Web)
if err != nil {
return nil, err
}
s.httpServer = httpServer
s.opampServer = opamp.InitializeServer(
&opAmpModel.AllAgents, agentConfMgr, signoz.Instrumentation,
)
@@ -150,133 +127,14 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
return s, nil
}
// HealthCheckStatus returns health check status channel a client can subscribe to
func (s Server) HealthCheckStatus() chan healthcheck.Status {
return s.unavailableChannel
}
func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*http.Server, error) {
r := baseapp.NewRouter()
am := middleware.NewAuthZ(s.signoz.Instrumentation.Logger(), s.signoz.Modules.OrgGetter, s.signoz.Authz)
r.Use(middleware.NewRecovery(s.signoz.Instrumentation.Logger()).Wrap)
r.Use(otelmux.Middleware(
"apiserver",
otelmux.WithMeterProvider(s.signoz.Instrumentation.MeterProvider()),
otelmux.WithTracerProvider(s.signoz.Instrumentation.TracerProvider()),
otelmux.WithPropagators(propagation.NewCompositeTextMapPropagator(propagation.Baggage{}, propagation.TraceContext{})),
otelmux.WithFilter(func(r *http.Request) bool {
return !slices.Contains([]string{"/api/v1/health"}, r.URL.Path)
}),
))
r.Use(middleware.NewIdentN(s.signoz.IdentNResolver, s.signoz.Sharder, s.signoz.Instrumentation.Logger()).Wrap)
r.Use(middleware.NewTimeout(s.signoz.Instrumentation.Logger(),
s.config.APIServer.Timeout.ExcludedRoutes,
s.config.APIServer.Timeout.Default,
s.config.APIServer.Timeout.Max,
).Wrap)
r.Use(middleware.NewResource(s.signoz.Instrumentation.Logger()).Wrap)
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, s.signoz.Auditor).Wrap)
r.Use(middleware.NewComment().Wrap)
apiHandler.RegisterRoutes(r, am)
apiHandler.RegisterLogsRoutes(r, am)
apiHandler.RegisterIntegrationRoutes(r, am)
apiHandler.RegisterQueryRangeV3Routes(r, am)
apiHandler.RegisterQueryRangeV4Routes(r, am)
apiHandler.RegisterWebSocketPaths(r, am)
apiHandler.RegisterMessagingQueuesRoutes(r, am)
apiHandler.RegisterThirdPartyApiRoutes(r, am)
apiHandler.RegisterTraceFunnelsRoutes(r, am)
err := s.signoz.APIServer.AddToRouter(r)
if err != nil {
return nil, err
}
c := cors.New(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "DELETE", "POST", "PUT", "PATCH", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "cache-control", "X-SIGNOZ-QUERY-ID", "Sec-WebSocket-Protocol"},
})
handler := c.Handler(r)
handler = handlers.CompressHandler(handler)
err = web.AddToRouter(r)
if err != nil {
return nil, err
}
routePrefix := s.config.Global.ExternalPath()
if routePrefix != "" {
prefixed := http.StripPrefix(routePrefix, handler)
handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
switch req.URL.Path {
case "/api/v1/health", "/api/v2/healthz", "/api/v2/readyz", "/api/v2/livez":
r.ServeHTTP(w, req)
return
}
prefixed.ServeHTTP(w, req)
})
}
return &http.Server{
Handler: handler,
}, nil
}
// initListeners initialises listeners of the server
func (s *Server) initListeners() error {
// listen on public port
var err error
publicHostPort := s.httpHostPort
if publicHostPort == "" {
return fmt.Errorf("baseconst.HTTPHostPort is required")
}
s.httpConn, err = net.Listen("tcp", publicHostPort)
if err != nil {
return err
}
slog.Info(fmt.Sprintf("Query server started listening on %s...", s.httpHostPort))
return nil
}
// Start listening on http and private http port concurrently
// Start starts the opamp websocket server. The HTTP API server is started by
// the signoz registry.
func (s *Server) Start(ctx context.Context) error {
err := s.initListeners()
if err != nil {
return err
}
var httpPort int
if port, err := utils.GetPort(s.httpConn.Addr()); err == nil {
httpPort = port
}
go func() {
slog.Info("Starting HTTP server", "port", httpPort, "addr", s.httpHostPort)
switch err := s.httpServer.Serve(s.httpConn); err {
case nil, http.ErrServerClosed, cmux.ErrListenerClosed:
// normal exit, nothing to do
default:
slog.Error("Could not start HTTP server", errors.Attr(err))
}
s.unavailableChannel <- healthcheck.Unavailable
}()
go func() {
slog.Info("Starting OpAmp Websocket server", "addr", baseconst.OpAmpWsEndpoint)
err := s.opampServer.Start(baseconst.OpAmpWsEndpoint)
if err != nil {
slog.Error("opamp ws server failed to start", errors.Attr(err))
s.unavailableChannel <- healthcheck.Unavailable
}
}()
@@ -284,12 +142,6 @@ func (s *Server) Start(ctx context.Context) error {
}
func (s *Server) Stop(ctx context.Context) error {
if s.httpServer != nil {
if err := s.httpServer.Shutdown(ctx); err != nil {
return err
}
}
s.opampServer.Stop()
// stop usage manager

View File

@@ -169,12 +169,12 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
// Check for workspace blocked (trial expired)
if (!isFetchingActiveLicense && isCloudPlatform && trialInfo?.workSpaceBlock) {
const isRouteEnabledForWorkspaceBlockedState =
isAdmin &&
(pathname === ROUTES.SETTINGS ||
pathname === ROUTES.ORG_SETTINGS ||
pathname === ROUTES.MEMBERS_SETTINGS ||
pathname === ROUTES.BILLING ||
pathname === ROUTES.MY_SETTINGS);
pathname === ROUTES.SETTINGS ||
pathname === ROUTES.BILLING ||
(isAdmin &&
(pathname === ROUTES.ORG_SETTINGS ||
pathname === ROUTES.MEMBERS_SETTINGS ||
pathname === ROUTES.MY_SETTINGS));
if (
pathname !== ROUTES.WORKSPACE_LOCKED &&

View File

@@ -739,7 +739,7 @@ describe('PrivateRoute', () => {
assertStaysOnRoute(ROUTES.MY_SETTINGS);
});
it('should redirect VIEWER to workspace locked even when trying to access settings', async () => {
it('should allow VIEWER to access /settings when workspace is blocked', () => {
renderPrivateRoute({
initialRoute: ROUTES.SETTINGS,
appContext: {
@@ -752,10 +752,10 @@ describe('PrivateRoute', () => {
isCloudUser: true,
});
await assertRedirectsTo(ROUTES.WORKSPACE_LOCKED);
assertStaysOnRoute(ROUTES.SETTINGS);
});
it('should redirect VIEWER to workspace locked when trying to access billing', async () => {
it('should allow VIEWER to access /settings/billing when workspace is blocked', () => {
renderPrivateRoute({
initialRoute: ROUTES.BILLING,
appContext: {
@@ -768,7 +768,7 @@ describe('PrivateRoute', () => {
isCloudUser: true,
});
await assertRedirectsTo(ROUTES.WORKSPACE_LOCKED);
assertStaysOnRoute(ROUTES.BILLING);
});
it('should redirect VIEWER to workspace locked when trying to access org-settings', async () => {
@@ -819,7 +819,7 @@ describe('PrivateRoute', () => {
await assertRedirectsTo(ROUTES.WORKSPACE_LOCKED);
});
it('should redirect EDITOR to workspace locked when trying to access settings', async () => {
it('should allow EDITOR to access /settings when workspace is blocked', () => {
renderPrivateRoute({
initialRoute: ROUTES.SETTINGS,
appContext: {
@@ -832,7 +832,7 @@ describe('PrivateRoute', () => {
isCloudUser: true,
});
await assertRedirectsTo(ROUTES.WORKSPACE_LOCKED);
assertStaysOnRoute(ROUTES.SETTINGS);
});
it('should not redirect when already on workspace locked page', () => {
@@ -1626,6 +1626,7 @@ describe('PrivateRoute', () => {
path: ROUTES.WORKSPACE_ACCESS_RESTRICTED,
deniedRoles: DENIED_ROLES,
},
BILLING: { path: ROUTES.BILLING, deniedRoles: DENIED_ROLES },
};
const authzRouteRolePairs: [string, string, ROLES][] = Object.entries(

View File

@@ -1,59 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
export interface DayBreakdownEntry {
timestamp: number;
total: number;
quantity: number;
count: number;
size: number;
}
export interface TierEntry {
quantity: number;
unitPrice: number;
tierCost: number;
}
export interface BreakdownEntry {
type: string;
unit: string;
dayWiseBreakdown: {
breakdown: DayBreakdownEntry[];
};
tiers?: TierEntry[];
}
export interface UsageResponsePayloadProps {
billingPeriodStart: number;
billingPeriodEnd: number;
details: {
total: number;
baseFee: number;
breakdown: BreakdownEntry[];
billTotal: number;
};
discount: number;
subscriptionStatus?: string;
}
const getUsage = async (
licenseKey: string,
): Promise<SuccessResponse<UsageResponsePayloadProps> | ErrorResponse> => {
try {
const response = await axios.get(`/billing?licenseKey=${licenseKey}`);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default getUsage;

View File

@@ -19,8 +19,10 @@ import type {
import type {
AlertmanagertypesPostableChannelDTO,
AlertmanagertypesPostableNotificationChannelDTO,
AlertmanagertypesReceiverDTO,
CreateChannel201,
CreateNotificationChannel201,
DeleteChannelByIDPathParameters,
GetChannelByID200,
GetChannelByIDPathParameters,
@@ -647,3 +649,87 @@ export const useTestChannelDeprecated = <
> => {
return useMutation(getTestChannelDeprecatedMutationOptions(options));
};
/**
* This endpoint creates a notification channel
* @summary Create notification channel
*/
export const createNotificationChannel = (
alertmanagertypesPostableNotificationChannelDTO?: BodyType<AlertmanagertypesPostableNotificationChannelDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateNotificationChannel201>({
url: `/api/v2/notification_channels`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: alertmanagertypesPostableNotificationChannelDTO,
signal,
});
};
export const getCreateNotificationChannelMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createNotificationChannel>>,
TError,
{ data?: BodyType<AlertmanagertypesPostableNotificationChannelDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createNotificationChannel>>,
TError,
{ data?: BodyType<AlertmanagertypesPostableNotificationChannelDTO> },
TContext
> => {
const mutationKey = ['createNotificationChannel'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createNotificationChannel>>,
{ data?: BodyType<AlertmanagertypesPostableNotificationChannelDTO> }
> = (props) => {
const { data } = props ?? {};
return createNotificationChannel(data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateNotificationChannelMutationResult = NonNullable<
Awaited<ReturnType<typeof createNotificationChannel>>
>;
export type CreateNotificationChannelMutationBody =
| BodyType<AlertmanagertypesPostableNotificationChannelDTO>
| undefined;
export type CreateNotificationChannelMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Create notification channel
*/
export const useCreateNotificationChannel = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createNotificationChannel>>,
TError,
{ data?: BodyType<AlertmanagertypesPostableNotificationChannelDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createNotificationChannel>>,
TError,
{ data?: BodyType<AlertmanagertypesPostableNotificationChannelDTO> },
TContext
> => {
return useMutation(getCreateNotificationChannelMutationOptions(options));
};

View File

@@ -37,6 +37,476 @@ export interface AlertmanagertypesChannelDTO {
updatedAt?: string;
}
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTOKind {
slack = 'slack',
}
export interface AlertmanagertypesChannelSlackConfigDTO {
/**
* @type string
*/
apiUrl: string;
/**
* @type string
*/
channel?: string;
/**
* @type boolean,null
*/
sendResolved?: boolean | null;
/**
* @type string
*/
text?: string;
/**
* @type string
*/
title?: string;
}
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTO {
/**
* @enum slack
* @type string
*/
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTOKind;
spec: AlertmanagertypesChannelSlackConfigDTO;
}
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfigDTOKind {
email = 'email',
}
export type AlertmanagertypesChannelEmailConfigDTOHeaders = {
[key: string]: string;
};
export interface AlertmanagertypesChannelEmailConfigDTO {
/**
* @type object
*/
headers?: AlertmanagertypesChannelEmailConfigDTOHeaders;
/**
* @type string
*/
html?: string;
/**
* @type boolean,null
*/
sendResolved?: boolean | null;
/**
* @type string
*/
to: string;
}
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfigDTO {
/**
* @enum email
* @type string
*/
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfigDTOKind;
spec: AlertmanagertypesChannelEmailConfigDTO;
}
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfigDTOKind {
webhook = 'webhook',
}
export interface AlertmanagertypesChannelWebhookConfigDTO {
/**
* @type string
*/
bearerToken?: string;
/**
* @type string
*/
password?: string;
/**
* @type boolean,null
*/
sendResolved?: boolean | null;
/**
* @type string
*/
url: string;
/**
* @type string
*/
username?: string;
}
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfigDTO {
/**
* @enum webhook
* @type string
*/
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfigDTOKind;
spec: AlertmanagertypesChannelWebhookConfigDTO;
}
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfigDTOKind {
pagerduty = 'pagerduty',
}
export type AlertmanagertypesChannelPagerdutyConfigDTODetails = {
[key: string]: string;
};
export interface AlertmanagertypesChannelPagerdutyConfigDTO {
/**
* @type string
*/
class?: string;
/**
* @type string
*/
client?: string;
/**
* @type string
*/
clientUrl?: string;
/**
* @type string
*/
component?: string;
/**
* @type string
*/
description?: string;
/**
* @type object
*/
details?: AlertmanagertypesChannelPagerdutyConfigDTODetails;
/**
* @type string
*/
group?: string;
/**
* @type string
*/
routingKey: string;
/**
* @type boolean,null
*/
sendResolved?: boolean | null;
/**
* @type string
*/
severity?: string;
/**
* @type string
*/
source?: string;
/**
* @type string
*/
url?: string;
}
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfigDTO {
/**
* @enum pagerduty
* @type string
*/
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfigDTOKind;
spec: AlertmanagertypesChannelPagerdutyConfigDTO;
}
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfigDTOKind {
opsgenie = 'opsgenie',
}
export type AlertmanagertypesChannelOpsgenieConfigDTODetails = {
[key: string]: string;
};
export interface AlertmanagertypesChannelOpsgenieConfigDTO {
/**
* @type string
*/
apiKey: string;
/**
* @type string
*/
apiUrl?: string;
/**
* @type string
*/
description?: string;
/**
* @type object
*/
details?: AlertmanagertypesChannelOpsgenieConfigDTODetails;
/**
* @type string
*/
message?: string;
/**
* @type string
*/
priority?: string;
/**
* @type boolean,null
*/
sendResolved?: boolean | null;
/**
* @type string
*/
source?: string;
}
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfigDTO {
/**
* @enum opsgenie
* @type string
*/
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfigDTOKind;
spec: AlertmanagertypesChannelOpsgenieConfigDTO;
}
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfigDTOKind {
msteams = 'msteams',
}
export interface AlertmanagertypesChannelMSTeamsConfigDTO {
/**
* @type boolean,null
*/
sendResolved?: boolean | null;
/**
* @type string
*/
text?: string;
/**
* @type string
*/
title?: string;
/**
* @type string
*/
webhookUrl: string;
}
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfigDTO {
/**
* @enum msteams
* @type string
*/
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfigDTOKind;
spec: AlertmanagertypesChannelMSTeamsConfigDTO;
}
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfigDTOKind {
googlechat = 'googlechat',
}
export interface AlertmanagertypesChannelGoogleChatConfigDTO {
/**
* @type boolean,null
*/
sendResolved?: boolean | null;
/**
* @type string
*/
text?: string;
/**
* @type string
*/
title?: string;
/**
* @type string
*/
webhookUrl: string;
}
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfigDTO {
/**
* @enum googlechat
* @type string
*/
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfigDTOKind;
spec: AlertmanagertypesChannelGoogleChatConfigDTO;
}
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTOKind {
jira = 'jira',
}
export type AlertmanagertypesChannelJiraConfigDTOCustomFields = {
[key: string]: unknown;
};
export interface AlertmanagertypesChannelJiraConfigDTO {
/**
* @type string
*/
apiToken: string;
/**
* @type object
*/
customFields?: AlertmanagertypesChannelJiraConfigDTOCustomFields;
/**
* @type string
*/
description?: string;
/**
* @type string
*/
email: string;
/**
* @type string
*/
issueType: string;
/**
* @type array
*/
labels?: string[];
/**
* @type string
*/
priority?: string;
/**
* @type string
*/
project: string;
/**
* @type string
*/
reopenDuration?: string;
/**
* @type string
*/
reopenTransition?: string;
/**
* @type string
*/
resolveTransition?: string;
/**
* @type boolean,null
*/
sendResolved?: boolean | null;
/**
* @type string
*/
site: string;
/**
* @type string
*/
summary?: string;
/**
* @type string
*/
wontFixResolution?: string;
}
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTO {
/**
* @enum jira
* @type string
*/
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTOKind;
spec: AlertmanagertypesChannelJiraConfigDTO;
}
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfigDTOKind {
jsmops = 'jsmops',
}
export interface AlertmanagertypesChannelJSMOpsConfigDTO {
/**
* @type string
*/
apiKey: string;
/**
* @type string
*/
description?: string;
/**
* @type string
*/
message?: string;
/**
* @type string
*/
priority?: string;
/**
* @type boolean,null
*/
sendResolved?: boolean | null;
/**
* @type string
*/
tags?: string;
}
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfigDTO {
/**
* @enum jsmops
* @type string
*/
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfigDTOKind;
spec: AlertmanagertypesChannelJSMOpsConfigDTO;
}
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTOKind {
incidentio = 'incidentio',
}
export type AlertmanagertypesChannelIncidentIOConfigDTOMetadata = {
[key: string]: string;
};
export interface AlertmanagertypesChannelIncidentIOConfigDTO {
/**
* @type string
*/
description?: string;
/**
* @type object
*/
metadata?: AlertmanagertypesChannelIncidentIOConfigDTOMetadata;
/**
* @type boolean,null
*/
sendResolved?: boolean | null;
/**
* @type string
*/
title?: string;
/**
* @type string
*/
token: string;
/**
* @type string
*/
url: string;
}
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTO {
/**
* @enum incidentio
* @type string
*/
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTOKind;
spec: AlertmanagertypesChannelIncidentIOConfigDTO;
}
export type AlertmanagertypesChannelConfigDTO =
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTO;
export enum AlertmanagertypesChannelKindDTO {
slack = 'slack',
email = 'email',
webhook = 'webhook',
pagerduty = 'pagerduty',
opsgenie = 'opsgenie',
msteams = 'msteams',
googlechat = 'googlechat',
jira = 'jira',
jsmops = 'jsmops',
incidentio = 'incidentio',
}
export interface ModelLabelSetDTO {
[key: string]: string;
}
@@ -88,6 +558,32 @@ export enum AlertmanagertypesExpressionKindDTO {
rule = 'rule',
policy = 'policy',
}
export interface AlertmanagertypesGettableNotificationChannelDTO {
config: AlertmanagertypesChannelConfigDTO;
/**
* @type string
* @format date-time
*/
createdAt: string;
/**
* @type string
*/
displayName: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name: string;
/**
* @type string
* @format date-time
*/
updatedAt: string;
}
export interface AlertmanagertypesGettableRoutePolicyDTO {
/**
* @type array,null
@@ -1748,6 +2244,22 @@ export type AlertmanagertypesPostableChannelDTO = unknown & {
wechat_configs?: ConfigWechatConfigDTO[];
};
export interface AlertmanagertypesPostableNotificationChannelDTO {
config: AlertmanagertypesChannelConfigDTO;
/**
* @type string
*/
displayName?: string;
/**
* @type boolean
*/
generateName?: boolean;
/**
* @type string
*/
name?: string;
}
export interface AlertmanagertypesPostablePlannedMaintenanceDTO {
/**
* @type array,null
@@ -12650,6 +13162,14 @@ export type GetMetricsTreemap200 = {
status: string;
};
export type CreateNotificationChannel201 = {
data: AlertmanagertypesGettableNotificationChannelDTO;
/**
* @type string
*/
status: string;
};
export type GetMyOrganization200 = {
data: TypesOrganizationDTO;
/**

View File

@@ -1,25 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/quickFilters/getCustomFilters';
const getCustomFilters = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
const { signal } = props;
try {
const response = await axios.get(`/orgs/me/filters/${signal}`);
return {
statusCode: 200,
error: null,
message: 'Success',
payload: response.data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default getCustomFilters;

View File

@@ -1,13 +0,0 @@
import axios from 'api';
import { AxiosError } from 'axios';
import { SuccessResponse } from 'types/api';
import { UpdateCustomFiltersProps } from 'types/api/quickFilters/updateCustomFilters';
const updateCustomFiltersAPI = async (
props: UpdateCustomFiltersProps,
): Promise<SuccessResponse<void> | AxiosError> =>
axios.put(`/orgs/me/filters`, {
...props.data,
});
export default updateCustomFiltersAPI;

View File

@@ -1,28 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import {
CheckoutRequestPayloadProps,
CheckoutSuccessPayloadProps,
PayloadProps,
} from 'types/api/billing/checkout';
const updateCreditCardApi = async (
props: CheckoutRequestPayloadProps,
): Promise<SuccessResponseV2<CheckoutSuccessPayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/checkout', {
url: props.url,
});
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default updateCreditCardApi;

View File

@@ -1,28 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import {
CheckoutRequestPayloadProps,
CheckoutSuccessPayloadProps,
PayloadProps,
} from 'types/api/billing/checkout';
const manageCreditCardApi = async (
props: CheckoutRequestPayloadProps,
): Promise<SuccessResponseV2<CheckoutSuccessPayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/portal', {
url: props.url,
});
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default manageCreditCardApi;

View File

@@ -2,8 +2,10 @@ import { cloneDeep, isEmpty } from 'lodash-es';
import { SuccessResponse, Warning } from 'types/api';
import { MetricRangePayloadV3 } from 'types/api/metrics/getQueryRange';
import {
BuilderQuery,
DistributionData,
MetricRangePayloadV5,
QueryEnvelope,
QueryRangeRequestV5,
RawData,
ScalarData,
@@ -11,6 +13,11 @@ import {
} from 'types/api/v5/queryRange';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
const isBuilderQueryEnvelope = (
envelope: QueryEnvelope,
): envelope is QueryEnvelope & { spec: BuilderQuery } =>
envelope.type === 'builder_query' || envelope.type === 'builder_ai_query';
function getColName(
col: ScalarData['columns'][number],
legendMap: Record<string, string>,
@@ -409,21 +416,19 @@ export function convertV5ResponseToLegacy(
const v5Data = payload?.data;
const aggregationPerQuery =
params?.compositeQuery?.queries
?.filter((query) => query.type === 'builder_query')
.reduce(
(acc, query) => {
if (
query.type === 'builder_query' &&
'aggregations' in query.spec &&
query.spec.name
) {
acc[query.spec.name] = query.spec.aggregations;
}
return acc;
},
{} as Record<string, any>,
) || {};
params?.compositeQuery?.queries?.filter(isBuilderQueryEnvelope).reduce(
(acc, query) => {
if (
isBuilderQueryEnvelope(query) &&
'aggregations' in query.spec &&
query.spec.name
) {
acc[query.spec.name] = query.spec.aggregations;
}
return acc;
},
{} as Record<string, any>,
) || {};
// clickhouse_sql queries have no aggregation metadata; their value columns
// are named/keyed by the real SQL alias the response carries (see getColId).

View File

@@ -14,6 +14,7 @@ import {
QueryBuilderFormula as V5QueryBuilderFormula,
QueryEnvelope,
QueryRangePayloadV5,
RequestType,
} from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
@@ -935,3 +936,41 @@ describe('convertBuilderQueriesToV5 having normalization', () => {
});
});
});
describe('convertBuilderQueriesToV5 builder query type', () => {
const buildEnvelope = (
builderQueryType: IBuilderQuery['builderQueryType'],
requestType: RequestType,
): QueryEnvelope => {
const [envelope] = convertBuilderQueriesToV5(
{
A: {
dataSource: DataSource.TRACES,
queryName: 'A',
builderQueryType,
} as unknown as IBuilderQuery,
},
requestType,
);
return envelope;
};
it.each<[RequestType]>([
['trace'],
['raw'],
['time_series'],
['scalar'],
['distribution'],
])('sends builder_ai_query for the %s request type', (requestType) => {
expect(buildEnvelope('builder_ai_query', requestType).type).toBe(
'builder_ai_query',
);
});
it.each<[string, IBuilderQuery['builderQueryType']]>([
['an unmarked query', undefined],
['an explicitly generic query', 'builder_query'],
])('sends builder_query for %s', (_label, builderQueryType) => {
expect(buildEnvelope(builderQueryType, 'trace').type).toBe('builder_query');
});
});

View File

@@ -365,7 +365,7 @@ export function convertBuilderQueriesToV5(
}
return {
type: 'builder_query' as QueryType,
type: queryData.builderQueryType ?? 'builder_query',
spec,
};
},

View File

@@ -4,11 +4,12 @@ import { useLocation } from 'react-router-dom';
import { Button, Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import updateCreditCardApi from 'api/v1/checkout/create';
import { createSubscription } from 'api/generated/services/subscriptions';
import type { CreateSubscription201 } from 'api/generated/services/sigNoz.schemas';
import { useNotifications } from 'hooks/useNotifications';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { SubscriptionCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import { CreditCard, MessageSquareText, X } from '@signozhq/icons';
import { SuccessResponseV2 } from 'types/api';
import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
import APIError from 'types/api/error';
import { getBaseUrl } from 'utils/basePath';
@@ -18,9 +19,7 @@ export default function ChatSupportGateway(): JSX.Element {
const [isAddCreditCardModalOpen, setIsAddCreditCardModalOpen] =
useState(false);
const handleBillingOnSuccess = (
data: SuccessResponseV2<CheckoutSuccessPayloadProps>,
): void => {
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
if (data?.data?.redirectURL) {
const newTab = document.createElement('a');
newTab.href = data.data.redirectURL;
@@ -38,7 +37,7 @@ export default function ChatSupportGateway(): JSX.Element {
};
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
updateCreditCardApi,
createSubscription,
{
onSuccess: (data) => {
handleBillingOnSuccess(data);
@@ -94,18 +93,23 @@ export default function ChatSupportGateway(): JSX.Element {
>
Cancel
</Button>,
<Button
<AuthZTooltip
key="submit"
type="primary"
icon={<CreditCard size={16} />}
size="middle"
loading={isLoadingBilling}
disabled={isLoadingBilling}
onClick={handleAddCreditCard}
className="add-credit-card-btn"
checks={[SubscriptionCreatePermission]}
withPortal={false}
>
Add Credit Card
</Button>,
<Button
type="primary"
icon={<CreditCard size={16} />}
size="middle"
loading={isLoadingBilling}
disabled={isLoadingBilling}
onClick={handleAddCreditCard}
className="add-credit-card-btn"
>
Add Credit Card
</Button>
</AuthZTooltip>,
]}
>
<Typography.Text className="add-credit-card-text">

View File

@@ -4,16 +4,17 @@ import { useLocation } from 'react-router-dom';
import { Button, Modal, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import updateCreditCardApi from 'api/v1/checkout/create';
import { createSubscription } from 'api/generated/services/subscriptions';
import type { CreateSubscription201 } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import { FeatureKeys } from 'constants/features';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useNotifications } from 'hooks/useNotifications';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { SubscriptionCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import { defaultTo } from 'lodash-es';
import { CircleHelp, CreditCard, X } from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
import { SuccessResponseV2 } from 'types/api';
import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
import APIError from 'types/api/error';
import { getBaseUrl } from 'utils/basePath';
@@ -118,9 +119,7 @@ function LaunchChatSupport({
}
};
const handleBillingOnSuccess = (
data: SuccessResponseV2<CheckoutSuccessPayloadProps>,
): void => {
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
if (data?.data?.redirectURL) {
const newTab = document.createElement('a');
newTab.href = data.data.redirectURL;
@@ -138,7 +137,7 @@ function LaunchChatSupport({
};
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
updateCreditCardApi,
createSubscription,
{
onSuccess: (data) => {
handleBillingOnSuccess(data);
@@ -193,18 +192,23 @@ function LaunchChatSupport({
>
Cancel
</Button>,
<Button
<AuthZTooltip
key="submit"
type="primary"
icon={<CreditCard size={16} />}
size="middle"
loading={isLoadingBilling}
disabled={isLoadingBilling}
onClick={handleAddCreditCard}
className="add-credit-card-btn"
checks={[SubscriptionCreatePermission]}
withPortal={false}
>
Add Credit Card
</Button>,
<Button
type="primary"
icon={<CreditCard size={16} />}
size="middle"
loading={isLoadingBilling}
disabled={isLoadingBilling}
onClick={handleAddCreditCard}
className="add-credit-card-btn"
>
Add Credit Card
</Button>
</AuthZTooltip>,
]}
>
<Typography.Text className="add-credit-card-text">

View File

@@ -16,8 +16,6 @@ import { githubLight } from '@uiw/codemirror-theme-github';
import CodeMirror, { EditorView, keymap, Prec } from '@uiw/react-codemirror';
import { Button, Card, Collapse, Popover, Tooltip } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
import cx from 'classnames';
import {
negationQueryOperatorSuggestions,
@@ -54,6 +52,12 @@ import {
SUGGESTION_FETCH_DEBOUNCE_MS,
SUGGESTIONS_SECTION,
} from './constants';
import {
fetchFieldKeysForQuery,
fetchFieldValuesForQuery,
SuggestedFieldKey,
SuggestedFieldKeysByName,
} from './fieldSuggestions';
import {
combineInitialAndUserExpression,
dedupeOptionsByLabel,
@@ -261,10 +265,8 @@ function QuerySearch({
const dashboardDynamicVariables = useDynamicVariableSuggestions();
// Add back the generateOptions function and useEffect
const generateOptions = (keys: {
[key: string]: QueryKeyDataSuggestionsProps[];
}): any[] =>
Object.values(keys).flatMap((items: QueryKeyDataSuggestionsProps[]) =>
const generateOptions = (keys: SuggestedFieldKeysByName): any[] =>
Object.values(keys).flatMap((items: SuggestedFieldKey[]) =>
items.map(({ name, fieldDataType, fieldContext }) => ({
label: name,
type: fieldDataType === 'string' ? 'keyword' : fieldDataType,
@@ -317,8 +319,9 @@ function QuerySearch({
lastFetchedKeyRef.current = searchText || '';
const response = await getKeySuggestions({
signal: dataSource,
const response = await fetchFieldKeysForQuery({
builderQueryType: queryData.builderQueryType,
dataSource,
searchText: searchText || '',
metricName: debouncedMetricName ?? undefined,
signalSource: signalSource as 'meter' | '',
@@ -360,6 +363,7 @@ function QuerySearch({
hardcodedAttributeKeys,
showFilterSuggestionsWithoutMetric,
metricNamespace,
queryData.builderQueryType,
],
);
@@ -493,10 +497,11 @@ function QuerySearch({
try {
const values = valueSuggestionsOverride
? await valueSuggestionsOverride(key, sanitizedSearchText)
: await getValueSuggestions({
: await fetchFieldValuesForQuery({
builderQueryType: queryData.builderQueryType,
dataSource,
key,
searchText: sanitizedSearchText,
signal: dataSource,
signalSource: signalSource as 'meter' | '',
metricName: debouncedMetricName ?? undefined,
}).then((response) => {
@@ -601,6 +606,7 @@ function QuerySearch({
signalSource,
toggleSuggestions,
valueSuggestionsOverride,
queryData.builderQueryType,
],
);

View File

@@ -0,0 +1,215 @@
import {
getAIObservabilityFieldsKeys,
getAIObservabilityFieldsValues,
} from 'api/generated/services/ai-observability';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
import { DataSource } from 'types/common/queryBuilder';
import {
fetchFieldKeysForQuery,
fetchFieldValuesForQuery,
} from '../fieldSuggestions';
jest.mock('api/generated/services/ai-observability', () => ({
getAIObservabilityFieldsKeys: jest.fn(),
getAIObservabilityFieldsValues: jest.fn(),
}));
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
getKeySuggestions: jest.fn(),
}));
jest.mock('api/querySuggestions/getValueSuggestion', () => ({
getValueSuggestions: jest.fn(),
}));
const mockedAIKeys = getAIObservabilityFieldsKeys as jest.MockedFunction<
typeof getAIObservabilityFieldsKeys
>;
const mockedGenericKeys = getKeySuggestions as jest.MockedFunction<
typeof getKeySuggestions
>;
const mockedAIValues = getAIObservabilityFieldsValues as jest.MockedFunction<
typeof getAIObservabilityFieldsValues
>;
const mockedGenericValues = getValueSuggestions as jest.MockedFunction<
typeof getValueSuggestions
>;
const aiValuesResponse = (
values: { stringValues?: string[]; numberValues?: number[] } | null,
complete = true,
): Awaited<ReturnType<typeof getAIObservabilityFieldsValues>> =>
({
status: 'success',
data: { complete, values },
}) as Awaited<ReturnType<typeof getAIObservabilityFieldsValues>>;
describe('fetchFieldKeysForQuery', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('reads the ai_observability endpoint for a builder_ai_query', async () => {
mockedAIKeys.mockResolvedValue({
status: 'success',
data: {
complete: true,
keys: { llm_call_count: [{ name: 'llm_call_count' }] },
},
} as Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>);
const keys = await fetchFieldKeysForQuery({
builderQueryType: 'builder_ai_query',
dataSource: DataSource.TRACES,
searchText: 'llm',
});
expect(mockedAIKeys).toHaveBeenCalledWith({ searchText: 'llm' });
expect(mockedGenericKeys).not.toHaveBeenCalled();
expect(keys.data.data).toStrictEqual({
complete: true,
keys: { llm_call_count: [{ name: 'llm_call_count' }] },
});
});
it.each<[string, 'builder_query' | undefined]>([
['an unmarked query', undefined],
['an explicitly generic query', 'builder_query'],
])('reads the generic endpoint for %s', async (_label, builderQueryType) => {
mockedGenericKeys.mockResolvedValue({
data: { status: 'success', data: { complete: true, keys: {} } },
} as Awaited<ReturnType<typeof getKeySuggestions>>);
await fetchFieldKeysForQuery({
builderQueryType,
dataSource: DataSource.TRACES,
searchText: 'svc',
});
expect(mockedAIKeys).not.toHaveBeenCalled();
expect(mockedGenericKeys).toHaveBeenCalledWith(
expect.objectContaining({ signal: DataSource.TRACES, searchText: 'svc' }),
);
});
it('normalizes a null ai_observability keys payload to an empty map', async () => {
mockedAIKeys.mockResolvedValue({
status: 'success',
data: { complete: false, keys: null },
} as Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>);
const response = await fetchFieldKeysForQuery({
builderQueryType: 'builder_ai_query',
dataSource: DataSource.TRACES,
searchText: '',
});
expect(response.data.data).toStrictEqual({ complete: false, keys: {} });
});
it('passes the generic response through untouched', async () => {
const genericResponse = {
data: { status: 'success', data: { complete: true, keys: {} } },
} as unknown as Awaited<ReturnType<typeof getKeySuggestions>>;
mockedGenericKeys.mockResolvedValue(genericResponse);
await expect(
fetchFieldKeysForQuery({
builderQueryType: 'builder_query',
dataSource: DataSource.TRACES,
searchText: '',
}),
).resolves.toBe(genericResponse);
});
});
describe('fetchFieldValuesForQuery', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('reads the ai_observability endpoint for a builder_ai_query', async () => {
mockedAIValues.mockResolvedValue(
aiValuesResponse({ stringValues: ['gpt-4o'], numberValues: [] }),
);
const response = await fetchFieldValuesForQuery({
builderQueryType: 'builder_ai_query',
dataSource: DataSource.TRACES,
key: 'gen_ai.request.model',
searchText: 'gpt',
});
expect(mockedGenericValues).not.toHaveBeenCalled();
expect(response).toStrictEqual({
data: {
data: {
complete: true,
values: { stringValues: ['gpt-4o'], numberValues: [] },
},
},
});
});
it('forwards the key as the name the endpoint expects', async () => {
mockedAIValues.mockResolvedValue(aiValuesResponse({}));
await fetchFieldValuesForQuery({
builderQueryType: 'builder_ai_query',
dataSource: DataSource.TRACES,
key: 'total_tokens',
searchText: '',
});
expect(mockedAIValues).toHaveBeenCalledWith({
name: 'total_tokens',
searchText: '',
});
});
it('wraps the ai_observability payload in the envelope the call site unwraps', async () => {
mockedAIValues.mockResolvedValue(aiValuesResponse(null, false));
await expect(
fetchFieldValuesForQuery({
builderQueryType: 'builder_ai_query',
dataSource: DataSource.TRACES,
key: 'llm_call_count',
searchText: '',
}),
).resolves.toStrictEqual({
data: { data: { complete: false, values: null } },
});
});
it.each<[string, 'builder_query' | undefined]>([
['an unmarked query', undefined],
['an explicitly generic query', 'builder_query'],
])('reads the generic endpoint for %s', async (_label, builderQueryType) => {
const genericResponse = {
data: {
data: { complete: false, values: { stringValues: ['frontend'] } },
},
} as unknown as Awaited<ReturnType<typeof getValueSuggestions>>;
mockedGenericValues.mockResolvedValue(genericResponse);
const response = await fetchFieldValuesForQuery({
builderQueryType,
dataSource: DataSource.TRACES,
key: 'service.name',
searchText: 'front',
});
expect(mockedAIValues).not.toHaveBeenCalled();
expect(mockedGenericValues).toHaveBeenCalledWith(
expect.objectContaining({
signal: DataSource.TRACES,
key: 'service.name',
searchText: 'front',
}),
);
expect(response).toBe(genericResponse);
});
});

View File

@@ -0,0 +1,111 @@
import {
getAIObservabilityFieldsKeys,
getAIObservabilityFieldsValues,
} from 'api/generated/services/ai-observability';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
export interface SuggestedFieldKey {
name: string;
fieldContext?: string;
fieldDataType?: string;
}
export type SuggestedFieldKeysByName = Record<string, SuggestedFieldKey[]>;
export interface SuggestedFieldKeysPayload {
complete: boolean;
keys: SuggestedFieldKeysByName;
}
export interface SuggestedFieldKeysResponse {
data: { data?: SuggestedFieldKeysPayload };
}
export interface SuggestedFieldValuesPayload {
complete?: boolean;
values?: {
stringValues?: string[] | null;
numberValues?: number[] | null;
} | null;
}
export interface SuggestedFieldValuesResponse {
data: { data?: SuggestedFieldValuesPayload };
}
interface FetchFieldKeysParams {
builderQueryType: IBuilderQuery['builderQueryType'];
dataSource: DataSource;
searchText: string;
metricName?: string;
signalSource?: 'meter' | '';
metricNamespace?: string;
}
interface FetchFieldValuesParams {
builderQueryType: IBuilderQuery['builderQueryType'];
dataSource: DataSource;
key: string;
searchText: string;
metricName?: string;
signalSource?: 'meter' | '';
}
export const fetchFieldKeysForQuery = async ({
builderQueryType,
dataSource,
searchText,
metricName,
signalSource,
metricNamespace,
}: FetchFieldKeysParams): Promise<SuggestedFieldKeysResponse> => {
if (builderQueryType === 'builder_ai_query') {
const response = await getAIObservabilityFieldsKeys({ searchText });
return {
data: {
data: response.data
? { complete: response.data.complete, keys: response.data.keys ?? {} }
: undefined,
},
};
}
return getKeySuggestions({
signal: dataSource,
searchText,
metricName,
signalSource,
metricNamespace,
});
};
export const fetchFieldValuesForQuery = async ({
builderQueryType,
dataSource,
key,
searchText,
metricName,
signalSource,
}: FetchFieldValuesParams): Promise<SuggestedFieldValuesResponse> => {
if (builderQueryType === 'builder_ai_query') {
const response = await getAIObservabilityFieldsValues({
name: key,
searchText,
});
return { data: { data: response.data } };
}
// getValueSuggestions' declared response type does not match what the endpoint returns.
return getValueSuggestions({
signal: dataSource,
key,
searchText,
signalSource,
metricName,
}) as unknown as Promise<SuggestedFieldValuesResponse>;
};

View File

@@ -54,7 +54,7 @@ export const QueryV2 = forwardRef(function QueryV2(
const { cloneQuery, panelType } = useQueryBuilder();
const showFunctions = query?.functions?.length > 0;
const { dataSource } = query;
const { dataSource, builderQueryType } = query;
const [isCollapsed, setIsCollapsed] = useState(false);
@@ -94,8 +94,9 @@ export const QueryV2 = forwardRef(function QueryV2(
);
const showSpanScopeSelector = useMemo(
() => dataSource === DataSource.TRACES,
[dataSource],
() =>
dataSource === DataSource.TRACES && builderQueryType !== 'builder_ai_query',
[dataSource, builderQueryType],
);
const showInlineQuerySearch = useMemo(() => {

View File

@@ -22,7 +22,10 @@ interface UseFieldValuesReturn {
isFetching: boolean;
}
const DATA_SOURCE_TO_SIGNAL: Record<DataSource, TelemetrytypesSignalDTO> = {
export const DATA_SOURCE_TO_SIGNAL: Record<
DataSource,
TelemetrytypesSignalDTO
> = {
[DataSource.METRICS]: TelemetrytypesSignalDTO.metrics,
[DataSource.TRACES]: TelemetrytypesSignalDTO.traces,
[DataSource.LOGS]: TelemetrytypesSignalDTO.logs,

View File

@@ -17,7 +17,7 @@ import { CSS } from '@dnd-kit/utilities';
import { Button } from 'antd';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { GripVertical } from '@signozhq/icons';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
function SortableFilter({
filter,
@@ -25,13 +25,13 @@ function SortableFilter({
allowDrag,
allowRemove,
}: {
filter: FilterType;
onRemove: (filter: FilterType) => void;
filter: TelemetryFieldKey;
onRemove: (filter: TelemetryFieldKey) => void;
allowDrag: boolean;
allowRemove: boolean;
}): JSX.Element {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: filter.key });
useSortable({ id: filter.key as string });
const style = {
transform: CSS.Transform.toString(transform),
@@ -46,14 +46,14 @@ function SortableFilter({
>
<div {...attributes} {...listeners} className="drag-handle">
{allowDrag && <GripVertical size={16} />}
{filter.key}
{filter.name}
</div>
{allowRemove && (
<Button
className="remove-filter-btn periscope-btn"
size="small"
onClick={(): void => {
onRemove(filter as FilterType);
onRemove(filter);
}}
>
Remove
@@ -69,8 +69,8 @@ function AddedFilters({
setAddedFilters,
}: {
inputValue: string;
addedFilters: FilterType[];
setAddedFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
addedFilters: TelemetryFieldKey[];
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
}): JSX.Element {
const sensors = useSensors(useSensor(PointerSensor));
@@ -90,12 +90,12 @@ function AddedFilters({
const filteredAddedFilters = useMemo(
() =>
addedFilters.filter((filter) =>
filter.key.toLowerCase().includes(inputValue.toLowerCase()),
filter.name.toLowerCase().includes(inputValue.toLowerCase()),
),
[addedFilters, inputValue],
);
const handleRemoveFilter = (filter: FilterType): void => {
const handleRemoveFilter = (filter: TelemetryFieldKey): void => {
setAddedFilters((prev) => prev.filter((f) => f.key !== filter.key));
};
@@ -116,7 +116,7 @@ function AddedFilters({
<div className="no-values-found">No values found</div>
) : (
<SortableContext
items={addedFilters.map((f) => f.key)}
items={addedFilters.map((f) => f.key as string)}
strategy={verticalListSortingStrategy}
disabled={!allowDrag}
>

View File

@@ -1,17 +1,17 @@
import { useMemo } from 'react';
import { Button, Skeleton } from 'antd';
import { useGetFieldsKeys } from 'api/generated/services/fields';
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { DATA_SOURCE_TO_SIGNAL } from 'components/QuickFilters/FilterRenderers/Checkbox/v2/useFieldValues';
import { SIGNAL_DATA_SOURCE_MAP } from 'components/QuickFilters/QuickFiltersSettings/constants';
import { SignalType } from 'components/QuickFilters/types';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { useGetAttributeSuggestions } from 'hooks/queryBuilder/useGetAttributeSuggestions';
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
import { DataSource } from 'types/common/queryBuilder';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import {
FieldContext,
FieldDataType,
TelemetryFieldKey,
} from 'types/api/v5/queryRange';
function OtherFiltersSkeleton(): JSX.Element {
return (
@@ -37,106 +37,48 @@ function OtherFilters({
}: {
signal: SignalType | undefined;
inputValue: string;
addedFilters: FilterType[];
setAddedFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
addedFilters: TelemetryFieldKey[];
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
}): JSX.Element {
const isLogDataSource = useMemo(
() => SIGNAL_DATA_SOURCE_MAP[signal as SignalType] === DataSource.LOGS,
[signal],
);
const isMeterDataSource = useMemo(
() => signal && signal === SignalType.METER_EXPLORER,
[signal],
const isMeterDataSource = signal === SignalType.METER_EXPLORER;
const { data, isFetching } = useGetFieldsKeys(
{
searchText: inputValue,
signal: signal
? DATA_SOURCE_TO_SIGNAL[SIGNAL_DATA_SOURCE_MAP[signal]]
: undefined,
source: isMeterDataSource ? TelemetrytypesSourceDTO.meter : undefined,
},
{ query: { enabled: !!signal } },
);
const { data: suggestionsData, isFetching: isFetchingSuggestions } =
useGetAttributeSuggestions(
{
searchText: inputValue,
dataSource: SIGNAL_DATA_SOURCE_MAP[signal as SignalType],
filters: {} as TagFilter,
},
{
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue],
enabled: !!signal && isLogDataSource,
},
);
const otherFilters = useMemo<TelemetryFieldKey[]>(() => {
const rawSuggestions = Object.values(data?.data?.keys ?? {}).flat();
// Normalize: synthesize the composite `key` once so downstream reads (dedupe,
// add, render) can trust it.
const suggestions: TelemetryFieldKey[] = rawSuggestions.map((attr) => ({
name: attr.name,
signal: attr.signal as TelemetryFieldKey['signal'],
fieldContext: attr.fieldContext as FieldContext,
fieldDataType: attr.fieldDataType as FieldDataType,
key: buildCompositeKey(attr.name, attr.fieldContext, attr.fieldDataType),
}));
const { data: aggregateKeysData, isFetching: isFetchingAggregateKeys } =
useGetAggregateKeys(
{
searchText: inputValue,
dataSource: SIGNAL_DATA_SOURCE_MAP[signal as SignalType],
aggregateOperator: 'noop',
aggregateAttribute: '',
tagType: '',
},
{
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue],
enabled: !!signal && !isLogDataSource && !isMeterDataSource,
},
const addedKeys = new Set(
addedFilters.map((filter) =>
buildCompositeKey(filter.name, filter.fieldContext, filter.fieldDataType),
),
);
return suggestions.filter((attr) => !addedKeys.has(attr.key as string));
}, [data, addedFilters]);
const { data: fieldKeysData, isLoading: isLoadingFieldKeys } =
useGetQueryKeySuggestions(
{
searchText: inputValue,
signal: SIGNAL_DATA_SOURCE_MAP[signal as SignalType],
signalSource: 'meter',
},
{
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue],
enabled: !!signal && isMeterDataSource,
},
);
const otherFilters = useMemo(() => {
let filterAttributes;
if (isLogDataSource) {
filterAttributes = suggestionsData?.payload?.attributes || [];
} else if (isMeterDataSource) {
const fieldKeys: QueryKeyDataSuggestionsProps[] = Object.values(
fieldKeysData?.data?.data?.keys || {},
)?.flat();
filterAttributes = fieldKeys.map(
(attr) =>
({
key: attr.name,
dataType: attr.fieldDataType,
type: attr.fieldContext,
signal: attr.signal,
}) as BaseAutocompleteData,
);
} else {
filterAttributes = aggregateKeysData?.payload?.attributeKeys || [];
}
return filterAttributes?.filter(
(attr) => !addedFilters.some((filter) => filter.key === attr.key),
);
}, [
suggestionsData,
aggregateKeysData,
addedFilters,
isLogDataSource,
fieldKeysData,
isMeterDataSource,
]);
const handleAddFilter = (filter: FilterType): void => {
setAddedFilters((prev) => [
...prev,
{
key: filter.key,
dataType: filter.dataType,
type: filter.type,
},
]);
const handleAddFilter = (filter: TelemetryFieldKey): void => {
setAddedFilters((prev) => [...prev, filter]);
};
const renderFilters = (): React.ReactNode => {
const isLoading =
isFetchingSuggestions || isFetchingAggregateKeys || isLoadingFieldKeys;
if (isLoading) {
if (isFetching) {
return <OtherFiltersSkeleton />;
}
if (!otherFilters?.length) {
@@ -145,11 +87,11 @@ function OtherFilters({
return otherFilters.map((filter) => (
<div key={filter.key} className="qf-filter-item other-filters-item">
<div className="qf-filter-key">{filter.key}</div>
<div className="qf-filter-key">{filter.name}</div>
<Button
className="add-filter-btn periscope-btn"
size="small"
onClick={(): void => handleAddFilter(filter as FilterType)}
onClick={(): void => handleAddFilter(filter)}
>
Add
</Button>

View File

@@ -1,8 +1,7 @@
import { useMemo } from 'react';
import { Input } from '@signozhq/ui/input';
import { Button } from 'antd';
import { Check, TableColumnsSplit, X } from '@signozhq/icons';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { SignalType } from '../types';
import AddedFilters from './AddedFilters';
@@ -19,7 +18,7 @@ function QuickFiltersSettings({
}: {
signal: SignalType | undefined;
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
customFilters: FilterType[];
customFilters: TelemetryFieldKey[];
refetchCustomFilters: () => void;
}): JSX.Element {
const {
@@ -28,6 +27,7 @@ function QuickFiltersSettings({
addedFilters,
setAddedFilters,
handleSaveChanges,
hasUnsavedChanges,
isUpdatingCustomFilters,
inputValue,
handleInputChange,
@@ -39,18 +39,6 @@ function QuickFiltersSettings({
signal,
});
const hasUnsavedChanges = useMemo(
() =>
// check if both arrays have the same length and same order of elements
!(
addedFilters.length === customFilters.length &&
addedFilters.every(
(filter, index) => filter.key === customFilters[index].key,
)
),
[addedFilters, customFilters],
);
return (
<>
<div className="qf-header">

View File

@@ -1,27 +1,31 @@
import { useCallback, useState } from 'react';
import { useMutation } from 'react-query';
import { useCallback, useMemo, useState } from 'react';
import { useUpdateQuickFilters } from 'api/generated/services/quick-filter';
import logEvent from 'api/common/logEvent';
import updateCustomFiltersAPI from 'api/quickFilters/updateCustomFilters';
import axios, { AxiosError } from 'axios';
import {
TelemetrytypesFieldContextDTO,
TelemetrytypesFieldDataTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { SignalType } from 'components/QuickFilters/types';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { useNotifications } from 'hooks/useNotifications';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
interface UseQuickFilterSettingsProps {
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
customFilters: FilterType[];
customFilters: TelemetryFieldKey[];
refetchCustomFilters: () => void;
signal?: SignalType;
}
interface UseQuickFilterSettingsReturn {
addedFilters: FilterType[];
setAddedFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
addedFilters: TelemetryFieldKey[];
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
handleSettingsClose: () => void;
handleDiscardChanges: () => void;
handleSaveChanges: () => void;
hasUnsavedChanges: boolean;
isUpdatingCustomFilters: boolean;
inputValue: string;
setInputValue: React.Dispatch<React.SetStateAction<string>>;
@@ -37,27 +41,43 @@ const useQuickFilterSettings = ({
}: UseQuickFilterSettingsProps): UseQuickFilterSettingsReturn => {
const [inputValue, setInputValue] = useState<string>('');
const [debouncedInputValue, setDebouncedInputValue] = useState<string>('');
const [addedFilters, setAddedFilters] = useState<FilterType[]>(customFilters);
const normalizedCustomFilters = useMemo<TelemetryFieldKey[]>(
() =>
customFilters.map((filter) => ({
...filter,
key: buildCompositeKey(
filter.name,
filter.fieldContext,
filter.fieldDataType,
),
})),
[customFilters],
);
const [addedFilters, setAddedFilters] = useState<TelemetryFieldKey[]>(
normalizedCustomFilters,
);
const { notifications } = useNotifications();
const { mutate: updateCustomFilters, isLoading: isUpdatingCustomFilters } =
useMutation(updateCustomFiltersAPI, {
onSuccess: () => {
setIsSettingsOpen(false);
refetchCustomFilters();
logEvent('Quick Filters Settings: changes saved', {
addedFilters,
});
notifications.success({
message: 'Quick filters updated successfully',
placement: 'bottomRight',
});
},
onError: (error: AxiosError) => {
notifications.error({
message: axios.isAxiosError(error) ? error.message : SOMETHING_WENT_WRONG,
placement: 'bottomRight',
});
useUpdateQuickFilters({
mutation: {
onSuccess: () => {
setIsSettingsOpen(false);
refetchCustomFilters();
void logEvent('Quick Filters Settings: changes saved', {
addedFilters,
});
notifications.success({
message: 'Quick filters updated successfully',
placement: 'bottomRight',
});
},
onError: (error) => {
notifications.error({
message: error.message || SOMETHING_WENT_WRONG,
placement: 'bottomRight',
});
},
},
});
const debouncedUpdate = useDebouncedFn((value) => {
@@ -78,19 +98,32 @@ const useQuickFilterSettings = ({
}, [setIsSettingsOpen]);
const handleDiscardChanges = useCallback((): void => {
setAddedFilters(customFilters);
}, [customFilters, setAddedFilters]);
setAddedFilters(normalizedCustomFilters);
}, [normalizedCustomFilters, setAddedFilters]);
const hasUnsavedChanges = useMemo(
() =>
!(
addedFilters.length === normalizedCustomFilters.length &&
addedFilters.every(
(filter, index) => filter.key === normalizedCustomFilters[index].key,
)
),
[addedFilters, normalizedCustomFilters],
);
const handleSaveChanges = useCallback((): void => {
if (signal) {
updateCustomFilters({
pathParams: { source: signal },
data: {
// Send only the stored TelemetryFieldKey fields; the composite `key`
// is UI-only.
filters: addedFilters.map((filter) => ({
key: filter.key,
datatype: filter.dataType,
type: filter.type,
name: filter.name,
fieldContext: filter.fieldContext as TelemetrytypesFieldContextDTO,
fieldDataType: filter.fieldDataType as TelemetrytypesFieldDataTypeDTO,
})),
signal,
},
});
}
@@ -102,6 +135,7 @@ const useQuickFilterSettings = ({
addedFilters,
setAddedFilters,
handleSaveChanges,
hasUnsavedChanges,
isUpdatingCustomFilters,
inputValue,
setInputValue,

View File

@@ -1,8 +1,6 @@
import { useMemo } from 'react';
import { useQuery } from 'react-query';
import getCustomFilters from 'api/quickFilters/getCustomFilters';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
import { useGetQuickFilters } from 'api/generated/services/quick-filter';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { IQuickFiltersConfig, SignalType } from '../types';
import { getFilterConfig } from '../utils';
@@ -13,7 +11,7 @@ interface UseFilterConfigProps {
}
interface UseFilterConfigReturn {
filterConfig: IQuickFiltersConfig[];
customFilters: FilterType[];
customFilters: TelemetryFieldKey[];
isCustomFiltersLoading: boolean;
isDynamicFilters: boolean;
refetchCustomFilters: () => void;
@@ -25,17 +23,16 @@ const useFilterConfig = ({
}: UseFilterConfigProps): UseFilterConfigReturn => {
const {
isFetching: isCustomFiltersLoading,
data: customFilters = [],
data,
refetch,
} = useQuery<FilterType[], Error>(
[REACT_QUERY_KEY.GET_CUSTOM_FILTERS, signal],
async () => {
const res = await getCustomFilters({ signal: signal || '' });
return 'payload' in res && res.payload?.filters ? res.payload.filters : [];
},
{
enabled: !!signal,
},
} = useGetQuickFilters(
{ source: signal ?? '' },
{ query: { enabled: !!signal } },
);
const customFilters = useMemo<TelemetryFieldKey[]>(
() => (data?.data?.filters ?? []) as TelemetryFieldKey[],
[data],
);
const isDynamicFilters = useMemo(

View File

@@ -11,7 +11,7 @@ import {
} from 'mocks-server/__mockdata__/customQuickFilters';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
import '@testing-library/jest-dom';
@@ -34,9 +34,9 @@ const mockUseApiMonitoringParams = jest.mocked(useApiMonitoringParams);
const BASE_URL = ENVIRONMENT.baseURL;
const SIGNAL = SignalType.LOGS;
const quickFiltersListURL = `${BASE_URL}/api/v1/orgs/me/filters/${SIGNAL}`;
const saveQuickFiltersURL = `${BASE_URL}/api/v1/orgs/me/filters`;
const quickFiltersSuggestionsURL = `${BASE_URL}/api/v3/filter_suggestions`;
const quickFiltersListURL = `${BASE_URL}/api/v2/quick_filters/${SIGNAL}`;
const saveQuickFiltersURL = `${BASE_URL}/api/v2/quick_filters/${SIGNAL}`;
const quickFiltersSuggestionsURL = `${BASE_URL}/api/v1/fields/keys`;
const quickFiltersAttributeValuesURL = `${BASE_URL}/api/v3/autocomplete/attribute_values`;
const fieldsValuesURL = `${BASE_URL}/api/v1/fields/values`;
@@ -338,6 +338,63 @@ describe('Quick Filters with custom filters', () => {
);
});
it('keeps same-name fields with different context as distinct entries', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(quickFiltersSuggestionsURL, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
complete: true,
keys: {
level: [
{
name: 'level',
fieldContext: 'attribute',
fieldDataType: 'string',
signal: 'logs',
},
{
name: 'level',
fieldContext: 'span',
fieldDataType: 'string',
signal: 'logs',
},
],
},
},
}),
),
),
);
render(<TestQuickFilters signal={SIGNAL} />);
await screen.findByText(FILTER_SERVICE_NAME);
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
const settingsButton = icon.closest('button') ?? icon;
await user.click(settingsButton);
const otherSection = screen.getByText(OTHER_FILTERS_LABEL).parentElement!;
// Both `level` variants are shown despite sharing a name.
await waitFor(() =>
expect(within(otherSection).getAllByText('level')).toHaveLength(2),
);
// Adding one variant removes only that one; the other stays.
const firstLevel = within(otherSection).getAllByText('level')[0];
const addButton = firstLevel.parentElement?.querySelector('button');
await user.click(addButton as HTMLButtonElement);
const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!;
await waitFor(() => {
expect(within(addedSection).getAllByText('level')).toHaveLength(1);
expect(within(otherSection).getAllByText('level')).toHaveLength(1);
});
});
it('adds a filter from OTHER FILTERS to ADDED FILTERS when clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
@@ -456,12 +513,10 @@ describe('Quick Filters with custom filters', () => {
});
const requestBody = putHandler.mock.calls[0][0];
expect(requestBody.filters).toStrictEqual(
expect.arrayContaining([
expect.not.objectContaining({ key: FILTER_OS_DESCRIPTION }),
]),
expect(requestBody.filters).not.toContainEqual(
expect.objectContaining({ name: FILTER_OS_DESCRIPTION }),
);
expect(requestBody.signal).toBe(SIGNAL);
expect(requestBody.filters).toHaveLength(10);
});
it('should render duration slider for duration_nono filter', async () => {
@@ -612,9 +667,9 @@ describe('Quick Filters refetch behavior', () => {
filters: [
...(quickFiltersListResponse.data.filters ?? []),
{
key: 'new.custom.filter',
dataType: 'string',
type: 'resource',
name: 'new.custom.filter',
fieldDataType: 'string',
fieldContext: 'resource',
} as const,
],
},

View File

@@ -1,5 +1,7 @@
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
import { SIGNAL_DATA_SOURCE_MAP } from 'components/QuickFilters/QuickFiltersSettings/constants';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { fieldDataTypeToDataType } from 'utils/fieldDataType';
import { FiltersType, IQuickFiltersConfig, SignalType } from './types';
@@ -12,6 +14,19 @@ const FILTER_TYPE_MAP: Record<string, FiltersType> = {
duration_nano: FiltersType.DURATION,
};
// The map below exists only for the old v3 attribute-values fetch
// (useCheckboxFilterValues), the sole reader of attributeKey.dataType/type.
// Once the values fetch moves to fields/values, remove this and reduce
// attributeKey to { id, key }.
const FIELD_CONTEXT_TO_ATTRIBUTE_TYPE: Record<string, string> = {
[TelemetrytypesFieldContextDTO.attribute]: 'tag',
[TelemetrytypesFieldContextDTO.resource]: 'resource',
};
const mapFieldContext = (fieldContext?: string): string =>
(fieldContext && FIELD_CONTEXT_TO_ATTRIBUTE_TYPE[fieldContext]) || '';
const getFilterName = (str: string): string => {
if (FILTER_TITLE_MAP[str]) {
return FILTER_TITLE_MAP[str];
@@ -26,16 +41,16 @@ const getFilterName = (str: string): string => {
.join(' ');
};
const getFilterType = (att: FilterType): FiltersType => {
if (FILTER_TYPE_MAP[att.key]) {
return FILTER_TYPE_MAP[att.key];
const getFilterType = (att: TelemetryFieldKey): FiltersType => {
if (FILTER_TYPE_MAP[att.name]) {
return FILTER_TYPE_MAP[att.name];
}
return FiltersType.CHECKBOX;
};
export const getFilterConfig = (
signal?: SignalType,
customFilters?: FilterType[],
customFilters?: TelemetryFieldKey[],
config?: IQuickFiltersConfig[],
): IQuickFiltersConfig[] => {
if (!customFilters?.length || !signal) {
@@ -46,13 +61,13 @@ export const getFilterConfig = (
(att, index) =>
({
type: getFilterType(att),
title: getFilterName(att.key),
title: getFilterName(att.name),
dataSource: SIGNAL_DATA_SOURCE_MAP[signal],
attributeKey: {
id: att.key,
key: att.key,
dataType: att.dataType,
type: att.type,
id: att.name,
key: att.name,
dataType: fieldDataTypeToDataType(att.fieldDataType),
type: mapFieldContext(att.fieldContext),
},
defaultOpen: index < 2,
}) as IQuickFiltersConfig,

View File

@@ -4,14 +4,18 @@ import { refreshLicense } from 'api/generated/services/licenses';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { RefreshCcw } from '@signozhq/icons';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { buildLicenseUpdatePermission } from 'lib/authz/hooks/useAuthZ/permissions/license.permissions';
import { useAppContext } from 'providers/App/App';
function RefreshPaymentStatus({
type,
className,
withPortal,
}: {
type?: 'button' | 'text' | 'tooltip';
className?: string;
withPortal?: false;
}): JSX.Element {
const { t } = useTranslation(['failedPayment']);
const { activeLicense, activeLicenseRefetch } = useAppContext();
@@ -36,17 +40,25 @@ function RefreshPaymentStatus({
};
const button = (
<Button
variant="link"
color={type === 'text' ? 'none' : 'secondary'}
size="md"
className={className}
onClick={handleRefreshPaymentStatus}
prefix={<RefreshCcw size={14} />}
loading={isLoading}
<AuthZTooltip
checks={
activeLicense ? [buildLicenseUpdatePermission(activeLicense.id)] : []
}
enabled={!!activeLicense}
withPortal={withPortal}
>
{type !== 'tooltip' ? t('refreshPaymentStatus') : ''}
</Button>
<Button
variant="link"
color={type === 'text' ? 'none' : 'secondary'}
size="md"
className={className}
onClick={handleRefreshPaymentStatus}
prefix={<RefreshCcw size={14} />}
loading={isLoading}
>
{type !== 'tooltip' ? t('refreshPaymentStatus') : ''}
</Button>
</AuthZTooltip>
);
return (
@@ -62,6 +74,7 @@ function RefreshPaymentStatus({
RefreshPaymentStatus.defaultProps = {
type: 'button',
className: undefined,
withPortal: undefined,
};
export default RefreshPaymentStatus;

View File

@@ -348,6 +348,19 @@ export const initialQueryMeterWithType: Query = {
},
};
export const initialQueryAIWithType: Query = {
...initialQueryWithType,
builder: {
...initialQueryWithType.builder,
queryData: [
{
...initialQueryBuilderFormValuesMap.traces,
builderQueryType: 'builder_ai_query',
},
],
},
};
export const operatorsByTypes: Record<LocalDataType, string[]> = {
string: Object.values(StringOperators),
number: Object.values(NumberOperators),

View File

@@ -15,7 +15,6 @@ export const REACT_QUERY_KEY = {
GET_ALL_DASHBOARDS: 'GET_ALL_DASHBOARDS',
GET_TRIGGERED_ALERTS: 'GET_TRIGGERED_ALERTS',
DASHBOARD_BY_ID: 'DASHBOARD_BY_ID',
GET_BILLING_USAGE: 'GET_BILLING_USAGE',
GET_FEATURES_FLAGS: 'GET_FEATURES_FLAGS',
DELETE_DASHBOARD: 'DELETE_DASHBOARD',
LOGS_PIPELINE_PREVIEW: 'LOGS_PIPELINE_PREVIEW',

View File

@@ -16,11 +16,13 @@ import * as Sentry from '@sentry/react';
import { Toaster } from '@signozhq/ui/sonner';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { Flex } from 'antd';
import { Button } from '@signozhq/ui/button';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import getChangelogByVersion from 'api/changelog/getChangelogByVersion';
import logEvent from 'api/common/logEvent';
import manageCreditCardApi from 'api/v1/portal/create';
import { updateSubscription } from 'api/generated/services/subscriptions';
import type { UpdateSubscription200 } from 'api/generated/services/sigNoz.schemas';
import updateUserPreference from 'api/v1/user/preferences/name/update';
import getUserVersion from 'api/v1/version/get';
import getUserLatestVersion from 'api/v1/version/getLatestVersion';
@@ -30,6 +32,8 @@ import ChangelogModal from 'components/ChangelogModal/ChangelogModal';
import ChatSupportGateway from 'components/ChatSupportGateway/ChatSupportGateway';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import RefreshPaymentStatus from 'components/RefreshPaymentStatus/RefreshPaymentStatus';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { SubscriptionManagePermissions } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import { MIN_ACCOUNT_AGE_FOR_CHANGELOG } from 'constants/changelog';
import { Events } from 'constants/events';
import { FeatureKeys } from 'constants/features';
@@ -63,8 +67,7 @@ import {
UPDATE_LATEST_VERSION,
UPDATE_LATEST_VERSION_ERROR,
} from 'types/actions/app';
import { ErrorResponse, SuccessResponse, SuccessResponseV2 } from 'types/api';
import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
ChangelogSchema,
DeploymentType,
@@ -77,7 +80,6 @@ import {
} from 'types/api/licensesV3/getActive';
import { UserPreference } from 'types/api/preferences/preference';
import AppReducer from 'types/reducer/app';
import { USER_ROLES } from 'types/roles';
import { getBaseUrl } from 'utils/basePath';
import { showErrorNotification } from 'utils/error';
import { eventEmitter } from 'utils/getEventEmitter';
@@ -166,9 +168,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
return Math.abs(currentDate.diff(userCreationDate, 'day'));
}, [user.createdAt]);
const handleBillingOnSuccess = (
data: SuccessResponseV2<CheckoutSuccessPayloadProps>,
): void => {
const handleBillingOnSuccess = (data: UpdateSubscription200): void => {
if (data?.data?.redirectURL) {
const newTab = document.createElement('a');
newTab.href = data.data.redirectURL;
@@ -186,7 +186,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
};
const { mutate: manageCreditCard, isLoading: isLoadingManageBilling } =
useMutation(manageCreditCardApi, {
useMutation(updateSubscription, {
onSuccess: (data) => {
handleBillingOnSuccess(data);
},
@@ -469,10 +469,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
}, [isLoggedIn]);
const handleUpgrade = useCallback((): void => {
if (user.role === USER_ROLES.ADMIN) {
history.push(ROUTES.BILLING);
}
}, [user.role]);
history.push(ROUTES.BILLING);
}, []);
const handleFailedPayment = useCallback((): void => {
manageCreditCard({
@@ -586,25 +584,21 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
<div>
Our systems are taking longer than expected for your trial workspace.
Please{' '}
{user.role === USER_ROLES.ADMIN ? (
<span>
<a
className="upgrade-link"
onClick={(): void => {
notifications.destroy('slow-api-warning');
<span>
<a
className="upgrade-link"
onClick={(): void => {
notifications.destroy('slow-api-warning');
logEvent(`Slow API Banner: Upgrade clicked`, {});
logEvent(`Slow API Banner: Upgrade clicked`, {});
handleUpgrade();
}}
>
upgrade
</a>
your workspace for a smoother experience.
</span>
) : (
'contact your administrator for upgrading to a paid plan for a smoother experience.'
)}
handleUpgrade();
}}
>
upgrade
</a>
your workspace for a smoother experience.
</span>
</div>
),
duration: 60000,
@@ -794,22 +788,18 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
<div className="trial-expiry-banner">
You are in free trial period. Your free trial will end on{' '}
<span>{getFormattedDate(trialInfo?.trialEnd || Date.now())}.</span>
{user.role === USER_ROLES.ADMIN ? (
<span>
<span>
{' '}
Please{' '}
<a className="upgrade-link" onClick={handleUpgrade}>
upgrade
</a>
to continue using SigNoz features.
<span className="refresh-payment-status">
{' '}
Please{' '}
<a className="upgrade-link" onClick={handleUpgrade}>
upgrade
</a>
to continue using SigNoz features.
<span className="refresh-payment-status">
{' '}
| Already upgraded? <RefreshPaymentStatus type="text" />
</span>
| Already upgraded? <RefreshPaymentStatus type="text" />
</span>
) : (
'Please contact your administrator for upgrading to a paid plan.'
)}
</span>
</div>
)}
@@ -826,22 +816,25 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
)}
.
</span>
{user.role === USER_ROLES.ADMIN ? (
<span>
{' '}
Please{' '}
<a className="upgrade-link" onClick={handleFailedPayment}>
<span>
{' '}
Please{' '}
<AuthZTooltip checks={SubscriptionManagePermissions}>
<Button
variant="link"
color="none"
className="upgrade-link"
onClick={handleFailedPayment}
>
pay the bill
</a>
to continue using SigNoz features.
<span className="refresh-payment-status">
{' '}
| Already paid? <RefreshPaymentStatus type="text" />
</span>
</Button>
</AuthZTooltip>
to continue using SigNoz features.
<span className="refresh-payment-status">
{' '}
| Already paid? <RefreshPaymentStatus type="text" />
</span>
) : (
' Please contact your administrator to pay the bill.'
)}
</span>
</div>
)}
</div>

View File

@@ -0,0 +1,113 @@
import {
SubscriptionCreatePermission,
SubscriptionReadPermission,
SubscriptionUpdatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import {
setupAuthzAdmin,
setupAuthzAllow,
setupAuthzDeny,
} from 'lib/authz/utils/authz-test-utils';
import { trialConvertedToSubscriptionResponse } from 'mocks-server/__mockdata__/licenses';
import { server } from 'mocks-server/server';
import { render, screen, waitFor } from 'tests/test-utils';
import BillingContainer from './BillingContainer';
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({
disconnect: jest.fn(),
observe: jest.fn(),
unobserve: jest.fn(),
}));
describe('BillingContainer - AuthZ', () => {
afterEach(() => {
server.resetHandlers();
});
it('renders usage and enables actions when all subscription permissions are granted', async () => {
server.use(setupAuthzAdmin());
render(<BillingContainer />);
await expect(
screen.findByRole('columnheader', { name: /data ingested/i }),
).resolves.toBeInTheDocument();
await waitFor(() => {
expect(screen.getByTestId('header-billing-button')).toBeEnabled();
});
expect(screen.queryByText(/not authorized/i)).not.toBeInTheDocument();
});
it('blocks the usage section when subscription read is denied', async () => {
server.use(setupAuthzDeny(SubscriptionReadPermission));
render(<BillingContainer />);
await expect(
screen.findByText(/not authorized/i),
).resolves.toBeInTheDocument();
expect(screen.getByTestId('header-billing-button')).toBeInTheDocument();
expect(
screen.queryByRole('columnheader', { name: /data ingested/i }),
).not.toBeInTheDocument();
});
it('disables upgrade when subscription create is denied', async () => {
server.use(setupAuthzAllow(SubscriptionReadPermission));
render(<BillingContainer />);
await waitFor(() => {
expect(screen.getByTestId('header-billing-button')).toBeDisabled();
});
expect(screen.getByTestId('upgrade-plan-button')).toBeDisabled();
});
it('disables manage billing when subscription update is denied', async () => {
server.use(
setupAuthzAllow(SubscriptionReadPermission, SubscriptionCreatePermission),
);
render(
<BillingContainer />,
{},
{
appContextOverrides: {
trialInfo: trialConvertedToSubscriptionResponse.data,
},
},
);
await waitFor(() => {
expect(screen.getByTestId('header-billing-button')).toBeDisabled();
});
expect(screen.queryByTestId('upgrade-plan-button')).not.toBeInTheDocument();
});
it('disables manage billing when subscription list is denied', async () => {
server.use(
setupAuthzAllow(
SubscriptionReadPermission,
SubscriptionCreatePermission,
SubscriptionUpdatePermission,
),
);
render(
<BillingContainer />,
{},
{
appContextOverrides: {
trialInfo: trialConvertedToSubscriptionResponse.data,
},
},
);
await waitFor(() => {
expect(screen.getByTestId('header-billing-button')).toBeDisabled();
});
});
});

View File

@@ -4,7 +4,7 @@
margin: 0 auto var(--spacing-20);
.pageHeader {
margin-bottom: var(--spacing-8);
margin-bottom: var(--spacing-4);
.pageHeaderTitle {
font-weight: var(--label-medium-500-font-weight);
@@ -41,6 +41,8 @@
}
.pageInfo {
margin-bottom: var(--spacing-4);
:global(.ant-card) {
padding: var(--padding-3);
}
@@ -59,7 +61,7 @@
}
.billingDetails {
margin: var(--spacing-12) 0;
margin: var(--spacing-4) 0;
border: 1px solid var(--l1-border);
border-radius: 2px;
overflow: hidden;
@@ -128,7 +130,7 @@
}
.upgradePlanBenefits {
margin: 0 var(--spacing-4);
margin: 0;
border: 1px solid var(--l1-border);
border-radius: 5px;
padding: 0 var(--padding-12);
@@ -176,7 +178,7 @@
}
.billingUpdateNote {
margin-top: var(--spacing-8);
margin-top: var(--spacing-4);
font-family: var(--font-family-inter);
font-size: var(--font-size-sm);
font-style: normal;

View File

@@ -1,9 +1,11 @@
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { billingSuccessResponse } from 'mocks-server/__mockdata__/billing';
import {
licensesSuccessResponse,
notOfTrailResponse,
trialConvertedToSubscriptionResponse,
} from 'mocks-server/__mockdata__/licenses';
import { server } from 'mocks-server/server';
import { act, render, screen, getAppContextMock } from 'tests/test-utils';
import APIError from 'types/api/error';
import {
@@ -15,11 +17,6 @@ import { getFormattedDate } from 'utils/timeUtils';
import BillingContainer from './BillingContainer';
jest.mock('hooks/useActiveLicenseKey/useActiveLicenseKey', () => ({
__esModule: true,
default: jest.fn(() => ({ licenseKey: 'test-key', isLoading: false })),
}));
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({
@@ -31,14 +28,22 @@ window.ResizeObserver =
describe('BillingContainer', () => {
jest.setTimeout(30000);
beforeEach(() => {
server.use(setupAuthzAdmin());
});
afterEach(() => {
server.resetHandlers();
});
it('Component should render', async () => {
render(<BillingContainer />);
const dataInjection = screen.getByRole('columnheader', {
const dataInjection = await screen.findByRole('columnheader', {
name: /data ingested/i,
});
expect(dataInjection).toBeInTheDocument();
const pricePerUnit = screen.getByRole('columnheader', {
const pricePerUnit = await screen.findByRole('columnheader', {
name: /price per unit/i,
});
expect(pricePerUnit).toBeInTheDocument();
@@ -49,13 +54,15 @@ describe('BillingContainer', () => {
const dayRemainingInBillingPeriod = await screen.findByText(
/Please upgrade plan now to retain your data./i,
{},
{ timeout: 5000 },
);
expect(dayRemainingInBillingPeriod).toBeInTheDocument();
const upgradePlanButton = screen.getByTestId('upgrade-plan-button');
expect(upgradePlanButton).toBeInTheDocument();
const dollar = await screen.findByText(/\$1,278.3/i);
const dollar = await screen.findByText(/\$1,278.3/i, {}, { timeout: 5000 });
expect(dollar).toBeInTheDocument();
const currentBill = await screen.findByText('billing');
@@ -86,7 +93,9 @@ describe('BillingContainer', () => {
await expect(screen.findByText('Free Trial')).resolves.toBeInTheDocument();
await expect(screen.findByText('billing')).resolves.toBeInTheDocument();
await expect(screen.findByText(/\$0/i)).resolves.toBeInTheDocument();
await expect(
screen.findByText(/\$0/i, {}, { timeout: 5000 }),
).resolves.toBeInTheDocument();
await expect(
screen.findByText(
@@ -132,7 +141,7 @@ describe('BillingContainer', () => {
const currentBill = await screen.findByText('billing');
expect(currentBill).toBeInTheDocument();
const dollar0 = await screen.findByText(/\$0/i);
const dollar0 = await screen.findByText(/\$0/i, {}, { timeout: 5000 });
expect(dollar0).toBeInTheDocument();
const onTrail = await screen.findByText(
@@ -250,7 +259,11 @@ describe('BillingContainer', () => {
billingSuccessResponse.data.billingPeriodStart,
)} to ${getFormattedDate(billingSuccessResponse.data.billingPeriodEnd)}`;
const billingPeriod = await findByText(billingPeriodText);
const billingPeriod = await findByText(
billingPeriodText,
{},
{ timeout: 5000 },
);
expect(billingPeriod).toBeInTheDocument();
const currentBill = await screen.findByText('billing');

View File

@@ -3,7 +3,7 @@ import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery } from 'react-query';
import { useMutation } from 'react-query';
import { CircleCheck, Landmark, MonitorDown } from '@signozhq/icons';
import {
Card,
@@ -15,25 +15,35 @@ import {
TableColumnsType as ColumnsType,
} from 'antd';
import { Badge } from '@signozhq/ui/badge';
import getUsage, {
BreakdownEntry,
UsageResponsePayloadProps,
} from 'api/billing/getUsage';
import logEvent from 'api/common/logEvent';
import updateCreditCardApi from 'api/v1/checkout/create';
import manageCreditCardApi from 'api/v1/portal/create';
import type {
CreateSubscription201,
GetSubscription200,
SubscriptiontypesGettableSubscriptionUsageDTO,
SubscriptiontypesSubscriptionUsageBreakdownDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
createSubscription,
updateSubscription,
useGetSubscription,
} from 'api/generated/services/subscriptions';
import RefreshPaymentStatus from 'components/RefreshPaymentStatus/RefreshPaymentStatus';
import Spinner from 'components/Spinner';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import useAxiosError from 'hooks/useAxiosError';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useNotifications } from 'hooks/useNotifications';
import { isEmpty, pick } from 'lodash-es';
import useActiveLicenseKey from 'hooks/useActiveLicenseKey/useActiveLicenseKey';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
import {
SubscriptionCreatePermission,
SubscriptionManagePermissions,
SubscriptionReadPermission,
} from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { useAppContext } from 'providers/App/App';
import { ErrorResponse, SuccessResponse, SuccessResponseV2 } from 'types/api';
import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
import { getBaseUrl } from 'utils/basePath';
import { getFormattedDate, getRemainingDays } from 'utils/timeUtils';
@@ -135,7 +145,7 @@ export default function BillingContainer(): JSX.Element {
const [isFreeTrial, setIsFreeTrial] = useState(false);
const [data, setData] = useState<DataType[]>([]);
const [apiResponse, setApiResponse] = useState<
Partial<UsageResponsePayloadProps>
Partial<SubscriptiontypesGettableSubscriptionUsageDTO>
>({});
const {
@@ -146,7 +156,8 @@ export default function BillingContainer(): JSX.Element {
activeLicense,
activeLicenseFetchError,
} = useAppContext();
const { licenseKey } = useActiveLicenseKey();
const { allowed: canReadSubscription, error: subscriptionAuthZError } =
useAuthZ([SubscriptionReadPermission]);
const { notifications } = useNotifications();
const handleError = useAxiosError();
@@ -154,33 +165,34 @@ export default function BillingContainer(): JSX.Element {
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
const processUsageData = useCallback(
(data: SuccessResponse<UsageResponsePayloadProps> | ErrorResponse): void => {
if (isEmpty(data?.payload)) {
(response: GetSubscription200): void => {
const usage = response?.data;
if (isEmpty(usage)) {
return;
}
const {
details: { breakdown = [], billTotal },
billingPeriodStart,
billingPeriodEnd,
} = (data as SuccessResponse<UsageResponsePayloadProps>).payload;
const breakdown = usage.details?.breakdown ?? [];
const billTotal = usage.details?.billTotal ?? 0;
const billingPeriodStart = usage.billingPeriodStart ?? 0;
const billingPeriodEnd = usage.billingPeriodEnd ?? 0;
const formattedUsageData: DataType[] = [];
if (breakdown && Array.isArray(breakdown)) {
for (let index = 0; index < breakdown.length; index += 1) {
const element: BreakdownEntry = breakdown[index];
element?.tiers?.forEach((tier, i: number) => {
breakdown.forEach(
(
element: SubscriptiontypesSubscriptionUsageBreakdownDTO,
index: number,
) => {
element?.tiers?.forEach((tier, tierIndex: number) => {
formattedUsageData.push({
key: `${index}${i}`,
name: i === 0 ? element?.type : '',
key: `${index}${tierIndex}`,
name: tierIndex === 0 ? (element?.type ?? '') : '',
unit: element?.unit ?? '',
dataIngested: `${tier.quantity} ${element?.unit}`,
pricePerUnit: String(tier.unitPrice),
cost: `$ ${tier.tierCost}`,
});
});
}
}
},
);
setData(formattedUsageData);
@@ -196,7 +208,7 @@ export default function BillingContainer(): JSX.Element {
setBillAmount(billTotal);
}
setApiResponse(data?.payload || {});
setApiResponse(usage);
},
[trialInfo?.onTrial],
);
@@ -208,11 +220,12 @@ export default function BillingContainer(): JSX.Element {
isLoading,
isFetching: isFetchingBillingData,
data: billingData,
} = useQuery([REACT_QUERY_KEY.GET_BILLING_USAGE, user?.id], {
queryFn: () => getUsage(licenseKey || ''),
onError: handleError,
enabled: !!licenseKey,
onSuccess: processUsageData,
} = useGetSubscription({
query: {
enabled: canReadSubscription || !!subscriptionAuthZError,
onError: handleError,
onSuccess: processUsageData,
},
});
useEffect(() => {
@@ -284,9 +297,7 @@ export default function BillingContainer(): JSX.Element {
/>
);
const handleBillingOnSuccess = (
data: SuccessResponseV2<CheckoutSuccessPayloadProps>,
): void => {
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
if (data?.data?.redirectURL) {
const newTab = document.createElement('a');
newTab.href = data.data.redirectURL;
@@ -303,7 +314,7 @@ export default function BillingContainer(): JSX.Element {
};
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
updateCreditCardApi,
createSubscription,
{
onSuccess: (data) => {
handleBillingOnSuccess(data);
@@ -313,7 +324,7 @@ export default function BillingContainer(): JSX.Element {
);
const { mutate: manageCreditCard, isLoading: isLoadingManageBilling } =
useMutation(manageCreditCardApi, {
useMutation(updateSubscription, {
onSuccess: (data) => {
handleBillingOnSuccess(data);
},
@@ -348,15 +359,21 @@ export default function BillingContainer(): JSX.Element {
updateCreditCard,
]);
const billingActionPermissions = trialInfo?.trialConvertedToSubscription
? SubscriptionManagePermissions
: [SubscriptionCreatePermission];
const subscriptionPastDueMessage = (): JSX.Element => (
<Typography>
{`We were not able to process payments for your account. Please update your card details `}
<Typography.Link
onClick={handleBilling}
style={{ cursor: 'pointer', color: 'var(--bg-cherry-500)' }}
>
{t('here')}
</Typography.Link>
<AuthZTooltip checks={billingActionPermissions}>
<Typography.Link
onClick={handleBilling}
style={{ cursor: 'pointer', color: 'var(--bg-cherry-500)' }}
>
{t('here')}
</Typography.Link>
</AuthZTooltip>
{` if your payment information has changed. Email us at `}
<Typography.Text color="muted">cloud-support@signoz.io</Typography.Text>
{` otherwise. Be sure to provide this information immediately to avoid interruption to your service.`}
@@ -411,11 +428,7 @@ export default function BillingContainer(): JSX.Element {
</Typography.Text>
</Flex>
<Card
bordered={false}
style={{ minHeight: 150, marginBottom: 16 }}
className={styles.pageInfo}
>
<Card bordered={false} className={styles.pageInfo}>
<Flex justify="space-between" align="center">
<Flex vertical gap={8}>
<p className={styles.pageInfoTitle}>
@@ -423,13 +436,14 @@ export default function BillingContainer(): JSX.Element {
{isFreeTrial ? <Badge color="success"> Free Trial </Badge> : ''}
</p>
{!isLoading && !isFetchingBillingData && !showGracePeriodMessage ? (
{billingData && !isFetchingBillingData && !showGracePeriodMessage ? (
<p className={styles.pageInfoSubtitle}>
{daysRemaining} {daysRemainingStr}
</p>
) : null}
</Flex>
<Button
<AuthZButton
checks={billingActionPermissions}
testId="header-billing-button"
variant="solid"
color="secondary"
@@ -443,7 +457,7 @@ export default function BillingContainer(): JSX.Element {
{trialInfo?.trialConvertedToSubscription
? t('manage_billing')
: t('upgrade_plan')}
</Button>
</AuthZButton>
</Flex>
{trialInfo?.onTrial && trialInfo?.trialConvertedToSubscription && (
@@ -495,66 +509,73 @@ export default function BillingContainer(): JSX.Element {
))}
</Card>
<div className={styles.billingGraphSection}>
{!isLoading && !isFetchingBillingData ? (
<BillingUsageGraph data={apiResponse} billAmount={billAmount} />
) : (
<Card className={styles.emptyGraphCard} bordered={false}>
<Spinner size="large" tip="Loading..." height="35vh" />
</Card>
)}
{!isLoading && !isFetchingBillingData && (
<div className={styles.billingGraphFooter}>
<Button
variant="outlined"
color="secondary"
size="md"
onClick={handleCsvDownload}
prefix={<MonitorDown size={14} />}
testId="download-csv-button"
className={styles.billingFooterBtn}
>
Download CSV
</Button>
<RefreshPaymentStatus type="button" className={styles.billingFooterBtn} />
<AuthZGuardContent checks={[SubscriptionReadPermission]}>
<>
<div className={styles.billingGraphSection}>
{!isLoading && !isFetchingBillingData ? (
<BillingUsageGraph data={apiResponse} billAmount={billAmount} />
) : (
<Card className={styles.emptyGraphCard} bordered={false}>
<Spinner size="large" tip="Loading..." height="35vh" />
</Card>
)}
{!isLoading && !isFetchingBillingData && (
<div className={styles.billingGraphFooter}>
<Button
variant="outlined"
color="secondary"
size="md"
onClick={handleCsvDownload}
prefix={<MonitorDown size={14} />}
testId="download-csv-button"
className={styles.billingFooterBtn}
>
Download CSV
</Button>
<RefreshPaymentStatus
type="button"
className={styles.billingFooterBtn}
/>
</div>
)}
</div>
)}
</div>
{!isLoading && !isFetchingBillingData && (
<Callout type="info" size="small" className={styles.billingUpdateNote}>
Billing metrics are updated once every 24 hours.
</Callout>
)}
{!isLoading && !isFetchingBillingData && (
<Callout type="info" size="small" className={styles.billingUpdateNote}>
Billing metrics are updated once every 24 hours.
</Callout>
)}
<div className={styles.billingDetails}>
{!isLoading && !isFetchingBillingData && (
<Table
columns={columns}
dataSource={data}
pagination={false}
bordered={false}
components={{
header: {
cell: ({
style,
...props
}: React.ThHTMLAttributes<HTMLTableCellElement>): JSX.Element => {
const { background: _, boxShadow: __, ...safeStyle } = style ?? {};
return (
<th
{...props}
style={safeStyle}
className={`${props.className ?? ''} ${styles.billingDetailsHeaderCell}`}
/>
);
},
},
}}
/>
)}
<div className={styles.billingDetails}>
{!isLoading && !isFetchingBillingData && (
<Table
columns={columns}
dataSource={data}
pagination={false}
bordered={false}
components={{
header: {
cell: ({
style,
...props
}: React.ThHTMLAttributes<HTMLTableCellElement>): JSX.Element => {
const { background: _, boxShadow: __, ...safeStyle } = style ?? {};
return (
<th
{...props}
style={safeStyle}
className={`${props.className ?? ''} ${styles.billingDetailsHeaderCell}`}
/>
);
},
},
}}
/>
)}
{(isLoading || isFetchingBillingData) && renderTableSkeleton()}
</div>
{(isLoading || isFetchingBillingData) && renderTableSkeleton()}
</div>
</>
</AuthZGuardContent>
{isCloudUserVal && activeLicense?.state === LicenseState.ACTIVATED && (
<CancelSubscriptionBanner />
@@ -597,7 +618,8 @@ export default function BillingContainer(): JSX.Element {
</Typography.Text>
</Col>
<Col span={4} style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
<AuthZButton
checks={[SubscriptionCreatePermission]}
testId="upgrade-plan-button"
variant="solid"
color="primary"
@@ -606,7 +628,7 @@ export default function BillingContainer(): JSX.Element {
onClick={handleBilling}
>
{t('upgrade_plan')}
</Button>
</AuthZButton>
</Col>
</Row>
</div>

View File

@@ -12,7 +12,7 @@ import {
} from 'lib/uPlotV2/components/types';
import type { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import type uPlot from 'uplot';
import type { UsageResponsePayloadProps } from 'api/billing/getUsage';
import type { SubscriptiontypesGettableSubscriptionUsageDTO } from 'api/generated/services/sigNoz.schemas';
import { BillingBarChartTooltip } from './BillingBarChartTooltip';
import { prepareBillingBarConfig } from './prepareBillingBarConfig';
@@ -25,7 +25,7 @@ import {
import styles from './BillingUsageGraph.module.scss';
interface BillingUsageGraphProps {
data: Partial<UsageResponsePayloadProps>;
data: Partial<SubscriptiontypesGettableSubscriptionUsageDTO>;
billAmount: number;
}
@@ -55,7 +55,7 @@ export function BillingUsageGraph(props: BillingUsageGraphProps): JSX.Element {
const currentDay = breakdown.dayWiseBreakdown.breakdown[0];
const nextDay = {
...currentDay,
timestamp: currentDay.timestamp + 86400,
timestamp: (currentDay.timestamp ?? 0) + 86400,
count: 0,
size: 0,
quantity: 0,
@@ -94,7 +94,9 @@ export function BillingUsageGraph(props: BillingUsageGraphProps): JSX.Element {
const { startTime, endTime } = useMemo(
() =>
calculateStartEndTime(normalizedData as Partial<UsageResponsePayloadProps>),
calculateStartEndTime(
normalizedData as Partial<SubscriptiontypesGettableSubscriptionUsageDTO>,
),
[normalizedData],
);

View File

@@ -1,4 +1,4 @@
import { UsageResponsePayloadProps } from 'api/billing/getUsage';
import { SubscriptiontypesGettableSubscriptionUsageDTO } from 'api/generated/services/sigNoz.schemas';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
@@ -117,7 +117,9 @@ export function csvFileName(csvData: QuantityData[]): string {
return `billing_usage_(${startDate}-${endDate}).csv`;
}
export function prepareCsvData(data: Partial<UsageResponsePayloadProps>): {
export function prepareCsvData(
data: Partial<SubscriptiontypesGettableSubscriptionUsageDTO>,
): {
csvData: string;
fileName: string;
} {
@@ -135,12 +137,14 @@ export function prepareCsvData(data: Partial<UsageResponsePayloadProps>): {
}
export function calculateStartEndTime(
data: Partial<UsageResponsePayloadProps>,
data: Partial<SubscriptiontypesGettableSubscriptionUsageDTO>,
): { startTime: number | undefined; endTime: number | undefined } {
const timestamps: number[] = [];
data?.details?.breakdown?.forEach((breakdown) => {
breakdown?.dayWiseBreakdown?.breakdown?.forEach((entry) => {
timestamps.push(entry.timestamp);
if (typeof entry.timestamp === 'number') {
timestamps.push(entry.timestamp);
}
});
});

View File

@@ -6,7 +6,7 @@
border-radius: 4px;
border: 1px solid var(--l1-border);
background-color: var(--l2-background);
margin: var(--spacing-4) 0 var(--spacing-12);
margin: var(--spacing-4) 0;
}
.info {

View File

@@ -1,3 +1,9 @@
import { SubscriptionDeletePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import {
setupAuthzAdmin,
setupAuthzDeny,
} from 'lib/authz/utils/authz-test-utils';
import { server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import CancelSubscriptionBanner from './CancelSubscriptionBanner';
@@ -36,10 +42,24 @@ function mockMailto(): {
}
describe('CancelSubscriptionBanner', () => {
beforeEach(() => {
server.use(setupAuthzAdmin());
});
afterEach(() => {
server.resetHandlers();
jest.restoreAllMocks();
});
it('disables Cancel Subscription when subscription delete is denied', async () => {
server.use(setupAuthzDeny(SubscriptionDeletePermission));
render(<CancelSubscriptionBanner />);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeDisabled();
});
});
it('renders banner with title and subtitle', () => {
render(<CancelSubscriptionBanner />);
expect(
@@ -56,9 +76,10 @@ describe('CancelSubscriptionBanner', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('cancel-subscription-btn'));
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(
@@ -76,9 +97,10 @@ describe('CancelSubscriptionBanner', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('cancel-subscription-btn'));
const confirmButton = screen.getByTestId('cancel-subscription-confirm-btn');
expect(confirmButton).toBeDisabled();
@@ -95,9 +117,10 @@ describe('CancelSubscriptionBanner', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('cancel-subscription-btn'));
const input = screen.getByTestId('cancel-confirm-input');
await user.type(input, 'cancel');
@@ -107,9 +130,10 @@ describe('CancelSubscriptionBanner', () => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('cancel-subscription-btn'));
expect(screen.getByTestId('cancel-confirm-input')).toHaveValue('');
});
@@ -119,9 +143,10 @@ describe('CancelSubscriptionBanner', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('cancel-subscription-btn'));
await user.type(screen.getByTestId('cancel-confirm-input'), 'cancel');
await user.click(screen.getByTestId('cancel-subscription-confirm-btn'));
@@ -151,9 +176,10 @@ describe('CancelSubscriptionBanner', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('cancel-subscription-btn'));
await user.type(screen.getByTestId('cancel-confirm-input'), 'cancel');
await user.click(screen.getByTestId('cancel-subscription-confirm-btn'));
@@ -172,9 +198,10 @@ describe('CancelSubscriptionBanner', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('cancel-subscription-btn'));
await user.type(screen.getByTestId('cancel-confirm-input'), 'cancel');
await user.click(screen.getByTestId('cancel-subscription-confirm-btn'));
@@ -192,9 +219,10 @@ describe('CancelSubscriptionBanner', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('cancel-subscription-btn'));
await user.type(screen.getByTestId('cancel-confirm-input'), 'cancel');
await user.click(screen.getByTestId('cancel-subscription-confirm-btn'));

View File

@@ -11,6 +11,8 @@ import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Input } from '@signozhq/ui/input';
import logEvent from 'api/common/logEvent';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { SubscriptionDeletePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import { pick } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { useCopyToClipboard } from 'react-use';
@@ -178,15 +180,17 @@ function CancelSubscriptionBanner(): JSX.Element {
immediately and removed from our servers.
</span>
</div>
<Button
<AuthZButton
checks={[SubscriptionDeletePermission]}
variant="solid"
color="secondary"
prefix={<X size={12} />}
onClick={handleOpenCancelDialog}
className={styles.cancelButton}
testId="cancel-subscription-btn"
>
Cancel Subscription
</Button>
</AuthZButton>
</div>
<DialogWrapper
open={dialogView !== null}

View File

@@ -11,17 +11,13 @@ import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import WarningPopover from 'components/WarningPopover/WarningPopover';
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
import { useOptionsMenu } from 'container/OptionsMenu';
import LeftToolbarActions from 'container/QueryBuilder/components/ToolbarActions/LeftToolbarActions';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import Toolbar from 'container/Toolbar/Toolbar';
import {
getExportQueryData,
getQueryByPanelType,
} from 'container/TracesExplorer/explorerUtils';
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
@@ -52,6 +48,7 @@ import {
import { v4 } from 'uuid';
import { TOOLBAR_VIEWS } from './constants';
import { getExportQueryData, getQueryByPanelType } from './explorerUtils';
import ListView from './ListView/ListView';
import { defaultSelectedColumns } from './ListView/configs';
import QuerySection from './QuerySection/QuerySection';
@@ -118,7 +115,7 @@ function Explorer(): JSX.Element {
const defaultQuery = useMemo(
(): Query =>
updateAllQueriesOperators(
initialQueriesMap.traces,
initialQueryAIWithType,
PANEL_TYPES.LIST,
DataSource.TRACES,
),
@@ -185,7 +182,7 @@ function Explorer(): JSX.Element {
const exportDefaultQuery = useMemo(
() =>
getQueryByPanelType(
stagedQuery || initialQueriesMap.traces,
stagedQuery || initialQueryAIWithType,
panelType || PANEL_TYPES.LIST,
),
[stagedQuery, panelType],

View File

@@ -17,12 +17,11 @@ import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useOptionsMenu } from 'container/OptionsMenu';
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
import TraceExplorerControls from 'container/TracesExplorer/Controls';
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
import {
getTraceLink,
transformSpanRows,
@@ -43,6 +42,7 @@ import { Warning } from 'types/api';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { getListViewQuery } from '../explorerUtils';
import {
defaultSelectedColumns,
PER_PAGE_OPTIONS,
@@ -94,7 +94,7 @@ function ListView({
paginationQueryData ?? getDefaultPaginationConfig(PER_PAGE_OPTIONS);
const requestQuery = useMemo(
() => getListViewQuery(stagedQuery || initialQueriesMap.traces, orderBy),
() => getListViewQuery(stagedQuery || initialQueryAIWithType, orderBy),
[stagedQuery, orderBy],
);

View File

@@ -1,8 +1,6 @@
import { memo, useCallback, useMemo } from 'react';
import { memo, useMemo } from 'react';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ExplorerOrderBy from 'container/ExplorerOrderBy';
import { OrderByFilterProps } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { DataSource } from 'types/common/queryBuilder';
@@ -10,33 +8,16 @@ import { DataSource } from 'types/common/queryBuilder';
function QuerySection(): JSX.Element {
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(() => {
const isList = panelTypes === PANEL_TYPES.LIST;
const config: QueryBuilderProps['filterConfigs'] = {
// Only reaches the builder for timeseries/table; list/trace panels use QueryBuilderV2's listViewTracesFilterConfigs.
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(
() => ({
stepInterval: { isHidden: false, isDisabled: false },
limit: { isHidden: isList, isDisabled: true },
having: { isHidden: isList, isDisabled: true },
};
return config;
}, [panelTypes]);
const renderOrderBy = useCallback(
({ query, onChange }: OrderByFilterProps) => (
<ExplorerOrderBy query={query} onChange={onChange} />
),
limit: { isHidden: false, isDisabled: true },
having: { isHidden: false, isDisabled: true },
}),
[],
);
const queryComponents = useMemo((): QueryBuilderProps['queryComponents'] => {
const shouldRenderCustomOrderBy =
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE;
return {
...(shouldRenderCustomOrderBy ? { renderOrderBy } : {}),
};
}, [panelTypes, renderOrderBy]);
const isListViewPanel = useMemo(
() => panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE,
[panelTypes],
@@ -45,14 +26,10 @@ function QuerySection(): JSX.Element {
return (
<QueryBuilderV2
isListViewPanel={isListViewPanel}
showTraceOperator
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
queryComponents={queryComponents}
panelType={panelTypes}
filterConfigs={filterConfigs}
showOnlyWhereClause={
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE
}
showOnlyWhereClause={isListViewPanel}
version="v3" // setting this to v3 as we this is rendered in logs explorer
/>
);

View File

@@ -14,10 +14,9 @@ import logEvent from 'api/common/logEvent';
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import TraceExplorerControls from 'container/TracesExplorer/Controls';
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
import { getTraceLink } from 'container/TracesExplorer/ListView/utils';
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
@@ -31,6 +30,7 @@ import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import DOCLINKS from 'utils/docLinks';
import { getListViewQuery } from '../explorerUtils';
import { columns, PER_PAGE_OPTIONS } from './configs';
import styles from './TracesView.module.scss';
@@ -60,7 +60,7 @@ function TracesView({
);
const transformedQuery = useMemo(
() => getListViewQuery(stagedQuery || initialQueriesMap.traces),
() => getListViewQuery(stagedQuery || initialQueryAIWithType),
[stagedQuery],
);

View File

@@ -0,0 +1,61 @@
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { OptionsQuery } from 'container/OptionsMenu/types';
import { cloneDeep, set } from 'lodash-es';
import { OrderByPayload, Query } from 'types/api/queryBuilder/queryBuilderData';
export const getListViewQuery = (
stagedQuery: Query,
orderBy?: string,
): Query => {
const query = stagedQuery
? cloneDeep(stagedQuery)
: cloneDeep(initialQueriesMap.traces);
const orderByPayload: OrderByPayload[] = orderBy
? [
{
columnName: orderBy.split(':')[0],
order: orderBy.split(':')[1] as 'asc' | 'desc',
},
]
: [];
for (let i = 0; i < query.builder.queryData.length; i++) {
const queryData = query.builder.queryData[i];
queryData.groupBy = [];
queryData.having = {
expression: '',
};
queryData.orderBy = orderByPayload;
}
return query;
};
export const getQueryByPanelType = (
stagedQuery: Query,
panelType: PANEL_TYPES,
): Query => {
if (panelType === PANEL_TYPES.LIST || panelType === PANEL_TYPES.TRACE) {
return getListViewQuery(stagedQuery);
}
return stagedQuery;
};
export const getExportQueryData = (
query: Query,
panelType: PANEL_TYPES,
options: OptionsQuery,
): Query => {
if (panelType === PANEL_TYPES.LIST) {
const updatedQuery = cloneDeep(query);
set(
updatedQuery,
'builder.queryData[0].selectColumns',
options.selectColumns,
);
return updatedQuery;
}
return query;
};

View File

@@ -108,7 +108,6 @@ function LogsExplorerViewsContainer({
const [page, setPage] = useState<number>(1);
const [logs, setLogs] = useState<ILog[]>([]);
const [requestData, setRequestData] = useState<Query | null>(null);
const [queryId, setQueryId] = useState<string>(v4());
const [listChartQuery, setListChartQuery] = useState<Query | null>(null);
const [orderBy, setOrderBy] = useState<string>('timestamp:desc');
@@ -180,12 +179,7 @@ function LogsExplorerViewsContainer({
},
undefined,
listQueryKeyRef,
{
...(!isEmpty(queryId) &&
selectedPanelType !== PANEL_TYPES.LIST && {
'X-SIGNOZ-QUERY-ID': queryId,
}),
},
undefined,
// custom selected time interval to prevent recalculating the start and end timestamps before fetching next pages
'custom',
);
@@ -250,10 +244,6 @@ function LogsExplorerViewsContainer({
setRequestData(newRequestData);
}, [isLimit, logs, listQuery, pageSize, stagedQuery, getRequestData, page]);
useEffect(() => {
setQueryId(v4());
}, [data]);
const logEventCalledRef = useRef(false);
useEffect(() => {
if (!logEventCalledRef.current && !isUndefined(data?.payload)) {

View File

@@ -329,16 +329,17 @@ describe('transformTransactionGroupsToResourcePermissions', () => {
it('returns all resources from RESOURCE_ORDER even with empty transaction groups', () => {
const result = transformTransactionGroupsToResourcePermissions([]);
expect(result).toHaveLength(8);
expect(result).toHaveLength(9);
expect(result.map((r) => r.resourceKind)).toStrictEqual([
'factor-api-key',
'role',
'serviceaccount',
'license',
'logs',
'traces',
'metrics',
'meter-metrics',
'metrics',
'role',
'serviceaccount',
'subscription',
'traces',
]);
});
@@ -419,16 +420,17 @@ describe('createEmptyRolePermissions', () => {
it('creates permissions for all resources in RESOURCE_ORDER', () => {
const result = createEmptyRolePermissions();
expect(result).toHaveLength(8);
expect(result).toHaveLength(9);
expect(result.map((r) => r.resourceKind)).toStrictEqual([
'factor-api-key',
'role',
'serviceaccount',
'license',
'logs',
'traces',
'metrics',
'meter-metrics',
'metrics',
'role',
'serviceaccount',
'subscription',
'traces',
]);
});

View File

@@ -6,6 +6,7 @@ import {
Gauge,
Key,
Logs,
Receipt,
Shield,
} from '@signozhq/icons';
@@ -69,6 +70,13 @@ export const RESOURCE_PANELS: Record<AuthZResource, ResourcePanelConfig> = {
selectorPlaceholder: 'Type license ID, separate multiple with comma or space',
docsAnchor: 'license',
},
subscription: {
label: 'Subscription',
description: 'The workspace subscription, its usage and billing details.',
icon: Receipt,
selectorPlaceholder: 'Type * to cover the workspace subscription',
docsAnchor: 'subscription',
},
logs: {
label: 'Logs',
description: 'Log data collected across the workspace.',
@@ -107,7 +115,11 @@ export const RESOURCE_PANELS: Record<AuthZResource, ResourcePanelConfig> = {
},
};
export const RESOURCE_ORDER = Object.keys(RESOURCE_PANELS) as AuthZResource[];
export const RESOURCE_ORDER = (
Object.keys(RESOURCE_PANELS) as AuthZResource[]
).sort((left, right) =>
RESOURCE_PANELS[left].label.localeCompare(RESOURCE_PANELS[right].label),
);
export function getResourcePanel(resource: AuthZResource): ResourcePanelConfig {
const panel = RESOURCE_PANELS[resource];

View File

@@ -0,0 +1,13 @@
import { useAutoRefreshSelection } from './useAutoRefreshSelection';
import { useAutoRefreshTick } from './useAutoRefreshTick';
/** Auto-refresh timer for views that hide the time selector that normally owns it. */
function AutoRefreshTicker(): null {
const { isEnabled, intervalMs } = useAutoRefreshSelection();
useAutoRefreshTick(isEnabled, intervalMs);
return null;
}
export default AutoRefreshTicker;

View File

@@ -0,0 +1,116 @@
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { act, render, screen } from '@testing-library/react';
import set from 'api/browser/localstorage/set';
import { DASHBOARD_TIME_IN_DURATION } from 'constants/app';
import configureStore, { MockStoreEnhanced } from 'redux-mock-store';
import { AppState } from 'store/reducers';
import { UPDATE_TIME_INTERVAL } from 'types/actions/globalTime';
import { GlobalReducer } from 'types/reducer/globalTime';
import AutoRefresh from '../index';
const mockStore = configureStore<Partial<AppState>>([]);
const PATHNAME = '/dashboard/test-id';
const randomTime = 1700000000000000000;
function createGlobalTimeState(
overrides: Partial<GlobalReducer> = {},
): GlobalReducer {
return {
minTime: randomTime,
maxTime: randomTime,
loading: false,
selectedTime: '15m',
isAutoRefreshDisabled: false,
selectedAutoRefreshInterval: '5s',
...overrides,
};
}
function renderAutoRefresh(
globalTime: GlobalReducer,
props: { disabled?: boolean } = {},
): MockStoreEnhanced<Partial<AppState>> {
const store = mockStore({ globalTime });
render(
<MemoryRouter initialEntries={[PATHNAME]}>
<Provider store={store}>
<AutoRefresh {...props} />
</Provider>
</MemoryRouter>,
);
return store;
}
function tickCount(store: MockStoreEnhanced<Partial<AppState>>): number {
return store.getActions().filter((a) => a.type === UPDATE_TIME_INTERVAL)
.length;
}
describe('AutoRefresh', () => {
beforeEach(() => {
jest.useFakeTimers();
localStorage.clear();
});
afterEach(() => {
jest.useRealTimers();
});
it('renders the trigger and ticks on the persisted interval', () => {
set(DASHBOARD_TIME_IN_DURATION, JSON.stringify({ [PATHNAME]: '5s' }));
const store = renderAutoRefresh(createGlobalTimeState());
expect(screen.getByTitle('Set auto refresh')).toBeInTheDocument();
act(() => {
jest.advanceTimersByTime(15_000);
});
expect(tickCount(store)).toBe(3);
});
it('does not tick when auto refresh was never enabled for the route', () => {
const store = renderAutoRefresh(createGlobalTimeState());
act(() => {
jest.advanceTimersByTime(60_000);
});
expect(tickCount(store)).toBe(0);
});
it('does not tick while the disabled prop is set', () => {
set(DASHBOARD_TIME_IN_DURATION, JSON.stringify({ [PATHNAME]: '5s' }));
const store = renderAutoRefresh(createGlobalTimeState(), { disabled: true });
act(() => {
jest.advanceTimersByTime(60_000);
});
expect(tickCount(store)).toBe(0);
});
it('renders nothing on a custom time range', () => {
set(DASHBOARD_TIME_IN_DURATION, JSON.stringify({ [PATHNAME]: '5s' }));
const store = renderAutoRefresh(
createGlobalTimeState({ selectedTime: 'custom' }),
);
expect(screen.queryByTitle('Set auto refresh')).not.toBeInTheDocument();
act(() => {
jest.advanceTimersByTime(60_000);
});
expect(tickCount(store)).toBe(0);
});
});

View File

@@ -0,0 +1,162 @@
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { act, render } from '@testing-library/react';
import set from 'api/browser/localstorage/set';
import { DASHBOARD_TIME_IN_DURATION } from 'constants/app';
import configureStore, { MockStoreEnhanced } from 'redux-mock-store';
import { AppState } from 'store/reducers';
import { UPDATE_TIME_INTERVAL } from 'types/actions/globalTime';
import { GlobalReducer } from 'types/reducer/globalTime';
import AutoRefresh from '../index';
import AutoRefreshTicker from '../AutoRefreshTicker';
const mockStore = configureStore<Partial<AppState>>([]);
const PATHNAME = '/dashboard/test-id';
const randomTime = 1700000000000000000;
function createGlobalTimeState(
overrides: Partial<GlobalReducer> = {},
): GlobalReducer {
return {
minTime: randomTime,
maxTime: randomTime,
loading: false,
selectedTime: '15m',
isAutoRefreshDisabled: false,
selectedAutoRefreshInterval: '5s',
...overrides,
};
}
function renderTicker(
globalTime: GlobalReducer,
): MockStoreEnhanced<Partial<AppState>> {
const store = mockStore({ globalTime });
render(
<MemoryRouter initialEntries={[PATHNAME]}>
<Provider store={store}>
<AutoRefreshTicker />
</Provider>
</MemoryRouter>,
);
return store;
}
function timeIntervalActions(
store: MockStoreEnhanced<Partial<AppState>>,
): unknown[] {
return store.getActions().filter((a) => a.type === UPDATE_TIME_INTERVAL);
}
describe('AutoRefreshTicker', () => {
beforeEach(() => {
jest.useFakeTimers();
localStorage.clear();
});
afterEach(() => {
jest.useRealTimers();
});
it('advances the global time window on the interval persisted for the route', () => {
set(DASHBOARD_TIME_IN_DURATION, JSON.stringify({ [PATHNAME]: '5s' }));
const store = renderTicker(createGlobalTimeState());
act(() => {
jest.advanceTimersByTime(10_000);
});
expect(timeIntervalActions(store)).toHaveLength(2);
});
it('does not tick when the route has no persisted interval', () => {
const store = renderTicker(createGlobalTimeState());
act(() => {
jest.advanceTimersByTime(60_000);
});
expect(timeIntervalActions(store)).toHaveLength(0);
});
it('does not tick while auto refresh is globally disabled', () => {
set(DASHBOARD_TIME_IN_DURATION, JSON.stringify({ [PATHNAME]: '5s' }));
const store = renderTicker(
createGlobalTimeState({ isAutoRefreshDisabled: true }),
);
act(() => {
jest.advanceTimersByTime(60_000);
});
expect(timeIntervalActions(store)).toHaveLength(0);
});
it('does not tick on a custom time range', () => {
set(DASHBOARD_TIME_IN_DURATION, JSON.stringify({ [PATHNAME]: '5s' }));
const store = renderTicker(createGlobalTimeState({ selectedTime: 'custom' }));
act(() => {
jest.advanceTimersByTime(60_000);
});
expect(timeIntervalActions(store)).toHaveLength(0);
});
});
// Mirrors DashboardContainer's swap: exactly one of the two must be ticking.
describe('AutoRefresh full screen handover', () => {
beforeEach(() => {
jest.useFakeTimers();
localStorage.clear();
});
afterEach(() => {
jest.useRealTimers();
});
it('keeps a single timer running across entering and leaving full screen', () => {
set(DASHBOARD_TIME_IN_DURATION, JSON.stringify({ [PATHNAME]: '5s' }));
const store = mockStore({ globalTime: createGlobalTimeState() });
function Harness({ active }: { active: boolean }): JSX.Element {
return active ? <AutoRefreshTicker /> : <AutoRefresh />;
}
const renderHarness = (active: boolean): JSX.Element => (
<MemoryRouter initialEntries={[PATHNAME]}>
<Provider store={store}>
<Harness active={active} />
</Provider>
</MemoryRouter>
);
const { rerender } = render(renderHarness(false));
act(() => {
jest.advanceTimersByTime(10_000);
});
expect(timeIntervalActions(store)).toHaveLength(2);
rerender(renderHarness(true));
act(() => {
jest.advanceTimersByTime(10_000);
});
expect(timeIntervalActions(store)).toHaveLength(4);
rerender(renderHarness(false));
act(() => {
jest.advanceTimersByTime(10_000);
});
expect(timeIntervalActions(store)).toHaveLength(6);
});
});

View File

@@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useDispatch, useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { useInterval } from 'react-use';
import { Check, ChevronDown } from '@signozhq/icons';
import { Button, Popover } from 'antd';
import { Checkbox } from '@signozhq/ui/checkbox';
@@ -11,21 +10,18 @@ import get from 'api/browser/localstorage/get';
import set from 'api/browser/localstorage/set';
import { DASHBOARD_TIME_IN_DURATION } from 'constants/app';
import useUrlQuery from 'hooks/useUrlQuery';
import { getMinMaxForSelectedTime } from 'lib/getMinMax';
import _omit from 'lodash-es/omit';
// eslint-disable-next-line no-restricted-imports
import { Dispatch } from 'redux';
import { AppState } from 'store/reducers';
import AppActions from 'types/actions';
import {
UPDATE_AUTO_REFRESH_INTERVAL,
UPDATE_TIME_INTERVAL,
} from 'types/actions/globalTime';
import { UPDATE_AUTO_REFRESH_INTERVAL } from 'types/actions/globalTime';
import { GlobalReducer } from 'types/reducer/globalTime';
import { popupContainer } from 'utils/selectPopupContainer';
import { refreshIntervalOptions } from './constants';
import { ButtonContainer } from './styles';
import { useAutoRefreshTick } from './useAutoRefreshTick';
import './AutoRefreshV2.styles.scss';
@@ -93,30 +89,10 @@ function AutoRefresh({
[selectedOption],
);
useInterval(() => {
const selectedValue = getOption?.value;
if (isDisabled || !isAutoRefreshEnabled) {
return;
}
if (selectedOption !== 'off' && selectedValue) {
const { maxTime, minTime } = getMinMaxForSelectedTime(
globalTime.selectedTime,
globalTime.minTime,
globalTime.maxTime,
);
dispatch({
type: UPDATE_TIME_INTERVAL,
payload: {
maxTime,
minTime,
selectedTime: globalTime.selectedTime,
},
});
}
}, getOption?.value || 0);
useAutoRefreshTick(
!isDisabled && isAutoRefreshEnabled && selectedOption !== 'off',
getOption?.value || 0,
);
const onChangeHandler = useCallback(
(selectedValue: string) => {

View File

@@ -0,0 +1,29 @@
import { useLocation } from 'react-router-dom';
import get from 'api/browser/localstorage/get';
import { DASHBOARD_TIME_IN_DURATION } from 'constants/app';
import { refreshIntervalOptions } from './constants';
export interface AutoRefreshSelection {
isEnabled: boolean;
intervalMs: number;
}
/**
* An entry for the current route means auto-refresh is on, its absence means off.
* Read on every render because localStorage isn't reactive.
*/
export function useAutoRefreshSelection(): AutoRefreshSelection {
const { pathname } = useLocation();
const selectedOption = JSON.parse(get(DASHBOARD_TIME_IN_DURATION) || '{}')[
pathname
];
return {
isEnabled: Boolean(selectedOption),
intervalMs:
refreshIntervalOptions.find((option) => option.key === selectedOption)
?.value || 0,
};
}

View File

@@ -0,0 +1,47 @@
// eslint-disable-next-line no-restricted-imports
import { useDispatch, useSelector } from 'react-redux';
import { useInterval } from 'react-use';
import { getMinMaxForSelectedTime } from 'lib/getMinMax';
// eslint-disable-next-line no-restricted-imports
import { Dispatch } from 'redux';
import { AppState } from 'store/reducers';
import AppActions from 'types/actions';
import { UPDATE_TIME_INTERVAL } from 'types/actions/globalTime';
import { GlobalReducer } from 'types/reducer/globalTime';
/**
* Advances the global time window on the auto-refresh interval. The global
* "auto refresh disabled" flag and a custom range override the caller's `enabled`.
*/
export function useAutoRefreshTick(enabled: boolean, intervalMs: number): void {
const globalTime = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const dispatch = useDispatch<Dispatch<AppActions>>();
const isTicking =
enabled &&
intervalMs > 0 &&
!globalTime.isAutoRefreshDisabled &&
globalTime.selectedTime !== 'custom';
useInterval(
() => {
const { maxTime, minTime } = getMinMaxForSelectedTime(
globalTime.selectedTime,
globalTime.minTime,
globalTime.maxTime,
);
dispatch({
type: UPDATE_TIME_INTERVAL,
payload: {
maxTime,
minTime,
selectedTime: globalTime.selectedTime,
},
});
},
isTicking ? intervalMs : null,
);
}

View File

@@ -13,6 +13,11 @@ export default {
type: 'metaresource',
allowedVerbs: ['create', 'delete', 'list', 'read', 'update'],
},
{
kind: 'subscription',
type: 'metaresource',
allowedVerbs: ['create', 'delete', 'list', 'read', 'update'],
},
{
kind: 'role',
type: 'role',

View File

@@ -4,3 +4,5 @@ import type { BrandedPermission } from '../types';
// Resource-level — require a specific license id
export const buildLicenseReadPermission = (id: string): BrandedPermission =>
buildPermission('read', `license:${id}`);
export const buildLicenseUpdatePermission = (id: string): BrandedPermission =>
buildPermission('update', `license:${id}`);

View File

@@ -0,0 +1,26 @@
import { buildPermission } from '../utils';
export const SubscriptionReadPermission = buildPermission(
'read',
'subscription:*',
);
export const SubscriptionCreatePermission = buildPermission(
'create',
'subscription:*',
);
export const SubscriptionUpdatePermission = buildPermission(
'update',
'subscription:*',
);
export const SubscriptionListPermission = buildPermission(
'list',
'subscription:*',
);
export const SubscriptionDeletePermission = buildPermission(
'delete',
'subscription:*',
);
export const SubscriptionManagePermissions = [
SubscriptionListPermission,
SubscriptionUpdatePermission,
];

View File

@@ -4,114 +4,85 @@ export const quickFiltersListResponse = {
signal: 'logs',
filters: [
{
key: 'os.description',
dataType: 'string',
type: 'resource',
name: 'os.description',
fieldDataType: 'string',
fieldContext: 'resource',
},
{
key: 'service.name',
dataType: 'string',
type: 'resource',
name: 'service.name',
fieldDataType: 'string',
fieldContext: 'resource',
},
{
key: 'duration_nano',
dataType: 'float64',
type: 'tag',
name: 'duration_nano',
fieldDataType: 'float64',
fieldContext: 'attribute',
},
{
key: 'quantity',
dataType: 'float64',
type: 'tag',
name: 'quantity',
fieldDataType: 'float64',
fieldContext: 'attribute',
},
{
key: 'body',
dataType: 'string',
type: '',
name: 'body',
fieldDataType: 'string',
fieldContext: '',
},
{
key: 'deployment.environment',
dataType: 'string',
type: 'resource',
name: 'deployment.environment',
fieldDataType: 'string',
fieldContext: 'resource',
},
{
key: 'service.namespace',
dataType: 'string',
type: 'resource',
name: 'service.namespace',
fieldDataType: 'string',
fieldContext: 'resource',
},
{
key: 'k8s.namespace.name',
dataType: 'string',
type: 'resource',
name: 'k8s.namespace.name',
fieldDataType: 'string',
fieldContext: 'resource',
},
{
key: 'service.instance.id',
dataType: 'string',
type: 'resource',
name: 'service.instance.id',
fieldDataType: 'string',
fieldContext: 'resource',
},
{
key: 'k8s.pod.name',
dataType: 'string',
type: 'resource',
name: 'k8s.pod.name',
fieldDataType: 'string',
fieldContext: 'resource',
},
{
key: 'process.owner',
dataType: 'string',
type: 'resource',
name: 'process.owner',
fieldDataType: 'string',
fieldContext: 'resource',
},
],
},
};
const otherFilterName = (name: string): { [k: string]: unknown[] } => ({
[name]: [
{ name, fieldContext: 'resource', fieldDataType: 'string', signal: 'logs' },
],
});
export const otherFiltersResponse = {
status: 'success',
data: {
attributes: [
{
key: 'service.name',
dataType: 'string',
type: 'resource',
},
{
key: 'k8s.deployment.name',
dataType: 'string',
type: 'resource',
},
{
key: 'deployment.environment',
dataType: 'string',
type: 'resource',
},
{
key: 'service.namespace',
dataType: 'string',
type: 'resource',
},
{
key: 'k8s.namespace.name',
dataType: 'string',
type: 'resource',
},
{
key: 'service.instance.id',
dataType: 'string',
type: 'resource',
},
{
key: 'k8s.pod.name',
dataType: 'string',
type: 'resource',
},
{
key: 'k8s.pod.uid',
dataType: 'string',
type: 'resource',
},
{
key: 'os.description',
dataType: 'string',
type: 'resource',
},
],
complete: true,
keys: {
...otherFilterName('service.name'),
...otherFilterName('k8s.deployment.name'),
...otherFilterName('deployment.environment'),
...otherFilterName('service.namespace'),
...otherFilterName('k8s.namespace.name'),
...otherFilterName('service.instance.id'),
...otherFilterName('k8s.pod.name'),
...otherFilterName('k8s.pod.uid'),
...otherFilterName('os.description'),
},
},
};

View File

@@ -139,7 +139,7 @@ export const handlers = [
res(ctx.status(200), ctx.json(licensesSuccessResponse)),
),
rest.get('http://localhost/api/v1/billing', (req, res, ctx) =>
rest.get('http://localhost/api/v1/subscriptions', (req, res, ctx) =>
res(ctx.status(200), ctx.json(billingSuccessResponse)),
),

View File

@@ -153,6 +153,21 @@ function TablePanelRenderer({
const [page, setPage] = useState(1);
useEffect(() => setPage(1), [searchTerm]);
// The measured size is only a default; without this the controlled `pageSize`
// snaps a size-changer pick straight back to the fitted value.
const [selectedPageSize, setSelectedPageSize] = useState<number>();
const effectivePageSize = selectedPageSize ?? pageSize;
const handlePaginationChange = useCallback(
(nextPage: number, nextPageSize: number): void => {
setPage(nextPage);
if (nextPageSize !== effectivePageSize) {
setSelectedPageSize(nextPageSize);
}
},
[effectivePageSize],
);
return (
<div
ref={containerRef}
@@ -170,10 +185,10 @@ function TablePanelRenderer({
dataSource={filteredDataSource}
pagination={{
current: page,
pageSize,
pageSize: effectivePageSize,
hideOnSinglePage: true,
size: 'small',
onChange: setPage,
onChange: handlePaginationChange,
}}
scroll={{ x: 'max-content', y: scrollY }}
/>

View File

@@ -1,3 +1,4 @@
import userEvent from '@testing-library/user-event';
import {
type DashboardtypesTablePanelSpecDTO,
type QueryRangeV5200,
@@ -10,6 +11,7 @@ import type {
PanelOfKind,
PanelRendererProps,
} from '../../../types/rendererProps';
import { MIN_PAGE_SIZE } from '../../../utils/recordTable';
import TablePanelRenderer from '../Renderer';
function panelWith(
@@ -129,6 +131,27 @@ describe('TablePanelRenderer', () => {
expect(queryByText('frontend')).not.toBeInTheDocument();
});
it('keeps a page size picked from the size changer', async () => {
const rows = Array.from({ length: 60 }, (_, index): [string, number] => [
`service-${index}`,
index,
]);
const { container, getByText } = renderPanel({ data: dataWith(rows) });
const countRows = (): number =>
container.querySelectorAll('.ant-table-tbody tr.ant-table-row').length;
expect(countRows()).toBe(MIN_PAGE_SIZE);
const sizeChanger = container.querySelector(
'.ant-pagination-options .ant-select-selector',
) as Element;
await userEvent.click(sizeChanger);
await userEvent.click(getByText('20 / page'));
expect(countRows()).toBe(20);
});
it('keeps the table mounted (not No Data) when the search matches no rows', () => {
const { getByTestId, queryByText } = renderPanel({
data: dataWith([['frontend', 1234]]),

View File

@@ -2,6 +2,7 @@ import { useEffect } from 'react';
import { FullScreen, useFullScreenHandle } from 'react-full-screen';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import AutoRefreshTicker from 'container/TopNav/AutoRefreshV2/AutoRefreshTicker';
import DashboardPageToolbar from './DashboardPageToolbar';
import PanelsAndSectionsLayout from './PanelsAndSectionsLayout';
@@ -77,7 +78,10 @@ function DashboardContainer({
return (
<FullScreen handle={fullScreenHandle}>
<div className={styles.container}>
{!fullScreenHandle.active && (
{fullScreenHandle.active ? (
// The hidden toolbar owns the auto-refresh timer.
<AutoRefreshTicker />
) : (
<>
<DashboardPageHeader title={name} image={image} />
<DashboardPageToolbar dashboard={dashboard} handle={fullScreenHandle} />

View File

@@ -58,14 +58,15 @@ function SettingsPage(): JSX.Element {
if (trialInfo?.workSpaceBlock && !isFetchingActiveLicense) {
updatedItems = updatedItems.map((item) => ({
...item,
isEnabled: !!(
isAdmin &&
(item.key === ROUTES.BILLING ||
item.key === ROUTES.ORG_SETTINGS ||
item.key === ROUTES.MEMBERS_SETTINGS ||
item.key === ROUTES.MY_SETTINGS ||
item.key === ROUTES.SHORTCUTS)
),
isEnabled:
item.key === ROUTES.BILLING ||
!!(
isAdmin &&
(item.key === ROUTES.ORG_SETTINGS ||
item.key === ROUTES.MEMBERS_SETTINGS ||
item.key === ROUTES.MY_SETTINGS ||
item.key === ROUTES.SHORTCUTS)
),
}));
return updatedItems;
@@ -76,6 +77,7 @@ function SettingsPage(): JSX.Element {
updatedItems = updatedItems.map((item) => ({
...item,
isEnabled:
item.key === ROUTES.BILLING ||
item.key === ROUTES.ROLES_SETTINGS ||
item.key === ROUTES.ROLE_CREATE ||
item.key === ROUTES.ROLE_DETAILS ||
@@ -89,7 +91,6 @@ function SettingsPage(): JSX.Element {
updatedItems = updatedItems.map((item) => ({
...item,
isEnabled:
item.key === ROUTES.BILLING ||
item.key === ROUTES.INTEGRATIONS ||
item.key === ROUTES.INGESTION_SETTINGS ||
item.key === ROUTES.ORG_SETTINGS ||
@@ -127,6 +128,7 @@ function SettingsPage(): JSX.Element {
updatedItems = updatedItems.map((item) => ({
...item,
isEnabled:
item.key === ROUTES.BILLING ||
item.key === ROUTES.ROLES_SETTINGS ||
item.key === ROUTES.ROLE_CREATE ||
item.key === ROUTES.ROLE_DETAILS ||
@@ -140,7 +142,6 @@ function SettingsPage(): JSX.Element {
updatedItems = updatedItems.map((item) => ({
...item,
isEnabled:
item.key === ROUTES.BILLING ||
item.key === ROUTES.INTEGRATIONS ||
item.key === ROUTES.ORG_SETTINGS ||
item.key === ROUTES.MEMBERS_SETTINGS ||

View File

@@ -73,17 +73,13 @@ describe('SettingsPage nav sections', () => {
});
});
it.each(['workspace', 'account', 'roles', 'service-accounts'])(
it.each(['workspace', 'account', 'roles', 'service-accounts', 'billing'])(
'renders "%s" element',
(id) => {
expect(screen.getByTestId(id)).toBeInTheDocument();
},
);
it.each(['billing'])('does not render "%s" element', (id) => {
expect(screen.queryByTestId(id)).not.toBeInTheDocument();
});
it('renders "mcp-server" element', () => {
expect(screen.getByTestId('mcp-server')).toBeInTheDocument();
});

View File

@@ -33,14 +33,20 @@ export const getRoutes = (
const isAdmin = userRole === USER_ROLES.ADMIN;
const isEditor = userRole === USER_ROLES.EDITOR;
if (isWorkspaceBlocked && isAdmin) {
settings.push(
...organizationSettings(t),
...membersSettings(t),
...mySettings(t),
...billingSettings(t),
...keyboardShortcuts(t),
);
if (isWorkspaceBlocked) {
if (isAdmin) {
settings.push(
...organizationSettings(t),
...membersSettings(t),
...mySettings(t),
);
}
settings.push(...billingSettings(t));
if (isAdmin) {
settings.push(...keyboardShortcuts(t));
}
return settings;
}
@@ -73,7 +79,7 @@ export const getRoutes = (
settings.push(...membersSettings(t));
}
if ((isCloudUser || isEnterpriseSelfHostedUser) && isAdmin) {
if (isCloudUser || isEnterpriseSelfHostedUser) {
settings.push(...billingSettings(t));
}

View File

@@ -4,9 +4,12 @@ import { useHistory, useLocation } from 'react-router-dom';
import { Button, Card, Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import updateCreditCardApi from 'api/v1/checkout/create';
import { createSubscription } from 'api/generated/services/subscriptions';
import type { CreateSubscription201 } from 'api/generated/services/sigNoz.schemas';
import { FeatureKeys } from 'constants/features';
import { useNotifications } from 'hooks/useNotifications';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { SubscriptionCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import {
ArrowUpRight,
Book,
@@ -18,8 +21,6 @@ import {
X,
} from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
import { SuccessResponseV2 } from 'types/api';
import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
import APIError from 'types/api/error';
import { getBaseUrl } from 'utils/basePath';
import { openInNewTab } from 'utils/navigation';
@@ -116,9 +117,7 @@ export default function Support(): JSX.Element {
const showAddCreditCardModal =
!isPremiumChatSupportEnabled && !trialInfo?.trialConvertedToSubscription;
const handleBillingOnSuccess = (
data: SuccessResponseV2<CheckoutSuccessPayloadProps>,
): void => {
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
if (data?.data?.redirectURL) {
const newTab = document.createElement('a');
newTab.href = data.data.redirectURL;
@@ -136,7 +135,7 @@ export default function Support(): JSX.Element {
};
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
updateCreditCardApi,
createSubscription,
{
onSuccess: (data) => {
handleBillingOnSuccess(data);
@@ -246,18 +245,23 @@ export default function Support(): JSX.Element {
>
Cancel
</Button>,
<Button
<AuthZTooltip
key="submit"
type="primary"
icon={<CreditCard size={16} />}
size="middle"
loading={isLoadingBilling}
disabled={isLoadingBilling}
onClick={handleAddCreditCard}
className="add-credit-card-btn periscope-btn primary"
checks={[SubscriptionCreatePermission]}
withPortal={false}
>
Add Credit Card
</Button>,
<Button
type="primary"
icon={<CreditCard size={16} />}
size="middle"
loading={isLoadingBilling}
disabled={isLoadingBilling}
onClick={handleAddCreditCard}
className="add-credit-card-btn periscope-btn primary"
>
Add Credit Card
</Button>
</AuthZTooltip>,
]}
>
<Typography.Text className="add-credit-card-text">

View File

@@ -504,7 +504,7 @@ jest.mock('hooks/useHandleExplorerTabChange', () => ({
let capturedPayload: QueryRangePayloadV5;
describe('TracesExplorer -', () => {
const quickFiltersListURL = `${BASE_URL}/api/v1/orgs/me/filters/traces`;
const quickFiltersListURL = `${BASE_URL}/api/v2/quick_filters/traces`;
const setupServer = (): void => {
server.use(

View File

@@ -1,7 +1,11 @@
import {
setupAuthzAdmin,
setupAuthzDenyAll,
} from 'lib/authz/utils/authz-test-utils';
import { licensesSuccessWorkspaceLockedResponse } from 'mocks-server/__mockdata__/licenses';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { act, render, screen } from 'tests/test-utils';
import { act, render, screen, waitFor } from 'tests/test-utils';
import WorkspaceLocked from '.';
@@ -30,40 +34,37 @@ describe('WorkspaceLocked', () => {
expect(contactUsBtn).toBeInTheDocument();
});
it('Render for Admin', async () => {
it('enables the upgrade action when subscription create is granted', async () => {
server.use(
rest.get(apiURL, (req, res, ctx) =>
res(ctx.status(200), ctx.json(licensesSuccessWorkspaceLockedResponse)),
),
setupAuthzAdmin(),
);
render(<WorkspaceLocked />);
const contactAdminMessage = await screen.queryByText(
/contact your admin to proceed with the upgrade./i,
);
expect(contactAdminMessage).not.toBeInTheDocument();
const updateCreditCardBtn = await screen.findByRole('button', {
name: /continue my journey/i,
});
expect(updateCreditCardBtn).toBeInTheDocument();
await waitFor(() => {
expect(updateCreditCardBtn).toBeEnabled();
});
});
it('Render for non Admin', async () => {
it('disables the upgrade action when subscription create is denied', async () => {
server.use(
rest.get(apiURL, (req, res, ctx) =>
res(ctx.status(200), ctx.json(licensesSuccessWorkspaceLockedResponse)),
),
setupAuthzDenyAll(),
);
render(<WorkspaceLocked />, {}, { role: 'VIEWER' });
const updateCreditCardBtn = await screen.queryByRole('button', {
name: /Continue My Journey/i,
const updateCreditCardBtn = await screen.findByRole('button', {
name: /continue my journey/i,
});
await waitFor(() => {
expect(updateCreditCardBtn).toBeDisabled();
});
expect(updateCreditCardBtn).not.toBeInTheDocument();
const contactAdminMessage = await screen.findByText(
/contact your admin to proceed with the upgrade./i,
);
expect(contactAdminMessage).toBeInTheDocument();
});
});

View File

@@ -4,7 +4,6 @@ import { useTranslation } from 'react-i18next';
import { useMutation } from 'react-query';
import type { TabsProps } from 'antd';
import {
Alert,
Button,
Col,
Collapse,
@@ -18,11 +17,13 @@ import {
} from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import updateCreditCardApi from 'api/v1/checkout/create';
import { createSubscription } from 'api/generated/services/subscriptions';
import RefreshPaymentStatus from 'components/RefreshPaymentStatus/RefreshPaymentStatus';
import ROUTES from 'constants/routes';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { SubscriptionCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import history from 'lib/history';
import { CircleArrowRight } from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
@@ -44,9 +45,7 @@ import {
import './WorkspaceLocked.styles.scss';
export default function WorkspaceBlocked(): JSX.Element {
const { user, isFetchingActiveLicense, trialInfo, activeLicense } =
useAppContext();
const isAdmin = user.role === 'ADMIN';
const { isFetchingActiveLicense, trialInfo, activeLicense } = useAppContext();
const { notifications } = useNotifications();
const { safeNavigate } = useSafeNavigate();
@@ -89,7 +88,7 @@ export default function WorkspaceBlocked(): JSX.Element {
]);
const { mutate: updateCreditCard, isLoading } = useMutation(
updateCreditCardApi,
createSubscription,
{
onSuccess: (data) => {
if (data.data?.redirectURL) {
@@ -184,8 +183,11 @@ export default function WorkspaceBlocked(): JSX.Element {
/>
</Space>
</Col>
{isAdmin && (
<Col span={24}>
<Col span={24}>
<AuthZTooltip
checks={[SubscriptionCreatePermission]}
withPortal={false}
>
<Button
type="primary"
shape="round"
@@ -195,8 +197,8 @@ export default function WorkspaceBlocked(): JSX.Element {
>
{t('continueToUpgrade')}
</Button>
</Col>
)}
</AuthZTooltip>
</Col>
</Row>
</Col>
</Row>
@@ -220,9 +222,9 @@ export default function WorkspaceBlocked(): JSX.Element {
>
{renderCustomerStories((index) => index % 2 !== 0)}
</Col>
{isAdmin && (
<Col span={24}>
<Flex justify="center">
<Col span={24}>
<Flex justify="center">
<AuthZTooltip checks={[SubscriptionCreatePermission]} withPortal={false}>
<Button
type="primary"
shape="round"
@@ -232,9 +234,9 @@ export default function WorkspaceBlocked(): JSX.Element {
>
{t('continueToUpgrade')}
</Button>
</Flex>
</Col>
)}
</AuthZTooltip>
</Flex>
</Col>
</Row>
),
},
@@ -260,7 +262,7 @@ export default function WorkspaceBlocked(): JSX.Element {
defaultActiveKey={['signoz-cloud-vs-community']}
onChange={handleCollapseChange}
/>
{isAdmin && (
<AuthZTooltip checks={[SubscriptionCreatePermission]} withPortal={false}>
<Button
type="primary"
shape="round"
@@ -270,7 +272,7 @@ export default function WorkspaceBlocked(): JSX.Element {
>
{t('continueToUpgrade')}
</Button>
)}
</AuthZTooltip>
</Space>
</Col>
</Row>
@@ -288,21 +290,19 @@ export default function WorkspaceBlocked(): JSX.Element {
{t('trialPlanExpired')}
</span>
<span className="workspace-locked__modal__header__actions">
{isAdmin && (
<Flex gap={8} justify="center" align="center">
<Button
className="workspace-locked__modal__header__actions__billing"
type="link"
size="small"
role="button"
onClick={(e): void => handleViewBilling(e)}
>
View Billing
</Button>
<Flex gap={8} justify="center" align="center">
<Button
className="workspace-locked__modal__header__actions__billing"
type="link"
size="small"
role="button"
onClick={(e): void => handleViewBilling(e)}
>
View Billing
</Button>
<RefreshPaymentStatus />
</Flex>
)}
<RefreshPaymentStatus withPortal={false} />
</Flex>
<Button
type="default"
@@ -346,7 +346,7 @@ export default function WorkspaceBlocked(): JSX.Element {
</Space>
</Col>
</Row>
{!isAdmin && (
<Flex gap={8} vertical justify="center" align="center">
<Row
justify="center"
align="middle"
@@ -354,22 +354,10 @@ export default function WorkspaceBlocked(): JSX.Element {
gutter={[8, 8]}
>
<Col>
<Alert
message="Contact your admin to proceed with the upgrade."
type="info"
/>
</Col>
</Row>
)}
{isAdmin && (
<Flex gap={8} vertical justify="center" align="center">
<Row
justify="center"
align="middle"
className="workspace-locked__modal__cta"
gutter={[8, 8]}
>
<Col>
<AuthZTooltip
checks={[SubscriptionCreatePermission]}
withPortal={false}
>
<Button
type="primary"
shape="round"
@@ -379,21 +367,21 @@ export default function WorkspaceBlocked(): JSX.Element {
>
Continue my Journey
</Button>
</Col>
<Col>
<Button
type="default"
shape="round"
size="middle"
className="periscope-btn"
onClick={handleExtendTrial}
>
{t('needMoreTime')}
</Button>
</Col>
</Row>
</Flex>
)}
</AuthZTooltip>
</Col>
<Col>
<Button
type="default"
shape="round"
size="middle"
className="periscope-btn"
onClick={handleExtendTrial}
>
{t('needMoreTime')}
</Button>
</Col>
</Row>
</Flex>
<div className="workspace-locked__tabs">
<Tabs

View File

@@ -1,12 +1,14 @@
import { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation } from 'react-query';
import { Alert, Button, Col, Flex, Modal, Row, Skeleton, Space } from 'antd';
import { Button, Col, Flex, Modal, Row, Skeleton, Space } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import manageCreditCardApi from 'api/v1/portal/create';
import { updateSubscription } from 'api/generated/services/subscriptions';
import RefreshPaymentStatus from 'components/RefreshPaymentStatus/RefreshPaymentStatus';
import ROUTES from 'constants/routes';
import { useNotifications } from 'hooks/useNotifications';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { SubscriptionManagePermissions } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import history from 'lib/history';
import { useAppContext } from 'providers/App/App';
import APIError from 'types/api/error';
@@ -18,15 +20,13 @@ import featureGraphicCorrelationUrl from '@/assets/Images/feature-graphic-correl
import './WorkspaceSuspended.styles.scss';
function WorkspaceSuspended(): JSX.Element {
const { user } = useAppContext();
const isAdmin = user.role === 'ADMIN';
const { notifications } = useNotifications();
const { activeLicense, isFetchingActiveLicense } = useAppContext();
const { t } = useTranslation(['failedPayment']);
const { mutate: manageCreditCard, isLoading } = useMutation(
manageCreditCardApi,
updateSubscription,
{
onSuccess: (data) => {
if (data.data?.redirectURL) {
@@ -111,29 +111,17 @@ function WorkspaceSuspended(): JSX.Element {
</Space>
</Col>
</Row>
{!isAdmin && (
<Row
justify="center"
align="middle"
className="workspace-suspended__modal__cta"
gutter={[16, 16]}
>
<Col>
<Alert
message="Contact your admin to proceed with the upgrade."
type="info"
/>
</Col>
</Row>
)}
{isAdmin && (
<Row
justify="center"
align="middle"
className="workspace-suspended__modal__cta"
gutter={[8, 8]}
>
<Flex gap={8} justify="center" align="center">
<Row
justify="center"
align="middle"
className="workspace-suspended__modal__cta"
gutter={[8, 8]}
>
<Flex gap={8} justify="center" align="center">
<AuthZTooltip
checks={SubscriptionManagePermissions}
withPortal={false}
>
<Button
type="primary"
shape="round"
@@ -143,10 +131,10 @@ function WorkspaceSuspended(): JSX.Element {
>
{t('continueMyJourney')}
</Button>
<RefreshPaymentStatus />
</Flex>
</Row>
)}
</AuthZTooltip>
<RefreshPaymentStatus withPortal={false} />
</Flex>
</Row>
<div className="workspace-suspended__creative">
<img src={featureGraphicCorrelationUrl} alt="correlation-graphic" />
</div>

View File

@@ -475,6 +475,7 @@ export function QueryBuilderProvider({
const newQuery: IBuilderQuery = {
...initialBuilderQuery,
source: queries?.[0]?.source || '',
builderQueryType: queries?.[0]?.builderQueryType,
queryName: createNewBuilderItemName({ existNames, sourceNames: alphabet }),
expression: createNewBuilderItemName({
existNames,

View File

@@ -0,0 +1,55 @@
import {
initialQueriesMap,
initialQueryAIWithType,
} from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { act, AllTheProviders, renderHook } from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
const renderQueryBuilder = (
initialQuery: Query,
): ReturnType<
typeof renderHook<ReturnType<typeof useQueryBuilder>, unknown>
> => {
const hook = renderHook(() => useQueryBuilder(), {
wrapper: AllTheProviders,
});
act(() => {
hook.result.current.initQueryBuilderData(initialQuery);
});
return hook;
};
describe('createNewBuilderQuery builderQueryType propagation', () => {
it('carries builderQueryType from the first query onto an added query', () => {
const { result } = renderQueryBuilder(initialQueryAIWithType);
expect(
result.current.currentQuery.builder.queryData[0].builderQueryType,
).toBe('builder_ai_query');
act(() => {
result.current.addNewBuilderQuery();
});
expect(result.current.currentQuery.builder.queryData).toHaveLength(2);
expect(
result.current.currentQuery.builder.queryData[1].builderQueryType,
).toBe('builder_ai_query');
});
it('leaves builderQueryType unset when the first query has none', () => {
const { result } = renderQueryBuilder(initialQueriesMap.traces);
act(() => {
result.current.addNewBuilderQuery();
});
expect(result.current.currentQuery.builder.queryData).toHaveLength(2);
expect(
result.current.currentQuery.builder.queryData[1].builderQueryType,
).toBeUndefined();
});
});

View File

@@ -1,12 +0,0 @@
export interface CheckoutSuccessPayloadProps {
redirectURL: string;
}
export interface CheckoutRequestPayloadProps {
url: string;
}
export interface PayloadProps {
data: CheckoutSuccessPayloadProps;
status: string;
}

View File

@@ -8,6 +8,7 @@ import {
} from 'types/common/queryBuilder';
import {
BuilderQueryType,
Filter,
Having as HavingV5,
LogAggregation,
@@ -90,6 +91,7 @@ export type IBuilderQuery = {
offset?: number;
selectColumns?: BaseAutocompleteData[] | TelemetryFieldKey[];
source?: 'meter' | '';
builderQueryType?: BuilderQueryType;
};
export interface IClickHouseQuery {

View File

@@ -1,14 +0,0 @@
export interface Filter {
key: string;
dataType: string;
type: string;
}
export interface Props {
signal: string;
}
export type PayloadProps = {
filters: Filter[];
signal: string;
};

View File

@@ -1,14 +0,0 @@
import { SignalType } from 'components/QuickFilters/types';
interface FilterType {
key: string;
datatype: string;
type: string;
}
export interface UpdateCustomFiltersProps {
data: {
filters: FilterType[];
signal: SignalType;
};
}

View File

@@ -16,6 +16,7 @@ export type RequestType =
export type QueryType =
| 'builder_query'
| 'builder_ai_query'
| 'builder_trace_operator'
| 'builder_formula'
| 'builder_sub_query'
@@ -23,6 +24,11 @@ export type QueryType =
| 'clickhouse_sql'
| 'promql';
export type BuilderQueryType = Extract<
QueryType,
'builder_query' | 'builder_ai_query'
>;
export type OrderDirection = 'asc' | 'desc';
export type JoinType = 'inner' | 'left' | 'right' | 'full' | 'cross';

View File

@@ -112,7 +112,7 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
ROLE_EDIT: ['ADMIN', 'EDITOR', 'VIEWER'],
MEMBERS_SETTINGS: ['ADMIN'],
SERVICE_ACCOUNTS_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
BILLING: ['ADMIN'],
BILLING: ['ADMIN', 'EDITOR', 'VIEWER'],
SUPPORT: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
SOMETHING_WENT_WRONG: ['ADMIN', 'EDITOR', 'VIEWER'],
LOGS_SAVE_VIEWS: ['ADMIN', 'EDITOR', 'VIEWER'],
@@ -186,4 +186,5 @@ export const routeWithInitialAuthZSupport = {
WORKSPACE_LOCKED: true,
WORKSPACE_SUSPENDED: true,
WORKSPACE_ACCESS_RESTRICTED: true,
BILLING: true,
} as const satisfies Partial<Record<keyof typeof ROUTES, true>>;

3
go.mod
View File

@@ -31,7 +31,6 @@ require (
github.com/google/uuid v1.6.0
github.com/gorilla/handlers v1.5.1
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
github.com/huandu/go-sqlbuilder v1.39.1
github.com/jackc/pgx/v5 v5.9.2
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12
@@ -58,7 +57,6 @@ require (
github.com/segmentio/analytics-go/v3 v3.2.1
github.com/sethvargo/go-password v0.2.0
github.com/smartystreets/goconvey v1.8.1
github.com/soheilhy/cmux v0.1.5
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
github.com/swaggest/jsonschema-go v0.3.78
@@ -134,6 +132,7 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
github.com/hashicorp/go-metrics v0.5.4 // indirect
github.com/huandu/go-clone v1.7.3 // indirect
github.com/leodido/go-urn v1.4.0 // indirect

3
go.sum
View File

@@ -1057,8 +1057,6 @@ github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY=
github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60=
github.com/soheilhy/cmux v0.1.5 h1:jjzc5WVemNEDTLwv9tlmemhC73tI08BNOIGwBOo10Js=
github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
@@ -1493,7 +1491,6 @@ golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81R
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=

View File

@@ -46,6 +46,10 @@ type Alertmanager interface {
// CreateChannel creates a channel for the organization.
CreateChannel(context.Context, string, *alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error)
// CreateNotificationChannel takes the postable rather than a receiver, because
// a receiver carries only the display name.
CreateNotificationChannel(context.Context, string, *alertmanagertypes.PostableNotificationChannel) (*alertmanagertypes.Channel, error)
// DeleteChannelByID deletes a channel for the organization.
DeleteChannelByID(context.Context, string, valuer.UUID) error

Some files were not shown because too many files have changed in this diff Show More