Compare commits

..

15 Commits

Author SHA1 Message Date
grandwizard28
94a2b85233 feat(web): move sentry dsn and tunnel to runtime web settings
Move the Sentry dsn and tunnel out of build-time Vite injection into the
web.settings config so they are configurable per deployment at runtime via
SIGNOZ_WEB_SETTINGS_SENTRY_DSN and SIGNOZ_WEB_SETTINGS_SENTRY_TUNNEL. The
backend injects them into index.html and the frontend reads them from
window.signozBootData.settings; environment and release stay build-time.
2026-06-30 16:11:09 +05:30
Yuvraj Singh Jadon
b71c49d01f docs: refresh README with current SigNoz offerings and features (#11595)
* docs: refresh README

* docs: update README Noz section

* docs: add Foundry to README self-host path

* docs: add README feature docs links

* docs: refine README links and badges

* docs: fix README LinkedIn badge link

* docs: update README instrumentation links

* docs: emphasize README contribution help

* docs: link Noz docs in README
2026-06-30 09:06:35 +00:00
Abhi kumar
570bc0aef1 feat(dashboards-v2): switch panel visualization type in the editor (#11820)
* refactor(dashboards-v2): extend ConfigSelect for the type switcher

Allow ConfigSelect items to carry an arbitrary icon node and a disabled
flag, and align the ConfigSegmented styling — the building blocks the
panel-type switcher needs.

* feat(dashboards-v2): add panel-type switch logic

Add getSwitchedPluginSpec (a reversible per-kind plugin-spec transform)
and the usePanelTypeSwitch hook that rebuilds the builder query and spec
when the panel kind changes, guarding null queries.

* feat(dashboards-v2): add the PanelTypeSwitcher control

A ConfigSelect-based control listing the panel kinds, disabling types
whose supported signals exclude the current datasource.

* refactor(dashboards-v2): component-based icons in the panel-type modal

Store icon components (not pre-rendered elements) in PANEL_TYPES so each
consumer controls sizing; rename constants.tsx to constants.ts now that
it holds no JSX.

* feat(dashboards-v2): wire the type switcher into the config pane

Render the switcher in the Visualization section, forward the panel
kind + datasource signal + switch handler through SectionSlot/registry,
and declare a Visualization section on every panel kind.

* feat(dashboards-v2): reuse findFreeSlot for panel clone placement

Clone now places the copy beside the last row when it fits, else wraps
to a fresh row at the section bottom — matching the new-panel save path
instead of always starting a new row.

* style(dashboards-v2): format getSwitchedPluginSpec test with oxfmt

Wrap the long getSwitchedPluginSpec(...) calls so the file passes
`oxfmt --check` (the fmt / js CI gate). Formatting only, no behavior change.

* chore: pr review changes

* refactor(dashboards-v2): make SectionKind a real enum

Replace the SectionKind string-literal union and the SECTION_KIND const
object with a TypeScript enum. SectionSpecMap, SectionControls,
SECTION_METADATA and the section registry are keyed by enum members
(computed keys), and every section-kind site (kind configs, section
editors, buildDefaultPluginSpec, getSwitchedPluginSpec) references
SectionKind.* instead of bare string literals.

* refactor(dashboards-v2): drop dead queries null-handling

panel.spec.queries is a required DashboardtypesQueryDTO[], so the
defensive `|| []` / `?? null` / optional-chaining and the dead
getBuilderQueries/toQueryEnvelopes guards were no-ops. Remove them across
the V2 dashboard: renderers, config pane, panel-type switch, query sync,
perses adapters, panel-query hook, and the query-range builder. Also
applies the same cleanup in usePanelEditorQuerySync and persesQueryAdapters
(missed by the first pass), and seeds the now-required `queries` field in
the ListPanel renderer test fixture.
2026-06-30 08:08:05 +00:00
Aditya Singh
a22e7b7b16 test(trace-details): add E2E coverage for Trace Details (#11846)
* feat(trace-details): move no-data component from v2 code to v3 before v2 cleanup

* feat(trace-details): move span logs out from v2 to v3 before cleanup

* feat(trace-details): move events  out from v2 to v3 before cleanup

* feat(trace-details): remove usage of getTraceV2 from V3 code

* fix(trace-details): fix serviceName path in trace funnel

* feat(trace-details): remove Trace Details V2 page and its module import

* feat(trace-details): remove unused trace details v2 code

* feat(trace-details): fix failing test

* test(trace-details): add e2e helper and large-trace fixture

* test(trace-details): add flamegraph e2e + canvas test hook

* test(trace-details): add waterfall e2e + row instrumentation

* test(trace-details): add highlight-errors filter e2e

* test(trace-details): add analytics panel e2e

* test(trace-details): add span details drawer e2e

* test(trace-details): add preview-fields hover card e2e

* test(trace-details): minor refactor

* test(trace-details): add common helpers

* test(trace-details): use playwright context instead of browser

* test(trace-details): add pinned side nav logic

* test(trace-details): remove tests which could go as unit or integration

* test(trace-details): remove tests and renumber
2026-06-30 06:46:56 +00:00
Vinicius Lourenço
fc83f91058 feat(invite-members): add reusable component for invite members (#11872)
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
Release Drafter / update_release_draft (push) Has been cancelled
* feat(invite-members): add hook for invite members component

* feat(invite-members): add component to handle invite member logic

* test(invite-members): add tests for the component
2026-06-30 01:37:20 +00:00
Vikrant Gupta
0d47f02100 fix(authz): validate transactionGroups in UpdateRole like CreateRole (#11898)
UpdatableRole.UnmarshalJSON unmarshalled transactionGroups directly into
the slice, bypassing the verb/resource/kind/selector validation that
CreateRole applies via NewTransactionGroups. Parse the field through
NewTransactionGroups so update enforces the same constraints, while
keeping transactionGroups required (omitted/null still rejected).
2026-06-29 20:30:25 +00:00
Vinicius Lourenço
810bf5d9a0 fix(notification-channels): edit not persisting any information (#11888)
* fix(edit-alerts): not persisting any information on save

* refactor(channels-edit): use matchPath instead of regex
2026-06-29 18:48:46 +00:00
Abhi kumar
7d8a00ab8c fix(uplotV2): tooltip list clips last row and over-scrolls (#11883)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
The Virtuoso scroller height was set to min(totalListHeight, 300), but the
scroll viewport adds 8px top + 8px bottom padding that totalListHeight does
not account for. The box was therefore ~16px shorter than its content,
clipping the last row and showing a scrollbar even when every row would fit.

Add the viewport's vertical padding back into the computed height so it only
scrolls once content genuinely exceeds the max height.
2026-06-29 17:58:01 +00:00
Gaurav Tewari
348fca1b62 feat(llm-pricing): listing page + table (2/5) (#11760)
* feat(llm-pricing): add model pricing foundation (route, permission, page shell)

* feat(llm-pricing): add listing page and table

* chore(llm-pricing): drop search + source filters from list request

The list API does not honour the q (search) and source params yet, so
the controls did nothing. Remove the search input and source dropdown
along with the params we sent, and trim useModelPricingFilters to the
URL-backed page state that pagination still needs. Currency dropdown,
tabs, table and pagination are unchanged. Filters will return once the
backend supports them.

* refactor(llm-pricing): extract getRelativeTime helper in utils

Pull the relative-time formatting out of getRelativeLastSeen into a
small local getRelativeTime helper. Kept feature-local (not in the
shared utils/timeUtils) so the LLM pricing module owns its own dayjs
config; the local relativeTime extend stays for test self-sufficiency.

* refactor(llm-pricing): drop dead NaN guard in formatPricePerMillion

Pricing fields are typed as required numbers and JSON can't carry NaN,
so Number.isNaN was unreachable. Keep the null/undefined guard as API
defensiveness (toFixed on a missing value would crash the row). Also
trims the now-redundant dayjs.extend comment.

* refactor(llm-pricing): centralize constants and shared types

Extract PAGE_SIZE, PAGE_KEY, COLUMN_COUNT and CURRENCY_OPTIONS into a
new constants.ts, and move the ModelPricingFilters contract into
types.ts. Component prop interfaces stay colocated with their
components, matching the convention in the drawer PR.

* refactor(llm-pricing): use nuqs for list pagination URL state

Replace the hand-rolled useHistory + URLSearchParams plumbing in
useModelPricingFilters with nuqs useQueryState, matching the convention
used by the dashboards, alerts and k8s list pages. Behaviour is
unchanged: parseAsInteger.withDefault(1) keeps ?page=1 out of the URL
and history:'replace' avoids polluting the back-stack.

* refactor(llm-pricing): inline pagination, drop useModelPricingFilters

The hook had shrunk to a one-line nuqs wrapper after search/source were
removed, so inline the useQueryState call into the container and remove
the hook file plus the now-unused ModelPricingFilters type. When the
filters return (once the API honours them) they can move back into a
dedicated hook.

* feat(llm-pricing): disable currency selector (USD-only for now)

Only USD is priced today, so render the currency SelectSimple in a
disabled state pinned to USD. A disabled select can't fire onChange, so
the currency useState is dead — drop it (and the now-unused useState
import).

* refactor(llm-pricing): render model costs inside its tab + tab URL param

The listing was rendered outside the Tabs, so the tab was decorative.
Move all model-cost content (currency control, list query, table,
pagination, footer) into a ModelCostsTab component rendered as the
'Model costs' tab's children, and drive the active tab from a 'tab' URL
query param (nuqs). The container is now just the page shell. Unpriced
models stays a disabled placeholder for a later PR.

* style(llm-pricing): target @signozhq table slots, drop dead antd/leftover rules

The component uses @signozhq/ui Table/Tabs (Radix-based), not antd, so the
.ant-table-* and .ant-tabs-nav selectors never matched — the intended
uppercase/muted header styling wasn't applied. Retarget header/cell rules to
[data-slot='table-head'|'table-cell'] (no !important needed). Also remove dead
rules left over from the removed search/source/add UI (.filters-bar__search,
__source, __add, .page-header__actions) and the unused .source-badge--auto/
--override modifiers.

* fix(llm-pricing): constrain currency dropdown width, drop tab URL param

- Currency SelectSimple stretched to fill the filters bar; give it a fixed
  160px width (min-width couldn't cap the trigger).
- Model costs is the only enabled tab for now, so use Tabs defaultValue
  instead of a URL-backed param. Removes the nuqs tab state plus the now-unused
  TAB_KEYS/TAB_QUERY_KEY constants and TabKey type.

* chore: self review changes

* fix: add skeleton loading

* refactor: self review changes

* refactor: initial prop

* fix: update styling

* fix: add comments in utils

* fix: route thing

* refactor: migrate to css moduel

* refactor: migrate to css module

* refactor: migrate to tanstack table

* docs: clarify price precision comment

* chore: remove comment

* refactor: shell

* fix: add key to route

* feat: add flags

* chore: additional refactor

* chore: add commet in utis

* refactor: types and other things

* refactor: types and other things

* refactor: update routes

* chore: remove usd selector for now

* fix: layout shift

* refactor: styles

* refactor: typography component

* refactor: more changes

* refactor: typograhy

* chore: sync table

* chore: remove extra comment

* chore: use typograpgy test in table config

* fix: llm pricing listing

* fix: update missing styles

* chore: update edit and delete options

* chore: remove divider

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-06-29 16:19:33 +00:00
Yunus M
83e0e974fe fix: include query metadata in alert edit and new contexts (#11891) 2026-06-29 15:37:07 +00:00
Himanshu RW
10217274b8 fix(render): add missing Content-Type header in Error() response (#11890) 2026-06-29 15:03:37 +00:00
Tushar Vats
7c3ac5b221 chore: json tags for error struct (#11886)
* chore: json tags for error struct

* fix: udpate unit tests
2026-06-29 12:17:00 +00:00
Naman Verma
c1d40d7359 fix: schema fixes based on UI and MCP integration review (#11718)
* fix: change schema properties based on UI integration review

* fix: check that panels referred in layouts exist

* chore: extract out validate panels method

* test: add test for missing spec prefix in layout

* fix: reject dashbaords that have vars with the same name

* fix: add additional error info on patch application error

* fix: add validations to list variable that text variable has

* fix: replicate text variable spec in signoz to make name required

* chore: replicate variable.sort into signoz

* chore: remove unsupported enum values (causing errors right now)

* chore: fix variable sort type errors

* fix: add back enum values

* fix: reject single-element list default when allowMultiple is false in list variables

* fix: remove unused import

* fix: make display required

* chore: make queries non-nullable

* fix: properly define default value and datasource plugin spec's api specs

* fix: promote variable defaultValue to a named oneOf component

The list variable defaultValue was an inline string | []string oneOf,
which downstream codegen can't canonicalize: tfplugingen-openapi rejects
the inline scalar-or-array multi-type, and oapi-codegen has no named type
to attach the union's Marshal/UnmarshalJSON to.

Shape the vendored variable.DefaultValue as the named VariableDefaultValue
oneOf via a reflector InterceptSchema hook and let defaultValue $ref it,
instead of overriding the property inline. Regenerate the OpenAPI spec and
frontend client accordingly.

* refactor: move VariableDefaultValue oneOf into dashboardtypes

Define VariableDefaultValue in dashboardtypes as a subclass of the perses
variable.DefaultValue and attach the string | []string oneOf via its own
PrepareJSONSchema, instead of shaping the perses type from an openapi.go
InterceptSchema hook. This keeps the union's schema next to its type.

The named component is now DashboardtypesVariableDefaultValue; regenerate
the OpenAPI spec and frontend client accordingly.

---------

Co-authored-by: Ashwin Bhatkal <ashwin96@gmail.com>
Co-authored-by: grandwizard28 <vibhupandey28@gmail.com>
2026-06-29 12:02:33 +00:00
Nityananda Gohain
c5c1913f97 fix(querier): pad clamped time range for trace_id-filtered logs (#11800)
* fix(querier): pad clamped time range for trace_id-filtered logs

* chore: use a struct instead

* chore: more cleanup
2026-06-29 11:19:39 +00:00
Naman Verma
5ab6636863 feat: add api to fetch v2 dashboards for a metric name (#11784)
* feat: add api to fetch v2 dashboards for a metric name

* chore: switch to query param

* chore: generate API specs

* chore: use proper struct in return type of GetByMetricNamesV2

* chore: add method for escaping like patterns in sqlstore formatter

* fix: use only one db call in GetByMetricNamesV2

* chore: dont use type alias for list of references
2026-06-29 10:24:23 +00:00
195 changed files with 9879 additions and 1561 deletions

View File

@@ -1,197 +1,190 @@
<p align="center">
<img src="https://res.cloudinary.com/dcv3epinx/image/upload/v1618904450/signoz-images/LogoGithub_sigfbu.svg" alt="SigNoz-logo" width="240" />
<p align="center">Überwache deine Anwendungen und behebe Probleme in deinen bereitgestellten Anwendungen. SigNoz ist eine Open Source Alternative zu DataDog, New Relic, etc.</p>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/readme-assets/signoz-hero-dark.png" width="900">
<source media="(prefers-color-scheme: light)" srcset="docs/readme-assets/signoz-hero-light.png" width="900">
<img alt="SigNoz - Observability nach deinen Bedingungen, basierend auf offenen Standards." src="docs/readme-assets/signoz-hero-light.png" width="900">
</picture>
</p>
<p align="center">
<img alt="Downloads" src="https://img.shields.io/docker/pulls/signoz/query-service?label=Downloads"> </a>
<img alt="GitHub issues" src="https://img.shields.io/github/issues/signoz/signoz"> </a>
<a href="https://twitter.com/intent/tweet?text=Monitor%20your%20applications%20and%20troubleshoot%20problems%20with%20SigNoz,%20an%20open-source%20alternative%20to%20DataDog,%20NewRelic.&url=https://signoz.io/&via=SigNozHQ&hashtags=opensource,signoz,observability">
<img alt="tweet" src="https://img.shields.io/twitter/url/http/shields.io.svg?style=social"> </a>
<a href="README.md">English</a> ·
<a href="README.zh-cn.md">中文</a> ·
<a href="README.pt-br.md">Português</a>
</p>
<h3 align="center">
<a href="https://signoz.io/docs"><b>Dokumentation</b></a> &bull;
<a href="https://github.com/SigNoz/signoz/blob/main/README.md"><b>Readme auf Englisch </b></a> &bull;
<a href="https://github.com/SigNoz/signoz/blob/main/README.zh-cn.md"><b>ReadMe auf Chinesisch</b></a> &bull;
<a href="https://github.com/SigNoz/signoz/blob/main/README.pt-br.md"><b>ReadMe auf Portugiesisch</b></a> &bull;
<a href="https://signoz.io/slack"><b>Slack Community</b></a> &bull;
<a href="https://twitter.com/SigNozHq"><b>Twitter</b></a>
</h3>
<p align="center">
<a href="https://github.com/SigNoz/signoz/issues"><img alt="GitHub issues" src="https://img.shields.io/github/issues/SigNoz/signoz"></a>
<a href="https://github.com/SigNoz/signoz/releases"><img alt="GitHub release" src="https://img.shields.io/github/v/release/SigNoz/signoz?label=release"></a>
<a href="https://signoz.io/slack"><img alt="Slack community" src="https://img.shields.io/badge/slack-community-4A154B?logo=slack&logoColor=white"></a>
<a href="https://www.linkedin.com/company/signozio/"><img alt="LinkedIn" src="https://img.shields.io/badge/linkedin-SigNoz-0A66C2?logo=linkedin&logoColor=white"></a>
<a href="https://twitter.com/intent/tweet?text=Monitor%20your%20applications%20and%20troubleshoot%20problems%20with%20SigNoz,%20an%20open-source%20alternative%20to%20DataDog,%20NewRelic.&url=https://signoz.io/&via=SigNozHQ&hashtags=opensource,signoz,observability"><img alt="Tweet" src="https://img.shields.io/twitter/url/http/shields.io.svg?style=social"></a>
</p>
##
SigNoz ist eine Open-Source-Observability-Plattform auf Basis von OpenTelemetry. Wir bauen eine Enterprise-taugliche Alternative zu fragmentierten Monitoring-Stacks, mit Logs, Metriken, Traces, Alerts und Dashboards an einem Ort.
SigNoz hilft Entwicklern, Anwendungen zu überwachen und Probleme in ihren bereitgestellten Anwendungen zu beheben. Mit SigNoz können Sie Folgendes tun:
### Wähle, wie du SigNoz betreibst
👉 Visualisieren Sie Metriken, Traces und Logs in einer einzigen Oberfläche.
#### SigNoz Cloud (empfohlen)
👉 Sie können Metriken wie die p99-Latenz, Fehlerquoten für Ihre Dienste, externe API-Aufrufe und individuelle Endpunkte anzeigen.
Vollständig verwaltetes SigNoz mit 30 Tagen kostenloser Testphase, ohne Kreditkarte, nutzungsbasierter Preisgestaltung ab 49 USD und regionalem Datenhosting.
👉 Sie können die Ursache des Problems ermitteln, indem Sie zu den genauen Traces gehen, die das Problem verursachen, und detaillierte Flammenbilder einzelner Anfragetraces anzeigen.
[**Kostenlos starten →**](https://signoz.io/teams/)
👉 Führen Sie Aggregationen auf Trace-Daten durch, um geschäftsrelevante Metriken zu erhalten.
#### Enterprise
👉 Filtern und Abfragen von Logs, Erstellen von Dashboards und Benachrichtigungen basierend auf Attributen in den Logs.
Enterprise Cloud, BYOC oder Enterprise Self-Hosted mit Compliance, Support, benutzerdefinierter Aufbewahrung, RBAC, Ingestion Controls, Datenresidenz und Regionsauswahl.
👉 Automatische Aufzeichnung von Ausnahmen in Python, Java, Ruby und Javascript.
[**Enterprise entdecken →**](https://signoz.io/enterprise/)
👉 Einfache Einrichtung von Benachrichtigungen mit dem selbst erstellbaren Abfrage-Builder.
#### Community
##
Kostenloses Open-Source-SigNoz, das in deiner eigenen Infrastruktur läuft. Deployment mit Docker, Kubernetes oder Linux, während du die volle Kontrolle über deine Datenebene behältst.
### Anwendung Metriken
[**SigNoz installieren →**](https://signoz.io/docs/install/self-host/)
![application_metrics](https://user-images.githubusercontent.com/83692067/226637410-900dbc5e-6705-4b11-a10c-bd0faeb2a92f.png)
### Was kannst du überwachen?
### Verteiltes Tracing
SigNoz hilft Teams, Produktionsprobleme schneller zu debuggen, indem Logs, Metriken, Traces, Alerts, Dashboards, Exceptions und agent-native Workflows an einem Ort verbunden werden.
<img width="2068" alt="distributed_tracing_2 2" src="https://user-images.githubusercontent.com/83692067/226536447-bae58321-6a22-4ed3-af80-e3e964cb3489.png">
#### APM-Überblick
<img width="2068" alt="distributed_tracing_1" src="https://user-images.githubusercontent.com/83692067/226536462-939745b6-4f9d-45a6-8016-814837e7f7b4.png">
Überwache Service-Latenz, Fehlerrate, Durchsatz, Apdex, wichtige Endpunkte, Datenbankaufrufe und externe Aufrufe.
### Log Verwaltung
<p align="center">
<img alt="SigNoz APM-Dashboard mit Latenz, Durchsatz, Apdex und wichtigen Operationen" src="docs/readme-assets/monitor/apm.png" width="900">
</p>
<img width="2068" alt="logs_management" src="https://user-images.githubusercontent.com/83692067/226536482-b8a5c4af-b69c-43d5-969c-338bd5eaf1a5.png">
Mehr erfahren: [APM-Dokumentation](https://signoz.io/docs/instrumentation/overview/)
### Infrastruktur Überwachung
#### Log-Management
<img width="2068" alt="infrastructure_monitoring" src="https://user-images.githubusercontent.com/83692067/226536496-f38c4dbf-e03c-4158-8be0-32d4a61158c7.png">
Erfasse, suche, aggregiere und korreliere Logs mit Traces und Metriken über einen visuellen Query Builder.
### Exceptions Monitoring
<p align="center">
<img alt="SigNoz Logs Explorer mit Filtern, Frequenzdiagramm und Log-Zeilen" src="docs/readme-assets/monitor/log-management.svg" width="900">
</p>
![exceptions_light](https://user-images.githubusercontent.com/83692067/226637967-4188d024-3ac9-4799-be95-f5ea9c45436f.png)
Mehr erfahren: [Log-Management-Dokumentation](https://signoz.io/docs/logs-management/overview/)
### Alarme
#### Metriken und Dashboards
<img width="2068" alt="alerts_management" src="https://user-images.githubusercontent.com/83692067/226536548-2c81e2e8-c12d-47e8-bad7-c6be79055def.png">
Erstelle Dashboards für Anwendungs-, Infrastruktur- und benutzerdefinierte Metriken mit Query Builder, PromQL oder ClickHouse SQL.
<br /><br />
<p align="center">
<img alt="SigNoz Host-Metrics-Dashboard mit Systemlast- und Netzwerkdiagrammen" src="docs/readme-assets/monitor/metrics.png" width="900">
</p>
## Werde Teil unserer Slack Community
Mehr erfahren: [Metriken-Dokumentation](https://signoz.io/docs/metrics-management/overview/)
Sag Hi zu uns auf [Slack](https://signoz.io/slack) 👋
#### Infrastruktur-Monitoring
<br /><br />
Überwache Kubernetes-Cluster, Pods, Nodes, Workloads sowie Host-CPU, Arbeitsspeicher, Festplatten, Netzwerk, Logs und Traces.
## Funktionen:
<p align="center">
<img alt="SigNoz Kubernetes-Infrastruktur-Dashboard mit Pod- und Node-Metriken" src="docs/readme-assets/monitor/infrastructure.png" width="900">
</p>
- Einheitliche Benutzeroberfläche für Metriken, Traces und Logs. Keine Notwendigkeit, zwischen Prometheus und Jaeger zu wechseln, um Probleme zu debuggen oder ein separates Log-Tool wie Elastic neben Ihrer Metriken- und Traces-Stack zu verwenden.
- Überblick über Anwendungsmetriken wie RPS, Latenzzeiten des 50tes/90tes/99tes Perzentils und Fehlerquoten.
- Langsamste Endpunkte in Ihrer Anwendung.
- Zeigen Sie genaue Anfragetraces an, um Probleme in nachgelagerten Diensten, langsamen Datenbankabfragen oder Aufrufen von Drittanbieterdiensten wie Zahlungsgateways zu identifizieren.
- Filtern Sie Traces nach Dienstname, Operation, Latenz, Fehler, Tags/Annotationen.
- Führen Sie Aggregationen auf Trace-Daten (Ereignisse/Spans) durch, um geschäftsrelevante Metriken zu erhalten. Beispielsweise können Sie die Fehlerquote und die 99tes Perzentillatenz für `customer_type: gold` oder `deployment_version: v2` oder `external_call: paypal` erhalten.
- Native Unterstützung für OpenTelemetry-Logs, erweiterten Log-Abfrage-Builder und automatische Log-Sammlung aus dem Kubernetes-Cluster.
- Blitzschnelle Log-Analytik ([Logs Perf. Benchmark](https://signoz.io/blog/logs-performance-benchmark/))
- End-to-End-Sichtbarkeit der Infrastrukturleistung, Aufnahme von Metriken aus allen Arten von Host-Umgebungen.
- Einfache Einrichtung von Benachrichtigungen mit dem selbst erstellbaren Abfrage-Builder.
Mehr erfahren: [Infrastruktur-Monitoring-Dokumentation](https://signoz.io/docs/infrastructure-monitoring/overview/)
<br /><br />
#### LLM- und AI-Observability
## Wieso SigNoz?
Verfolge LLM-Apps, RAG-Pipelines, Prompts, Tool Calls, Tokens, Latenz und Kosten zusammen mit Anwendungs- und Infrastruktur-Telemetrie.
Als Entwickler fanden wir es anstrengend, uns für jede kleine Funktion, die wir haben wollten, auf Closed Source SaaS Anbieter verlassen zu müssen. Closed Source Anbieter überraschen ihre Kunden zum Monatsende oft mit hohen Rechnungen, die keine Transparenz bzgl. der Kostenaufteilung bieten.
<p align="center">
<img alt="SigNoz LLM-Observability-Dashboard für Traces, Token-Nutzung, Latenz und Kosten" src="docs/readme-assets/monitor/llm.png" width="900">
</p>
Wir wollten eine selbst gehostete, Open Source Variante von Lösungen wie DataDog, NewRelic für Firmen anbieten, die Datenschutz und Sicherheitsbedenken haben, bei der Weitergabe von Kundendaten an Drittanbieter.
Mehr erfahren: [LLM-Observability-Dokumentation](https://signoz.io/docs/llm-observability/)
Open Source gibt dir außerdem die totale Kontrolle über deine Konfiguration, Stichprobenentnahme und Betriebszeit. Du kannst des Weiteren neue Module auf Basis von SigNoz bauen, die erweiterte, geschäftsspezifische Funktionen anbieten.
#### Agent-Native Observability und MCP
### Languages supported:
Nutze den SigNoz MCP-Server, um Telemetrie in Coding Agents zu bringen, oder nutze Noz in SigNoz, um Incidents zu untersuchen, Alerts zu verbessern und Dashboards mit Produktionskontext zu erstellen. [Noz](https://signoz.io/docs/ai/noz/) ist nur in SigNoz Cloud verfügbar.
Wir unterstützen [OpenTelemetry](https://opentelemetry.io) als Bibliothek, mit der Sie Ihre Anwendungen instrumentieren können. Daher wird jedes von OpenTelemetry unterstützte Framework und jede Sprache auch von SignNoz unterstützt. Einige der wichtigsten unterstützten Sprachen sind:
<p align="center">
<img alt="SigNoz Noz-Oberfläche neben einem MCP-gestützten Agent-Workflow" src="docs/readme-assets/monitor/agent-native.png" width="900">
</p>
- Java
- Python
- NodeJS
- Go
- PHP
- .NET
- Ruby
- Elixir
- Rust
Mehr erfahren: [SigNoz MCP-Server-Dokumentation](https://signoz.io/docs/ai/signoz-mcp-server/) · [Agent-Skills-Dokumentation](https://signoz.io/docs/ai/agent-skills/#install-the-plugin)
Hier findest du die vollständige Liste von unterstützten Programmiersprachen - https://opentelemetry.io/docs/
#### Distributed Tracing
<br /><br />
Verfolge Requests über Services hinweg mit Flamegraphs, Waterfalls, Span Events, Filtern und Trace Analytics.
## Erste Schritte mit SigNoz
<p align="center">
<img alt="SigNoz Distributed-Tracing-Ansicht mit Flamegraph und Waterfall-Spans" src="docs/readme-assets/monitor/distributed-tracing.png" width="900">
</p>
### Bereitstellung mit Docker
Mehr erfahren: [Distributed-Tracing-Dokumentation](https://signoz.io/docs/instrumentation/)
Bitte folge den [hier](https://signoz.io/docs/install/docker/) aufgelisteten Schritten um deine Anwendung mit Docker bereitzustellen.
#### Trace Funnels
Die [Anleitungen zur Fehlerbehebung](https://signoz.io/docs/install/troubleshooting/) könnten hilfreich sein, falls du auf irgendwelche Schwierigkeiten stößt.
Erstelle Funnels aus Traces, um Drop-offs im Request-Flow, fehlgeschlagene Übergänge und systemische Workflow-Probleme zu verstehen.
<p>&nbsp </p>
<p align="center">
<img alt="SigNoz Trace Funnels mit Request-Flow-Drop-offs und fehlgeschlagenen Übergängen" src="docs/readme-assets/monitor/trace-funnels.png" width="900">
</p>
### Deploy in Kubernetes using Helm
Mehr erfahren: [Trace-Funnels-Dokumentation](https://signoz.io/docs/trace-funnels/overview/)
Bitte folge den [hier](https://signoz.io/docs/deployment/helm_chart) aufgelisteten Schritten, um deine Anwendung mit Helm Charts bereitzustellen.
Du kannst außerdem [**Exceptions**](https://signoz.io/docs/userguide/exceptions/), [**Alerts**](https://signoz.io/docs/alerts/), [**externe APIs**](https://signoz.io/docs/external-api-monitoring/overview/) und [**Integrationen**](https://signoz.io/docs/integrations/integrations-list/) für OpenTelemetry, Prometheus, Kubernetes, Cloud-Anbieter, Sprach-SDKs, Application Frameworks, Datenbanken und LLM-Tools überwachen.
<br /><br />
### Warum Teams SigNoz nutzen
## Vergleiche mit bekannten Tools
1. **OpenTelemetry-native**<br>
Einmal mit offenen Standards instrumentieren und die Kontrolle über deine Telemetrie behalten.
2. **Korrelierte Signale**<br>
Von Service-Charts zu Traces, Logs, Infrastrukturmetriken und Exceptions wechseln, ohne das Tool zu wechseln.
3. **Eine einzelne spaltenorientierte Datenbank**<br>
Gebaut für hochkardinale Observability-Workloads mit hohem Volumen.
4. **Vorhersehbare Preise**<br>
Keine Preise pro Host, keine Preise pro Nutzerplatz und keine Sonderpreise für Custom Metrics.
5. **Enterprise-ready**<br>
SOC 2 Type II und HIPAA Compliance, RBAC, Ingestion Controls, benutzerdefinierte Aufbewahrung, Support, BYOC und Self-Hosting.
### SigNoz vs Prometheus
### Erste Schritte
Prometheus ist gut, falls du dich nur für Metriken interessierst. Wenn du eine nahtlose Integration von Metriken und Einzelschritt-Fehlersuchen haben möchtest, ist die Kombination aus Prometheus und Jaeger nicht das Richtige für dich.
#### Mit Cloud starten
Unser Ziel ist es, eine integrierte Benutzeroberfläche aus Metriken und Einzelschritt-Fehlersuchen anzubieten, ähnlich wie es SaaS Anbieter wie Datadog tun, mit der Möglichkeit von erweitertem filtern und aggregieren von Fehlersuchen. Etwas, was in Jaeger aktuell fehlt.
Erstelle einen verwalteten SigNoz-Workspace und erhalte dein erstes Dashboard, ohne Observability-Infrastruktur betreiben zu müssen.
<p>&nbsp </p>
[**Kostenlos mit SigNoz Cloud starten**](https://signoz.io/teams/)
### SigNoz vs Jaeger
#### SigNoz selbst hosten
Jaeger kümmert sich nur um verteilte Einzelschritt-Fehlersuche. SigNoz erstellt sowohl Metriken als auch Einzelschritt-Fehlersuche, daneben haben wir auch Protokoll Verwaltung auf unserem Plan.
Betreibe SigNoz in deiner eigenen Infrastruktur mit Foundry, Docker, Kubernetes oder Linux.
Außerdem hat SigNoz noch mehr spezielle Funktionen im Vergleich zu Jaeger:
[**Foundry**](https://github.com/SigNoz/foundry) · [**Docker**](https://signoz.io/docs/install/docker/) · [**Kubernetes**](https://signoz.io/docs/install/kubernetes/) · [**Linux**](https://signoz.io/docs/install/linux/)
- Jaeger UI zeigt keine Metriken für Einzelschritt-Fehlersuchen oder für gefilterte Einzelschritt-Fehlersuchen an.
- Jaeger erstellt keine Aggregate für gefilterte Einzelschritt-Fehlersuchen, z. B. die P99 Latenz von Abfragen mit dem Tag `customer_type=premium`, was hingegen mit SigNoz leicht umsetzbar ist.
#### Daten senden
<p>&nbsp </p>
Instrumentiere Anwendungen und Infrastruktur mit OpenTelemetry, Prometheus, Sprach-SDKs und Integrationen.
### SigNoz vs Elastic
[**Instrumentation**](https://signoz.io/docs/instrumentation/) · [**Integrationen**](https://signoz.io/docs/integrations/integrations-list/)
- Die Verwaltung von SigNoz-Protokollen basiert auf 'ClickHouse', einem spaltenbasierten OLAP-Datenspeicher, der aggregierte Protokollanalyseabfragen wesentlich effizienter macht.
- 50 % geringerer Ressourcenbedarf im Vergleich zu Elastic während der Aufnahme.
### Vergleich mit bekannten Tools
Wir haben Benchmarks veröffentlicht, die Elastic mit SignNoz vergleichen. Schauen Sie es sich [hier](https://signoz.io/blog/logs-performance-benchmark/?utm_source=github-readme&utm_medium=logs-benchmark)
SigNoz wird häufig von Teams eingeführt, die von einzelnen Spezialtools oder kommerziellen Plattformen mit unvorhersehbarer Preisgestaltung wechseln.
<p>&nbsp </p>
**Prometheus**<br>
Gut, wenn du nur Metriken brauchst. SigNoz hält Metriken, Logs, Traces, Dashboards und Alerts zusammen, damit Teams mit korreliertem Kontext debuggen können.
### SigNoz vs Loki
**Jaeger**<br>
Jaeger macht ausschließlich Distributed Tracing. SigNoz ergänzt Metriken, Logs, Trace Analytics, Dashboards, Alerts, Exceptions und Trace-to-Log-Workflows.
- SigNoz unterstützt Aggregationen von Daten mit hoher Kardinalität über ein großes Volumen, Loki hingegen nicht.
- SigNoz unterstützt Indizes über Daten mit hoher Kardinalität und hat keine Beschränkungen hinsichtlich der Anzahl der Indizes, während Loki maximale Streams erreicht, wenn ein paar Indizes hinzugefügt werden.
- Das Durchsuchen großer Datenmengen ist in Loki im Vergleich zu SigNoz schwierig und langsam.
**Elastic**<br>
SigNoz nutzt eine spaltenorientierte Datenbank für effiziente Observability-Analysen und hochkardinale Log-Workloads, mit 50 % geringerem Ressourcenbedarf gegenüber Elastic während der Ingestion. Lies die [detaillierte Studie](https://signoz.io/blog/logs-performance-benchmark/?utm_source=github-readme&utm_medium=logs-benchmark).
Wir haben Benchmarks veröffentlicht, die Loki mit SigNoz vergleichen. Schauen Sie es sich [hier](https://signoz.io/blog/logs-performance-benchmark/?utm_source=github-readme&utm_medium=logs-benchmark)
**Loki**<br>
Im verlinkten Benchmark indexierte SigNoz alle Keys im Test-Setup, während Loki beim Hinzufügen weiterer Labels Max-Stream-Fehler erreichte. Lies die [detaillierte Studie](https://signoz.io/blog/logs-performance-benchmark/?utm_source=github-readme&utm_medium=logs-benchmark).
<br /><br />
## Mitwirken
## Zum Projekt beitragen
Wir freuen uns über große und kleine Beiträge. Lies bitte [CONTRIBUTING.md](CONTRIBUTING.md), um mit Beiträgen zu SigNoz loszulegen.
Wir ❤️ Beiträge zum Projekt, egal ob große oder kleine. Bitte lies dir zuerst die [CONTRIBUTING.md](CONTRIBUTING.md), durch, bevor du anfängst, Beiträge zu SigNoz zu machen.
Du bist dir nicht sicher, wie du anfangen sollst? Schreib uns einfach auf dem #contributing Kanal in unserer [slack community](https://signoz.io/slack)
Nicht sicher, wie du anfangen sollst? **Schreib uns einfach im Channel `#contributing` in unserer [Slack Community](https://signoz.io/slack).**
<br /><br />
## Dokumentation
Du findest unsere Dokumentation unter https://signoz.io/docs/. Falls etwas unverständlich ist oder fehlt, öffne gerne ein Github Issue mit dem Label `documentation` oder schreib uns über den Community Slack Channel.
<br /><br />
## Gemeinschaft
Werde Teil der [slack community](https://signoz.io/slack) um mehr über verteilte Einzelschritt-Fehlersuche, Messung von Systemzuständen oder SigNoz zu erfahren und sich mit anderen Nutzern und Mitwirkenden in Verbindung zu setzen.
Falls du irgendwelche Ideen, Fragen oder Feedback hast, kannst du sie gerne über unsere [Github Discussions](https://github.com/SigNoz/signoz/discussions) mit uns teilen.
Wie immer, Dank an unsere großartigen Mitwirkenden!
Wie immer: Danke an unsere großartigen Contributors!
<a href="https://github.com/signoz/signoz/graphs/contributors">
<img src="https://contrib.rocks/image?repo=signoz/signoz" />
<img alt="SigNoz Contributors" src="https://contrib.rocks/image?repo=signoz/signoz" />
</a>

278
README.md
View File

@@ -1,244 +1,190 @@
<h1 align="center" style="border-bottom: none">
<a href="https://signoz.io" target="_blank">
<img alt="SigNoz" src="https://github.com/user-attachments/assets/ef9a33f7-12d7-4c94-8908-0a02b22f0c18" width="100" height="100">
</a>
<br>SigNoz
</h1>
<p align="center">All your logs, metrics, and traces in one place. Monitor your application, spot issues before they occur and troubleshoot downtime quickly with rich context. SigNoz is a cost-effective open-source alternative to Datadog and New Relic. Visit <a href="https://signoz.io" target="_blank">signoz.io</a> for the full documentation, tutorials, and guide.</p>
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/readme-assets/signoz-hero-dark.png" width="900">
<source media="(prefers-color-scheme: light)" srcset="docs/readme-assets/signoz-hero-light.png" width="900">
<img alt="SigNoz - Observability on Your Terms, Powered by Open Standards." src="docs/readme-assets/signoz-hero-light.png" width="900">
</picture>
</p>
<p align="center">
<img alt="GitHub issues" src="https://img.shields.io/github/issues/signoz/signoz"> </a>
<a href="https://twitter.com/intent/tweet?text=Monitor%20your%20applications%20and%20troubleshoot%20problems%20with%20SigNoz,%20an%20open-source%20alternative%20to%20DataDog,%20NewRelic.&url=https://signoz.io/&via=SigNozHQ&hashtags=opensource,signoz,observability">
<img alt="tweet" src="https://img.shields.io/twitter/url/http/shields.io.svg?style=social"> </a>
<a href="README.zh-cn.md">中文</a> ·
<a href="README.de-de.md">Deutsch</a> ·
<a href="README.pt-br.md">Português</a>
</p>
<h3 align="center">
<a href="https://signoz.io/docs"><b>Documentation</b></a> &bull;
<a href="https://github.com/SigNoz/signoz/blob/main/README.zh-cn.md"><b>ReadMe in Chinese</b></a> &bull;
<a href="https://github.com/SigNoz/signoz/blob/main/README.de-de.md"><b>ReadMe in German</b></a> &bull;
<a href="https://github.com/SigNoz/signoz/blob/main/README.pt-br.md"><b>ReadMe in Portuguese</b></a> &bull;
<a href="https://signoz.io/slack"><b>Slack Community</b></a> &bull;
<a href="https://twitter.com/SigNozHq"><b>Twitter</b></a>
</h3>
## Features
<p align="center">
<a href="https://github.com/SigNoz/signoz/issues"><img alt="GitHub issues" src="https://img.shields.io/github/issues/SigNoz/signoz"></a>
<a href="https://github.com/SigNoz/signoz/releases"><img alt="GitHub release" src="https://img.shields.io/github/v/release/SigNoz/signoz?label=release"></a>
<a href="https://signoz.io/slack"><img alt="Slack community" src="https://img.shields.io/badge/slack-community-4A154B?logo=slack&logoColor=white"></a>
<a href="https://www.linkedin.com/company/signozio/"><img alt="LinkedIn" src="https://img.shields.io/badge/linkedin-SigNoz-0A66C2?logo=linkedin&logoColor=white"></a>
<a href="https://twitter.com/intent/tweet?text=Monitor%20your%20applications%20and%20troubleshoot%20problems%20with%20SigNoz,%20an%20open-source%20alternative%20to%20DataDog,%20NewRelic.&url=https://signoz.io/&via=SigNozHQ&hashtags=opensource,signoz,observability"><img alt="Tweet" src="https://img.shields.io/twitter/url/http/shields.io.svg?style=social"></a>
</p>
SigNoz is an open-source observability platform built on OpenTelemetry. Were building an enterprise-grade alternative to fragmented monitoring stacks, with logs, metrics, traces, alerts, and dashboards in one place.
### Application Performance Monitoring
### Choose how to run SigNoz
Use SigNoz APM to monitor your applications and services. It comes with out-of-box charts for key application metrics like p99 latency, error rate, Apdex and operations per second. You can also monitor the database and external calls made from your application. Read [more](https://signoz.io/application-performance-monitoring/).
#### SigNoz Cloud (Recommended)
You can [instrument](https://signoz.io/docs/instrumentation/) your application with OpenTelemetry to get started.
Fully managed SigNoz with a 30-day free trial, no credit card required, usage-based pricing that starts at $49, and regional data hosting.
![apm-cover](https://github.com/user-attachments/assets/fa5c0396-0854-4c8b-b972-9b62fd2a70d2)
[**Start free →**](https://signoz.io/teams/)
#### Enterprise
### Logs Management
Enterprise Cloud, BYOC, or Enterprise Self-Hosted with compliance, support, custom retention, RBAC, ingestion controls, data residency, and region selection.
SigNoz can be used as a centralized log management solution. We use ClickHouse (used by likes of Uber & Cloudflare) as a datastore, ⎯ an extremely fast and highly optimized storage for logs data. Instantly search through all your logs using quick filters and a powerful query builder.
[**Explore Enterprise →**](https://signoz.io/enterprise/)
You can also create charts on your logs and monitor them with customized dashboards. Read [more](https://signoz.io/log-management/).
#### Community
![logs-management-cover](https://github.com/user-attachments/assets/343588ee-98fb-4310-b3d2-c5bacf9c7384)
Free open-source SigNoz that runs in your own infrastructure. Deploy with Docker, Kubernetes, or Linux and keep full control of your data plane.
[**Install SigNoz →**](https://signoz.io/docs/install/self-host/)
### Distributed Tracing
### What can you monitor?
Distributed Tracing is essential to troubleshoot issues in microservices applications. Powered by OpenTelemetry, distributed tracing in SigNoz can help you track user requests across services to help you identify performance bottlenecks.
SigNoz helps teams debug production issues faster by connecting logs, metrics, traces, alerts, dashboards, exceptions, and agent-native workflows in one place.
See user requests in a detailed breakdown with the help of Flamegraphs and Gantt Charts. Click on any span to see the entire trace represented beautifully, which will help you make sense of where issues actually occurred in the flow of requests.
#### APM Overview
Read [more](https://signoz.io/distributed-tracing/).
Monitor service latency, error rate, throughput, Apdex, top endpoints, database calls, and external calls.
![distributed-tracing-cover](https://github.com/user-attachments/assets/9bfe060a-0c40-4922-9b55-8a97e1a4076c)
<p align="center">
<img alt="SigNoz APM dashboard showing latency, throughput, Apdex, and key operations" src="docs/readme-assets/monitor/apm.png" width="900">
</p>
Learn more: [APM documentation](https://signoz.io/docs/instrumentation/overview/)
#### Log Management
### Metrics and Dashboards
Ingest, search, aggregate, and correlate logs with traces and metrics using a visual query builder.
Ingest metrics from your infrastructure or applications and create customized dashboards to monitor them. Create visualization that suits your needs with a variety of panel types like pie chart, time-series, bar chart, etc.
<p align="center">
<img alt="SigNoz logs explorer with filters, frequency chart, and log lines" src="docs/readme-assets/monitor/log-management.svg" width="900">
</p>
Create queries on your metrics data quickly with an easy-to-use metrics query builder. Add multiple queries and combine those queries with formulae to create really complex queries quickly.
Learn more: [Log management documentation](https://signoz.io/docs/logs-management/overview/)
Read [more](https://signoz.io/metrics-and-dashboards/).
#### Metrics and Dashboards
![metrics-n-dashboards-cover](https://github.com/user-attachments/assets/a536fd71-1d2c-4681-aa7e-516d754c47a5)
Build dashboards for application, infrastructure, and custom metrics using Query Builder, PromQL, or ClickHouse SQL.
### LLM Observability
<p align="center">
<img alt="SigNoz host metrics dashboard with system load and network charts" src="docs/readme-assets/monitor/metrics.png" width="900">
</p>
Monitor and debug your LLM applications with comprehensive observability. Track LLM calls, analyze token usage, monitor performance, and gain insights into your AI application's behavior in production.
Learn more: [Metrics documentation](https://signoz.io/docs/metrics-management/overview/)
SigNoz LLM observability helps you understand how your language models are performing, identify issues with prompts and responses, track token usage and costs, and optimize your AI applications for better performance and reliability.
#### Infrastructure Monitoring
[Get started with LLM Observability →](https://signoz.io/docs/llm-observability/)
Monitor Kubernetes clusters, pods, nodes, workloads, and host-level CPU, memory, disk, network, logs, and traces.
![llm-observability-cover](https://github.com/user-attachments/assets/a6cc0ca3-59df-48f9-9c16-7c843fccff96)
<p align="center">
<img alt="SigNoz Kubernetes infrastructure dashboard with pod and node metrics" src="docs/readme-assets/monitor/infrastructure.png" width="900">
</p>
Learn more: [Infrastructure monitoring documentation](https://signoz.io/docs/infrastructure-monitoring/overview/)
### Alerts
#### LLM and AI Observability
Use alerts in SigNoz to get notified when anything unusual happens in your application. You can set alerts on any type of telemetry signal (logs, metrics, traces), create thresholds and set up a notification channel to get notified. Advanced features like alert history and anomaly detection can help you create smarter alerts.
Trace LLM apps, RAG pipelines, prompts, tool calls, tokens, latency, and costs alongside application and infrastructure telemetry.
Alerts in SigNoz help you identify issues proactively so that you can address them before they reach your customers.
<p align="center">
<img alt="SigNoz LLM observability dashboard for traces, token usage, latency, and costs" src="docs/readme-assets/monitor/llm.png" width="900">
</p>
Read [more](https://signoz.io/alerts-management/).
Learn more: [LLM observability documentation](https://signoz.io/docs/llm-observability/)
![alerts-cover](https://github.com/user-attachments/assets/03873bb8-1b62-4adf-8f56-28bb7b1750ea)
#### Agent-Native Observability and MCP
### Exceptions Monitoring
Use the SigNoz MCP server to bring telemetry into coding agents, or use Noz inside SigNoz to investigate incidents, tune alerts, and build dashboards with production context. [Noz](https://signoz.io/docs/ai/noz/) is available only on SigNoz Cloud.
Monitor exceptions automatically in Python, Java, Ruby, and Javascript. For other languages, just drop in a few lines of code and start monitoring exceptions.
<p align="center">
<img alt="SigNoz Noz interface alongside MCP-powered agent workflow" src="docs/readme-assets/monitor/agent-native.png" width="900">
</p>
See the detailed stack trace for all exceptions caught in your application. You can also log in custom attributes to add more context to your exceptions. For example, you can add attributes to identify users for which exceptions occurred.
Learn more: [SigNoz MCP server docs](https://signoz.io/docs/ai/signoz-mcp-server/) · [Agent skills docs](https://signoz.io/docs/ai/agent-skills/#install-the-plugin)
Read [more](https://signoz.io/exceptions-monitoring/).
#### Distributed Tracing
Follow requests across services with flamegraphs, waterfalls, span events, filters, and trace analytics.
![exceptions-cover](https://github.com/user-attachments/assets/4be37864-59f2-4e8a-8d6e-e29ad04298c5)
<p align="center">
<img alt="SigNoz distributed trace view with flamegraph and waterfall spans" src="docs/readme-assets/monitor/distributed-tracing.png" width="900">
</p>
Learn more: [Distributed tracing documentation](https://signoz.io/docs/instrumentation/)
<br /><br />
#### Trace Funnels
## Why SigNoz?
Create funnels from traces to understand request-flow drop-offs, failed transitions, and systemic workflow issues.
SigNoz is a single tool for all your monitoring and observability needs. Here are a few reasons why you should choose SigNoz:
<p align="center">
<img alt="SigNoz trace funnels showing request-flow drop-offs and failed transitions" src="docs/readme-assets/monitor/trace-funnels.png" width="900">
</p>
- Single tool for observability(logs, metrics, and traces)
Learn more: [Trace funnels documentation](https://signoz.io/docs/trace-funnels/overview/)
- Built on top of [OpenTelemetry](https://opentelemetry.io/), the open-source standard which frees you from any type of vendor lock-in
Also monitor: [**exceptions**](https://signoz.io/docs/userguide/exceptions/), [**alerts**](https://signoz.io/docs/alerts/), [**external APIs**](https://signoz.io/docs/external-api-monitoring/overview/), and [**integrations**](https://signoz.io/docs/integrations/integrations-list/) for OpenTelemetry, Prometheus, Kubernetes, cloud providers, language SDKs, application frameworks, databases, and LLM tools.
- Correlated logs, metrics and traces for much richer context while debugging
### Why teams use SigNoz
- Uses ClickHouse (used by likes of Uber & Cloudflare) as datastore - an extremely fast and highly optimized storage for observability data
1. **OpenTelemetry-native**<br>
Instrument once with open standards and keep ownership of your telemetry.
2. **Correlated signals**<br>
Move from service charts to traces, logs, infra metrics, and exceptions without switching tools.
3. **Single columnar database**<br>
Built for high-cardinality, high-volume observability workloads.
4. **Predictable pricing**<br>
No per-host pricing, no user-seat pricing, and no special pricing for custom metrics.
5. **Enterprise ready**<br>
SOC 2 Type II and HIPAA compliance, RBAC, ingestion controls, custom retention, support, BYOC, and self-hosting.
- DIY Query builder, PromQL, and ClickHouse queries to fulfill all your use-cases around querying observability data
### Getting started
- Open-Source - you can use open-source, our [cloud service](https://signoz.io/teams/) or a mix of both based on your use case
#### Start on Cloud
Create a managed SigNoz workspace and get your first dashboard without running observability infrastructure.
## Getting Started
[**Start free on SigNoz Cloud**](https://signoz.io/teams/)
### Create a SigNoz Cloud Account
#### Self-host SigNoz
SigNoz cloud is the easiest way to get started with SigNoz. Our cloud service is for those users who want to spend more time in getting insights for their application performance without worrying about maintenance.
Run SigNoz in your own infrastructure with Foundry, Docker, Kubernetes, or Linux.
[Get started for free](https://signoz.io/teams/)
[**Foundry**](https://github.com/SigNoz/foundry) · [**Docker**](https://signoz.io/docs/install/docker/) · [**Kubernetes**](https://signoz.io/docs/install/kubernetes/) · [**Linux**](https://signoz.io/docs/install/linux/)
### Deploy using Docker(self-hosted)
#### Send data
Please follow the steps listed [here](https://signoz.io/docs/install/docker/) to install using docker
Instrument applications and infrastructure with OpenTelemetry, Prometheus, language SDKs, and integrations.
The [troubleshooting instructions](https://signoz.io/docs/install/troubleshooting/) may be helpful if you face any issues.
[**Instrumentation**](https://signoz.io/docs/instrumentation/) · [**Integrations**](https://signoz.io/docs/integrations/integrations-list/)
<p>&nbsp </p>
### Deploy in Kubernetes using Helm(self-hosted)
### Comparisons to familiar tools
Please follow the steps listed [here](https://signoz.io/docs/deployment/helm_chart) to install using helm charts
SigNoz is often adopted by teams moving from a stack of single-purpose tools or commercial platforms with unpredictable pricing.
<br /><br />
**Prometheus**<br>
Good if you just need metrics. SigNoz keeps metrics, logs, traces, dashboards, and alerts together so teams can debug with correlated context.
We also offer managed services in your infra. Check our [pricing plans](https://signoz.io/pricing/) for all details.
**Jaeger**<br>
Jaeger only does distributed tracing. SigNoz adds metrics, logs, trace analytics, dashboards, alerts, exceptions, and trace-to-log workflows.
**Elastic**<br>
SigNoz uses columnar database for efficient observability analytics and high-cardinality log workloads, with 50% lower resource requirement compared to Elastic during ingestion. Check the [detailed study](https://signoz.io/blog/logs-performance-benchmark/?utm_source=github-readme&utm_medium=logs-benchmark).
## Join our Slack community
Come say Hi to us on [Slack](https://signoz.io/slack) 👋
<br /><br />
### Languages supported:
SigNoz supports all major programming languages for monitoring. Any framework and language supported by OpenTelemetry is supported by SigNoz. Find instructions for instrumenting different languages below:
- [Java](https://signoz.io/docs/instrumentation/java/)
- [Python](https://signoz.io/docs/instrumentation/python/)
- [Node.js or Javascript](https://signoz.io/docs/instrumentation/javascript/)
- [Go](https://signoz.io/docs/instrumentation/golang/)
- [PHP](https://signoz.io/docs/instrumentation/php/)
- [.NET](https://signoz.io/docs/instrumentation/dotnet/)
- [Ruby](https://signoz.io/docs/instrumentation/ruby-on-rails/)
- [Elixir](https://signoz.io/docs/instrumentation/elixir/)
- [Rust](https://signoz.io/docs/instrumentation/rust/)
- [Swift](https://signoz.io/docs/instrumentation/swift/)
You can find our entire documentation [here](https://signoz.io/docs/introduction/).
<br /><br />
## Comparisons to Familiar Tools
### SigNoz vs Prometheus
Prometheus is good if you want to do just metrics. But if you want to have a seamless experience between metrics, logs and traces, then current experience of stitching together Prometheus & other tools is not great.
SigNoz is a one-stop solution for metrics and other telemetry signals. And because you will use the same standard(OpenTelemetry) to collect all telemetry signals, you can also correlate these signals to troubleshoot quickly.
For example, if you see that there are issues with infrastructure metrics of your k8s cluster at a timestamp, you can jump to other signals like logs and traces to understand the issue quickly.
<p>&nbsp </p>
### SigNoz vs Jaeger
Jaeger only does distributed tracing. SigNoz supports metrics, traces and logs - all the 3 pillars of observability.
Moreover, SigNoz has few more advanced features wrt Jaeger:
- Jaegar UI doesnt show any metrics on traces or on filtered traces
- Jaeger cant get aggregates on filtered traces. For example, p99 latency of requests which have tag - customer_type='premium'. This can be done easily on SigNoz
- You can also go from traces to logs easily in SigNoz
<p>&nbsp </p>
### SigNoz vs Elastic
- SigNoz Logs management are based on ClickHouse, a columnar OLAP datastore which makes aggregate log analytics queries much more efficient
- 50% lower resource requirement compared to Elastic during ingestion
We have published benchmarks comparing Elastic with SigNoz. Check it out [here](https://signoz.io/blog/logs-performance-benchmark/?utm_source=github-readme&utm_medium=logs-benchmark)
<p>&nbsp </p>
### SigNoz vs Loki
- SigNoz supports aggregations on high-cardinality data over a huge volume while loki doesnt.
- SigNoz supports indexes over high cardinality data and has no limitations on the number of indexes, while Loki reaches max streams with a few indexes added to it.
- Searching over a huge volume of data is difficult and slow in Loki compared to SigNoz
We have published benchmarks comparing Loki with SigNoz. Check it out [here](https://signoz.io/blog/logs-performance-benchmark/?utm_source=github-readme&utm_medium=logs-benchmark)
<br /><br />
**Loki**<br>
In the linked benchmark, SigNoz indexed all keys in the test setup, while Loki hit max stream errors when more labels were added. Check the [detailed study](https://signoz.io/blog/logs-performance-benchmark/?utm_source=github-readme&utm_medium=logs-benchmark).
## Contributing
We ❤️ contributions big or small. Please read [CONTRIBUTING.md](CONTRIBUTING.md) to get started with making contributions to SigNoz.
Not sure how to get started? Just ping us on `#contributing` in our [slack community](https://signoz.io/slack)
<br /><br />
## Documentation
You can find docs at https://signoz.io/docs/. If you need any clarification or find something missing, feel free to raise a GitHub issue with the label `documentation` or reach out to us at the community slack channel.
<br /><br />
## Community
Join the [slack community](https://signoz.io/slack) to know more about distributed tracing, observability, or SigNoz and to connect with other users and contributors.
If you have any ideas, questions, or any feedback, please share on our [Github Discussions](https://github.com/SigNoz/signoz/discussions)
Not sure how to get started? **Just ping us on `#contributing` in our [slack community](https://signoz.io/slack).**
As always, thanks to our amazing contributors!
<a href="https://github.com/signoz/signoz/graphs/contributors">
<img src="https://contrib.rocks/image?repo=signoz/signoz" />
<img alt="SigNoz contributors" src="https://contrib.rocks/image?repo=signoz/signoz" />
</a>

View File

@@ -1,158 +1,190 @@
<p align="center">
<img src="https://res.cloudinary.com/dcv3epinx/image/upload/v1618904450/signoz-images/LogoGithub_sigfbu.svg" alt="SigNoz-logo" width="240" />
<p align="center">Monitore seus aplicativos e solucione problemas em seus aplicativos implantados, uma alternativa de código aberto para soluções como DataDog, New Relic, entre outras.</p>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/readme-assets/signoz-hero-dark.png" width="900">
<source media="(prefers-color-scheme: light)" srcset="docs/readme-assets/signoz-hero-light.png" width="900">
<img alt="SigNoz - Observabilidade nos seus termos, baseada em padrões abertos." src="docs/readme-assets/signoz-hero-light.png" width="900">
</picture>
</p>
<p align="center">
<img alt="Downloads" src="https://img.shields.io/docker/pulls/signoz/frontend?label=Downloads"> </a>
<img alt="GitHub issues" src="https://img.shields.io/github/issues/signoz/signoz"> </a>
<a href="https://twitter.com/intent/tweet?text=Monitor%20your%20applications%20and%20troubleshoot%20problems%20with%20SigNoz,%20an%20open-source%20alternative%20to%20DataDog,%20NewRelic.&url=https://signoz.io/&via=SigNozHQ&hashtags=opensource,signoz,observability">
<img alt="tweet" src="https://img.shields.io/twitter/url/http/shields.io.svg?style=social"> </a>
<a href="README.md">English</a> ·
<a href="README.zh-cn.md">中文</a> ·
<a href="README.de-de.md">Deutsch</a>
</p>
<h3 align="center">
<a href="https://signoz.io/docs"><b>Documentação</b></a> &bull;
<a href="https://signoz.io/slack"><b>Comunidade no Slack</b></a> &bull;
<a href="https://twitter.com/SigNozHq"><b>Twitter</b></a>
</h3>
##
<p align="center">
<a href="https://github.com/SigNoz/signoz/issues"><img alt="GitHub issues" src="https://img.shields.io/github/issues/SigNoz/signoz"></a>
<a href="https://github.com/SigNoz/signoz/releases"><img alt="GitHub release" src="https://img.shields.io/github/v/release/SigNoz/signoz?label=release"></a>
<a href="https://signoz.io/slack"><img alt="Slack community" src="https://img.shields.io/badge/slack-community-4A154B?logo=slack&logoColor=white"></a>
<a href="https://www.linkedin.com/company/signozio/"><img alt="LinkedIn" src="https://img.shields.io/badge/linkedin-SigNoz-0A66C2?logo=linkedin&logoColor=white"></a>
<a href="https://twitter.com/intent/tweet?text=Monitor%20your%20applications%20and%20troubleshoot%20problems%20with%20SigNoz,%20an%20open-source%20alternative%20to%20DataDog,%20NewRelic.&url=https://signoz.io/&via=SigNozHQ&hashtags=opensource,signoz,observability"><img alt="Tweet" src="https://img.shields.io/twitter/url/http/shields.io.svg?style=social"></a>
</p>
SigNoz auxilia os desenvolvedores a monitorarem aplicativos e solucionar problemas em seus aplicativos implantados. SigNoz usa rastreamento distribuído para obter visibilidade em sua pilha de software.
SigNoz é uma plataforma de observabilidade open-source construída sobre OpenTelemetry. Estamos criando uma alternativa de nível empresarial a stacks de monitoramento fragmentadas, com logs, métricas, traces, alertas e dashboards em um só lugar.
👉 Você pode verificar métricas como latência p99, taxas de erro em seus serviços, requisições às APIs externas e endpoints individuais.
### Escolha como executar o SigNoz
👉 Você pode encontrar a causa raiz do problema acessando os rastreamentos exatos que estão causando o problema e verificar os quadros detalhados de cada requisição individual.
#### SigNoz Cloud (recomendado)
👉 Execute agregações em dados de rastreamento para obter métricas de negócios relevantes.
SigNoz totalmente gerenciado, com teste gratuito de 30 dias, sem cartão de crédito, preço baseado em uso a partir de US$ 49 e hospedagem de dados por região.
[**Comece gratuitamente →**](https://signoz.io/teams/)
![SigNoz Feature](https://signoz-public.s3.us-east-2.amazonaws.com/signoz_hero_github.png)
#### Enterprise
<br /><br />
Enterprise Cloud, BYOC ou Enterprise Self-Hosted com compliance, suporte, retenção personalizada, RBAC, controles de ingestão, residência de dados e seleção de região.
<img align="left" src="https://signoz-public.s3.us-east-2.amazonaws.com/Contributing.svg" width="50px" />
[**Conheça o Enterprise →**](https://signoz.io/enterprise/)
## Junte-se à nossa comunidade no Slack
#### Community
Venha dizer oi para nós no [Slack](https://signoz.io/slack) 👋
SigNoz open-source gratuito, executado na sua própria infraestrutura. Faça o deploy com Docker, Kubernetes ou Linux e mantenha controle total sobre o seu plano de dados.
<br /><br />
[**Instale o SigNoz →**](https://signoz.io/docs/install/self-host/)
<img align="left" src="https://signoz-public.s3.us-east-2.amazonaws.com/Features.svg" width="50px" />
### O que você pode monitorar?
## Funções:
O SigNoz ajuda equipes a depurar problemas de produção mais rapidamente ao conectar logs, métricas, traces, alertas, dashboards, exceções e fluxos agent-native em um só lugar.
- Métricas de visão geral do aplicativo, como RPS, latências de percentual 50/90/99 e taxa de erro
- Endpoints mais lentos em seu aplicativo
- Visualize o rastreamento preciso de requisições de rede para descobrir problemas em serviços downstream, consultas lentas de banco de dados, chamadas para serviços de terceiros, como gateways de pagamento, etc.
- Filtre os rastreamentos por nome de serviço, operação, latência, erro, tags / anotações.
- Execute agregações em dados de rastreamento (eventos / extensões) para obter métricas de negócios relevantes, como por exemplo, você pode obter a taxa de erro e a latência do 99º percentil de `customer_type: gold` or `deployment_version: v2` or `external_call: paypal`
- Interface de Usuário unificada para métricas e rastreios. Não há necessidade de mudar de Prometheus para Jaeger para depurar problemas.
#### Visão geral de APM
<br /><br />
Monitore latência de serviço, taxa de erro, throughput, Apdex, principais endpoints, chamadas ao banco de dados e chamadas externas.
<img align="left" src="https://signoz-public.s3.us-east-2.amazonaws.com/WhatsCool.svg" width="50px" />
<p align="center">
<img alt="Dashboard de APM do SigNoz mostrando latência, throughput, Apdex e operações principais" src="docs/readme-assets/monitor/apm.png" width="900">
</p>
## Por que escolher SigNoz?
Saiba mais: [documentação de APM](https://signoz.io/docs/instrumentation/overview/)
Sendo desenvolvedores, achamos irritante contar com fornecedores de SaaS de código fechado para cada pequeno recurso que queríamos. Fornecedores de código fechado costumam surpreendê-lo com enormes contas no final do mês de uso sem qualquer transparência .
#### Gerenciamento de logs
Queríamos fazer uma versão auto-hospedada e de código aberto de ferramentas como DataDog, NewRelic para empresas que têm preocupações com privacidade e segurança em ter dados de clientes indo para serviços de terceiros.
Ingira, pesquise, agregue e correlacione logs com traces e métricas usando um construtor visual de consultas.
Ser open source também oferece controle completo de sua configuração, amostragem e tempos de atividade. Você também pode construir módulos sobre o SigNoz para estender recursos específicos do negócio.
<p align="center">
<img alt="Explorador de logs do SigNoz com filtros, gráfico de frequência e linhas de log" src="docs/readme-assets/monitor/log-management.svg" width="900">
</p>
### Linguagens Suportadas:
Saiba mais: [documentação de gerenciamento de logs](https://signoz.io/docs/logs-management/overview/)
Nós apoiamos a biblioteca [OpenTelemetry](https://opentelemetry.io) como a biblioteca que você pode usar para instrumentar seus aplicativos. Em outras palavras, SigNoz oferece suporte a qualquer framework e linguagem que suporte a biblioteca OpenTelemetry. As principais linguagens suportadas incluem:
#### Métricas e dashboards
- Java
- Python
- NodeJS
- Go
Crie dashboards para métricas de aplicação, infraestrutura e métricas personalizadas usando Query Builder, PromQL ou ClickHouse SQL.
Você pode encontrar a lista completa de linguagens aqui - https://opentelemetry.io/docs/
<p align="center">
<img alt="Dashboard de métricas de host do SigNoz com gráficos de carga do sistema e rede" src="docs/readme-assets/monitor/metrics.png" width="900">
</p>
<br /><br />
Saiba mais: [documentação de métricas](https://signoz.io/docs/metrics-management/overview/)
<img align="left" src="https://signoz-public.s3.us-east-2.amazonaws.com/Philosophy.svg" width="50px" />
#### Monitoramento de infraestrutura
## Iniciando
### Implantar usando Docker
Monitore clusters Kubernetes, pods, nodes, workloads e CPU, memória, disco, rede, logs e traces em nível de host.
Siga as etapas listadas [aqui](https://signoz.io/docs/install/docker/) para instalar usando o Docker.
<p align="center">
<img alt="Dashboard de infraestrutura Kubernetes do SigNoz com métricas de pods e nodes" src="docs/readme-assets/monitor/infrastructure.png" width="900">
</p>
Esse [guia para solução de problemas](https://signoz.io/docs/install/troubleshooting/) pode ser útil se você enfrentar quaisquer problemas.
Saiba mais: [documentação de monitoramento de infraestrutura](https://signoz.io/docs/infrastructure-monitoring/overview/)
<p>&nbsp </p>
### Implentar no Kubernetes usando Helm
#### Observabilidade de LLM e AI
Siga as etapas listadas [aqui](https://signoz.io/docs/deployment/helm_chart) para instalar usando helm charts.
Rastreie apps LLM, pipelines RAG, prompts, chamadas de ferramentas, tokens, latência e custos junto com telemetria de aplicação e infraestrutura.
<br /><br />
<p align="center">
<img alt="Dashboard de observabilidade de LLM do SigNoz para traces, uso de tokens, latência e custos" src="docs/readme-assets/monitor/llm.png" width="900">
</p>
<img align="left" src="https://signoz-public.s3.us-east-2.amazonaws.com/UseSigNoz.svg" width="50px" />
Saiba mais: [documentação de observabilidade de LLM](https://signoz.io/docs/llm-observability/)
## Comparações com ferramentas similares
#### Observabilidade agent-native e MCP
### SigNoz ou Prometheus
Use o servidor MCP do SigNoz para levar telemetria aos agentes de programação, ou use o Noz dentro do SigNoz para investigar incidentes, ajustar alertas e criar dashboards com contexto de produção. O [Noz](https://signoz.io/docs/ai/noz/) está disponível apenas no SigNoz Cloud.
Prometheus é bom se você quiser apenas fazer métricas. Mas se você quiser ter uma experiência perfeita entre métricas e rastreamentos, a experiência atual de unir Prometheus e Jaeger não é ótima.
<p align="center">
<img alt="Interface Noz do SigNoz ao lado de um fluxo agent via MCP" src="docs/readme-assets/monitor/agent-native.png" width="900">
</p>
Nosso objetivo é fornecer uma interface do usuário integrada entre métricas e rastreamentos - semelhante ao que fornecedores de SaaS como o Datadog fornecem - e fornecer filtragem e agregação avançada sobre rastreamentos, algo que a Jaeger atualmente carece.
Saiba mais: [documentação do servidor MCP do SigNoz](https://signoz.io/docs/ai/signoz-mcp-server/) · [documentação de agent skills](https://signoz.io/docs/ai/agent-skills/#install-the-plugin)
<p>&nbsp </p>
#### Tracing distribuído
### SigNoz ou Jaeger
Acompanhe requisições entre serviços com flamegraphs, waterfalls, eventos de span, filtros e análise de traces.
Jaeger só faz rastreamento distribuído. SigNoz faz métricas e rastreia, e também temos gerenciamento de log em nossos planos.
<p align="center">
<img alt="Visualização de tracing distribuído do SigNoz com flamegraph e spans em waterfall" src="docs/readme-assets/monitor/distributed-tracing.png" width="900">
</p>
Além disso, SigNoz tem alguns recursos mais avançados do que Jaeger:
Saiba mais: [documentação de tracing distribuído](https://signoz.io/docs/instrumentation/)
- A interface de usuário do Jaegar não mostra nenhuma métrica em traces ou em traces filtrados
- Jaeger não pode obter agregados em rastros filtrados. Por exemplo, latência p99 de solicitações que possuem tag - customer_type='premium'. Isso pode ser feito facilmente com SigNoz.
#### Trace Funnels
<br /><br />
Crie funis a partir de traces para entender quedas no fluxo de requisições, transições com falha e problemas sistêmicos de workflow.
<img align="left" src="https://signoz-public.s3.us-east-2.amazonaws.com/Contributors.svg" width="50px" />
<p align="center">
<img alt="Trace Funnels do SigNoz mostrando quedas no fluxo de requisições e transições com falha" src="docs/readme-assets/monitor/trace-funnels.png" width="900">
</p>
Saiba mais: [documentação de Trace Funnels](https://signoz.io/docs/trace-funnels/overview/)
Também monitore: [**exceções**](https://signoz.io/docs/userguide/exceptions/), [**alertas**](https://signoz.io/docs/alerts/), [**APIs externas**](https://signoz.io/docs/external-api-monitoring/overview/) e [**integrações**](https://signoz.io/docs/integrations/integrations-list/) para OpenTelemetry, Prometheus, Kubernetes, provedores de nuvem, SDKs de linguagem, frameworks de aplicação, bancos de dados e ferramentas de LLM.
### Por que equipes usam o SigNoz
1. **Nativo em OpenTelemetry**<br>
Instrumente uma vez com padrões abertos e mantenha a posse da sua telemetria.
2. **Sinais correlacionados**<br>
Vá de gráficos de serviço para traces, logs, métricas de infraestrutura e exceções sem trocar de ferramenta.
3. **Um único banco de dados colunar**<br>
Construído para workloads de observabilidade de alto volume e alta cardinalidade.
4. **Preço previsível**<br>
Sem cobrança por host, sem cobrança por usuário e sem preço especial para métricas personalizadas.
5. **Pronto para enterprise**<br>
Compliance SOC 2 Type II e HIPAA, RBAC, controles de ingestão, retenção personalizada, suporte, BYOC e self-hosting.
### Primeiros passos
#### Comece na Cloud
Crie um workspace gerenciado do SigNoz e obtenha seu primeiro dashboard sem operar infraestrutura de observabilidade.
[**Comece gratuitamente no SigNoz Cloud**](https://signoz.io/teams/)
#### Self-host SigNoz
Execute o SigNoz na sua própria infraestrutura com Foundry, Docker, Kubernetes ou Linux.
[**Foundry**](https://github.com/SigNoz/foundry) · [**Docker**](https://signoz.io/docs/install/docker/) · [**Kubernetes**](https://signoz.io/docs/install/kubernetes/) · [**Linux**](https://signoz.io/docs/install/linux/)
#### Envie dados
Instrumente aplicações e infraestrutura com OpenTelemetry, Prometheus, SDKs de linguagem e integrações.
[**Instrumentação**](https://signoz.io/docs/instrumentation/) · [**Integrações**](https://signoz.io/docs/integrations/integrations-list/)
### Comparações com ferramentas conhecidas
O SigNoz é frequentemente adotado por equipes que estão migrando de ferramentas de propósito único ou plataformas comerciais com preços imprevisíveis.
**Prometheus**<br>
Bom se você precisa apenas de métricas. O SigNoz mantém métricas, logs, traces, dashboards e alertas juntos para que equipes possam depurar com contexto correlacionado.
**Jaeger**<br>
Jaeger faz apenas tracing distribuído. O SigNoz adiciona métricas, logs, análise de traces, dashboards, alertas, exceções e fluxos de trace para log.
**Elastic**<br>
O SigNoz usa banco de dados colunar para análises de observabilidade eficientes e workloads de logs de alta cardinalidade, com 50% menos necessidade de recursos em comparação ao Elastic durante a ingestão. Confira o [estudo detalhado](https://signoz.io/blog/logs-performance-benchmark/?utm_source=github-readme&utm_medium=logs-benchmark).
**Loki**<br>
No benchmark vinculado, o SigNoz indexou todas as chaves na configuração de teste, enquanto o Loki atingiu erros de max streams ao adicionar mais labels. Confira o [estudo detalhado](https://signoz.io/blog/logs-performance-benchmark/?utm_source=github-readme&utm_medium=logs-benchmark).
## Contribuindo
Adoramos contribuições grandes ou pequenas. Leia [CONTRIBUTING.md](CONTRIBUTING.md) para começar a contribuir com o SigNoz.
Nós ❤️ contribuições grandes ou pequenas. Leia [CONTRIBUTING.md](CONTRIBUTING.md) para começar a fazer contribuições para o SigNoz.
Não sabe como começar? **Fale conosco no `#contributing` na nossa [comunidade Slack](https://signoz.io/slack).**
Não sabe como começar? Basta enviar um sinal para nós no canal `#contributing` em nossa [comunidade no Slack.](https://signoz.io/slack)
<br /><br />
<img align="left" src="https://signoz-public.s3.us-east-2.amazonaws.com/DevelopingLocally.svg" width="50px" />
## Documentação
Você pode encontrar a documentação em https://signoz.io/docs/. Se você tiver alguma dúvida ou sentir falta de algo, sinta-se à vontade para criar uma issue com a tag `documentation` no GitHub ou entre em contato conosco no canal da comunidade no Slack.
<br /><br />
<img align="left" src="https://signoz-public.s3.us-east-2.amazonaws.com/Contributing.svg" width="50px" />
## Comunidade
Junte-se a [comunidade no Slack](https://signoz.io/slack) para saber mais sobre rastreamento distribuído, observabilidade ou SigNoz e para se conectar com outros usuários e colaboradores.
Se você tiver alguma ideia, pergunta ou feedback, compartilhe em nosso [Github Discussões](https://github.com/SigNoz/signoz/discussions)
Como sempre, obrigado aos nossos incríveis colaboradores!
Como sempre, obrigado aos nossos incríveis contribuidores!
<a href="https://github.com/signoz/signoz/graphs/contributors">
<img src="https://contrib.rocks/image?repo=signoz/signoz" />
<img alt="Contribuidores do SigNoz" src="https://contrib.rocks/image?repo=signoz/signoz" />
</a>

View File

@@ -1,208 +1,190 @@
<img src="https://res.cloudinary.com/dcv3epinx/image/upload/v1618904450/signoz-images/LogoGithub_sigfbu.svg" alt="SigNoz-logo" width="240" />
<p align="center">监控你的应用,并且可排查已部署应用的问题,这是一个可替代 DataDog、NewRelic 的开源方案</p>
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/readme-assets/signoz-hero-dark.png" width="900">
<source media="(prefers-color-scheme: light)" srcset="docs/readme-assets/signoz-hero-light.png" width="900">
<img alt="SigNoz - 按你的方式运行的可观测性,由开放标准驱动。" src="docs/readme-assets/signoz-hero-light.png" width="900">
</picture>
</p>
<p align="center">
<img alt="Downloads" src="https://img.shields.io/docker/pulls/signoz/query-service?label=Docker Downloads"> </a>
<img alt="GitHub issues" src="https://img.shields.io/github/issues/signoz/signoz"> </a>
<a href="https://twitter.com/intent/tweet?text=Monitor%20your%20applications%20and%20troubleshoot%20problems%20with%20SigNoz,%20an%20open-source%20alternative%20to%20DataDog,%20NewRelic.&url=https://signoz.io/&via=SigNozHQ&hashtags=opensource,signoz,observability">
<img alt="tweet" src="https://img.shields.io/twitter/url/http/shields.io.svg?style=social"> </a>
<a href="README.md">English</a> ·
<a href="README.de-de.md">Deutsch</a> ·
<a href="README.pt-br.md">Português</a>
</p>
<h3 align="center">
<a href="https://signoz.io/docs"><b>文档</b></a>
<a href="https://github.com/SigNoz/signoz/blob/main/README.zh-cn.md"><b>中文ReadMe</b></a>
<a href="https://github.com/SigNoz/signoz/blob/main/README.de-de.md"><b>德文ReadMe</b></a>
<a href="https://github.com/SigNoz/signoz/blob/main/README.pt-br.md"><b>葡萄牙语ReadMe</b></a>
<a href="https://signoz.io/slack"><b>Slack 社区</b></a>
<a href="https://twitter.com/SigNozHq"><b>Twitter</b></a>
</h3>
<p align="center">
<a href="https://github.com/SigNoz/signoz/issues"><img alt="GitHub issues" src="https://img.shields.io/github/issues/SigNoz/signoz"></a>
<a href="https://github.com/SigNoz/signoz/releases"><img alt="GitHub release" src="https://img.shields.io/github/v/release/SigNoz/signoz?label=release"></a>
<a href="https://signoz.io/slack"><img alt="Slack community" src="https://img.shields.io/badge/slack-community-4A154B?logo=slack&logoColor=white"></a>
<a href="https://www.linkedin.com/company/signozio/"><img alt="LinkedIn" src="https://img.shields.io/badge/linkedin-SigNoz-0A66C2?logo=linkedin&logoColor=white"></a>
<a href="https://twitter.com/intent/tweet?text=Monitor%20your%20applications%20and%20troubleshoot%20problems%20with%20SigNoz,%20an%20open-source%20alternative%20to%20DataDog,%20NewRelic.&url=https://signoz.io/&via=SigNozHQ&hashtags=opensource,signoz,observability"><img alt="Tweet" src="https://img.shields.io/twitter/url/http/shields.io.svg?style=social"></a>
</p>
##
SigNoz 是一个基于 OpenTelemetry 构建的开源可观测性平台。我们正在构建一个企业级替代方案,用来替代分散的监控工具栈,把日志、指标、链路追踪、告警和仪表盘放在同一个地方。
SigNoz 帮助开发人员监控应用并排查已部署应用的问题。你可以使用 SigNoz 实现如下能力:
### 选择 SigNoz 的运行方式
👉 在同一块面板上,可视化 Metrics, Traces 和 Logs 内容。
#### SigNoz Cloud推荐
👉 你可以关注服务的 p99 延迟和错误率, 包括外部 API 调用和个别的端点
完全托管的 SigNoz提供 30 天免费试用,无需信用卡,按用量计费,起价为 49 美元,并支持区域化数据托管
👉 你可以找到问题的根因,通过提取相关问题的 traces 日志、单独查看请求 traces 的火焰图详情。
[**免费开始 →**](https://signoz.io/teams/)
👉 执行 trace 数据聚合,以获取业务相关的 metrics
#### 企业版
👉 对日志过滤和查询,通过日志的属性建立看板和告警
Enterprise Cloud、BYOC 或 Enterprise Self-Hosted提供合规、支持、自定义保留期、RBAC、摄取控制、数据驻留和区域选择。
👉 通过 PythonjavaRuby 和 Javascript 自动记录异常
[**了解企业版 →**](https://signoz.io/enterprise/)
👉 轻松的自定义查询和设置告警
#### 社区版
### 应用 Metrics 展示
免费的开源 SigNoz可运行在你自己的基础设施中。使用 Docker、Kubernetes 或 Linux 部署,并完全掌控你的数据平面。
![application_metrics](https://user-images.githubusercontent.com/83692067/226637410-900dbc5e-6705-4b11-a10c-bd0faeb2a92f.png)
[**安装 SigNoz →**](https://signoz.io/docs/install/self-host/)
### 分布式追踪
### 你可以监控什么?
<img width="2068" alt="distributed_tracing_2 2" src="https://user-images.githubusercontent.com/83692067/226536447-bae58321-6a22-4ed3-af80-e3e964cb3489.png">
SigNoz 将日志、指标、链路追踪、告警、仪表盘、异常和面向 Agent 的工作流连接在一起,帮助团队更快地调试生产问题。
<img width="2068" alt="distributed_tracing_1" src="https://user-images.githubusercontent.com/83692067/226536462-939745b6-4f9d-45a6-8016-814837e7f7b4.png">
#### APM 概览
### 日志管理
监控服务延迟、错误率、吞吐量、Apdex、核心端点、数据库调用和外部调用。
<img width="2068" alt="logs_management" src="https://user-images.githubusercontent.com/83692067/226536482-b8a5c4af-b69c-43d5-969c-338bd5eaf1a5.png">
<p align="center">
<img alt="SigNoz APM 仪表盘展示延迟、吞吐量、Apdex 和关键操作" src="docs/readme-assets/monitor/apm.png" width="900">
</p>
### 基础设施监控
了解更多:[APM 文档](https://signoz.io/docs/instrumentation/overview/)
<img width="2068" alt="infrastructure_monitoring" src="https://user-images.githubusercontent.com/83692067/226536496-f38c4dbf-e03c-4158-8be0-32d4a61158c7.png">
#### 日志管理
### 异常监控
摄取、搜索、聚合日志,并通过可视化查询构建器将日志与链路追踪和指标关联起来。
![exceptions_light](https://user-images.githubusercontent.com/83692067/226637967-4188d024-3ac9-4799-be95-f5ea9c45436f.png)
<p align="center">
<img alt="SigNoz 日志浏览器,包含过滤器、频率图和日志行" src="docs/readme-assets/monitor/log-management.svg" width="900">
</p>
### 告警
了解更多:[日志管理文档](https://signoz.io/docs/logs-management/overview/)
<img width="2068" alt="alerts_management" src="https://user-images.githubusercontent.com/83692067/226536548-2c81e2e8-c12d-47e8-bad7-c6be79055def.png">
#### 指标和仪表盘
<br /><br />
使用 Query Builder、PromQL 或 ClickHouse SQL 为应用、基础设施和自定义指标构建仪表盘。
## 加入我们 Slack 社区
<p align="center">
<img alt="SigNoz 主机指标仪表盘,展示系统负载和网络图表" src="docs/readme-assets/monitor/metrics.png" width="900">
</p>
来 [Slack](https://signoz.io/slack) 和我们打招呼吧 👋
了解更多:[指标文档](https://signoz.io/docs/metrics-management/overview/)
<br /><br />
#### 基础设施监控
## 特性:
监控 Kubernetes 集群、Pod、节点、工作负载以及主机级 CPU、内存、磁盘、网络、日志和链路追踪。
- 为 metrics, traces and logs 制定统一的 UI。 无需切换 Prometheus 到 Jaeger 去查找问题,也无需使用想 Elastic 这样的日志工具分开你的 metrics 和 traces
<p align="center">
<img alt="SigNoz Kubernetes 基础设施仪表盘,展示 Pod 和节点指标" src="docs/readme-assets/monitor/infrastructure.png" width="900">
</p>
- 默认统计应用的 metrics 数据,像 RPS (每秒请求数) 50th/90th/99th 的分位数延迟数据,还有相关的错误率
了解更多:[基础设施监控文档](https://signoz.io/docs/infrastructure-monitoring/overview/)
- 找到应用中最慢的端点
#### LLM 和 AI 可观测性
- 查看准确的请求跟踪数据,找到下游服务的问题了,比如 DB 慢查询,或者调用第三方的支付网关等
追踪 LLM 应用、RAG 流水线、Prompt、工具调用、Token、延迟和成本并与应用和基础设施遥测数据放在一起分析。
- 通过 服务名、操作方式、延迟、错误、标签/注释 过滤 traces 数据
<p align="center">
<img alt="SigNoz LLM 可观测性仪表盘展示链路追踪、Token 使用、延迟和成本" src="docs/readme-assets/monitor/llm.png" width="900">
</p>
- 通过聚合 trace 数据而获得业务相关的 metrics。 比如你可以通过 `customer_type: gold` 或者 `deployment_version: v2` 或者 `external_call: paypal` 获取错误率和 P99 延迟数据
了解更多:[LLM 可观测性文档](https://signoz.io/docs/llm-observability/)
- 原生支持 OpenTelemetry 日志,高级日志查询,自动收集 k8s 相关日志
#### Agent 原生可观测性和 MCP
- 快如闪电的日志分析 ([Logs Perf. Benchmark](https://signoz.io/blog/logs-performance-benchmark/))
使用 SigNoz MCP server 将遥测数据带入编程 Agent或在 SigNoz 中使用 Noz基于生产上下文调查事故、优化告警并构建仪表盘。[Noz](https://signoz.io/docs/ai/noz/) 仅适用于 SigNoz Cloud。
- 可视化点到点的基础设施性能,提取有所有类型机器的 metrics 数据
<p align="center">
<img alt="SigNoz Noz 界面与基于 MCP 的 Agent 工作流" src="docs/readme-assets/monitor/agent-native.png" width="900">
</p>
- 轻易自定义告警查询
了解更多:[SigNoz MCP server 文档](https://signoz.io/docs/ai/signoz-mcp-server/) · [Agent skills 文档](https://signoz.io/docs/ai/agent-skills/#install-the-plugin)
<br /><br />
#### 分布式链路追踪
## 为什么使用 SigNoz?
通过火焰图、瀑布图、Span 事件、过滤器和 Trace 分析,跟踪请求在各个服务之间的流转。
作为开发者, 我们发现 SaaS 厂商对一些大家想要的小功能都是闭源的,这种行为真的让人有点恼火。 闭源厂商还会在月底给你一张没有明细的巨额账单。
<p align="center">
<img alt="SigNoz 分布式链路追踪视图,包含火焰图和瀑布图 Span" src="docs/readme-assets/monitor/distributed-tracing.png" width="900">
</p>
我们想做一个自托管并且可开源的工具,像 DataDog 和 NewRelic 那样, 为那些担心数据隐私和安全的公司提供第三方服务。
了解更多:[分布式链路追踪文档](https://signoz.io/docs/instrumentation/)
作为开源的项目,你完全可以自己掌控你的配置、样本和更新。你同样可以基于 SigNoz 拓展特定的业务模块。
#### Trace Funnels
### 支持的编程语言:
基于链路追踪创建漏斗,用于理解请求流中的掉点、失败转换和系统性工作流问题。
我们支持 [OpenTelemetry](https://opentelemetry.io)。作为一个观测你应用的库文件。所以任何 OpenTelemetry 支持的框架和语言,对于 SigNoz 也同样支持。 一些主要支持的语言如下:
<p align="center">
<img alt="SigNoz Trace Funnels展示请求流掉点和失败转换" src="docs/readme-assets/monitor/trace-funnels.png" width="900">
</p>
- Java
- Python
- NodeJS
- Go
- PHP
- .NET
- Ruby
- Elixir
- Rust
了解更多:[Trace Funnels 文档](https://signoz.io/docs/trace-funnels/overview/)
你可以在这里找到全部支持的语言列表 - https://opentelemetry.io/docs/
可以监控:[**异常**](https://signoz.io/docs/userguide/exceptions/)、[**告警**](https://signoz.io/docs/alerts/)、[**外部 API**](https://signoz.io/docs/external-api-monitoring/overview/),以及面向 OpenTelemetry、Prometheus、Kubernetes、云服务商、语言 SDK、应用框架、数据库和 LLM 工具的[**集成**](https://signoz.io/docs/integrations/integrations-list/)。
<br /><br />
### 为什么团队选择 SigNoz
## 让我们开始吧
1. **OpenTelemetry 原生**<br>
用开放标准完成一次接入,并保持对遥测数据的所有权。
2. **信号关联**<br>
在服务图表、链路追踪、日志、基础设施指标和异常之间切换时,不需要更换工具。
3. **单一列式数据库**<br>
为高基数、高吞吐量的可观测性工作负载而构建。
4. **可预测的定价**<br>
不按主机收费,不按用户席位收费,也不对自定义指标设置特殊价格。
5. **企业就绪**<br>
SOC 2 Type II 和 HIPAA 合规、RBAC、摄取控制、自定义保留期、支持、BYOC 和自托管。
### 使用 Docker 部署
### 快速开始
请一步步跟随 [这里](https://signoz.io/docs/install/docker/) 通过 docker 来安装。
#### 从 Cloud 开始
这个 [排障说明书](https://signoz.io/docs/install/troubleshooting/) 可以帮助你解决碰到的问题
创建一个托管的 SigNoz 工作区,无需运行可观测性基础设施,即可获得第一个仪表盘
<p>&nbsp </p>
[**免费开始使用 SigNoz Cloud**](https://signoz.io/teams/)
### 使用 Helm 在 Kubernetes 部署
#### 自托管 SigNoz
请一步步跟随 [这里](https://signoz.io/docs/deployment/helm_chart) 通过 helm 来安装
在你自己的基础设施中通过 Foundry、Docker、Kubernetes 或 Linux 运行 SigNoz。
<br /><br />
[**Foundry**](https://github.com/SigNoz/foundry) · [**Docker**](https://signoz.io/docs/install/docker/) · [**Kubernetes**](https://signoz.io/docs/install/kubernetes/) · [**Linux**](https://signoz.io/docs/install/linux/)
## 比较相似的工具
#### 发送数据
### SigNoz vs Prometheus
使用 OpenTelemetry、Prometheus、语言 SDK 和集成来接入应用与基础设施。
Prometheus 是一个针对 metrics 监控的强大工具。但是如果你想无缝的切换 metrics 和 traces 查询,你当前大概率需要在 Prometheus 和 Jaeger 之间切换。
[**接入文档**](https://signoz.io/docs/instrumentation/) · [**集成列表**](https://signoz.io/docs/integrations/integrations-list/)
我们的目标是提供一个客户观测 metrics 和 traces 整合的 UI。就像 SaaS 供应商 DataDog它提供很多 jaeger 缺失的功能,比如针对 traces 过滤功能和聚合功能。
### 与常见工具的对比
<p>&nbsp </p>
许多团队在从单一用途工具或价格不可预测的商业平台迁移时,会选择 SigNoz。
### SigNoz vs Jaeger
**Prometheus**<br>
如果你只需要指标Prometheus 很合适。SigNoz 将指标、日志、链路追踪、仪表盘和告警放在一起,让团队可以通过关联上下文进行调试。
Jaeger 仅仅是一个分布式追踪系统。 但是 SigNoz 可以提供 metrics, traces 和 logs 所有的观测。
**Jaeger**<br>
Jaeger 只做分布式链路追踪。SigNoz 增加了指标、日志、Trace 分析、仪表盘、告警、异常和 Trace 到日志的工作流。
而且, SigNoz 相较于 Jaeger 拥有更对的高级功能:
**Elastic**<br>
SigNoz 使用列式数据库来高效处理可观测性分析和高基数日志工作负载。在摄取阶段,相比 Elastic 可降低 50% 的资源需求。查看[详细评测](https://signoz.io/blog/logs-performance-benchmark/?utm_source=github-readme&utm_medium=logs-benchmark)。
- Jaegar UI 不能提供任何基于 traces 的 metrics 查询和过滤。
- Jaeger 不能针对过滤的 traces 做聚合。 比如, p99 延迟的请求有个标签是 customer_type='premium'。 而这些在 SigNoz 可以轻松做到。
<p>&nbsp </p>
### SigNoz vs Elastic
- SigNoz 的日志管理是基于 ClickHouse 实现的,可以使日志的聚合更加高效,因为它是基于 OLAP 的数据仓储。
- 与 Elastic 相比,可以节省 50% 的资源成本
我们已经公布了 Elastic 和 SigNoz 的性能对比。 请点击 [这里](https://signoz.io/blog/logs-performance-benchmark/?utm_source=github-readme&utm_medium=logs-benchmark)
<p>&nbsp </p>
### SigNoz vs Loki
- SigNoz 支持大容量高基数的聚合,但是 loki 是不支持的。
- SigNoz 支持索引的高基数查询,并且对索引没有数量限制,而 Loki 会在添加部分索引后到达最大上限。
- 相较于 SigNozLoki 在搜索大量数据下既困难又缓慢。
我们已经发布了基准测试对比 Loki 和 SigNoz 性能。请点击 [这里](https://signoz.io/blog/logs-performance-benchmark/?utm_source=github-readme&utm_medium=logs-benchmark)
<br /><br />
**Loki**<br>
在链接的评测中SigNoz 在测试设置中索引了所有键,而 Loki 在增加更多标签时遇到了 max stream 错误。查看[详细评测](https://signoz.io/blog/logs-performance-benchmark/?utm_source=github-readme&utm_medium=logs-benchmark)。
## 贡献
我们 ❤️ 你的贡献,无论大小。 请先阅读 [CONTRIBUTING.md](CONTRIBUTING.md)开始 SigNoz 做贡献。
无论贡献大小,我们都非常欢迎。请阅读 [CONTRIBUTING.md](CONTRIBUTING.md)开始 SigNoz 做贡献。
如果你不知道如何开始? 只需要在 [slack 社区](https://signoz.io/slack) 通过 `#contributing` 频道联系我们。
不确定如何开始?**可以在我们的 [Slack 社区](https://signoz.io/slack)通过 `#contributing` 联系我们。**
<br /><br />
## 文档
你可以通过 https://signoz.io/docs/ 找到相关文档。如果你需要阐述问题或者发现一些确实的事件, 通过标签为 `documentation` 提交 Github 问题。或者通过 slack 社区频道。
<br /><br />
## 社区
加入 [slack 社区](https://signoz.io/slack) 去了解更多关于分布式追踪、可观测性系统 。或者与 SigNoz 其他用户和贡献者交流。
如果你有任何想法、问题、或者任何反馈, 请通过 [Github Discussions](https://github.com/SigNoz/signoz/discussions) 分享。
不管怎么样,感谢这个项目的所有贡献者!
一如既往,感谢所有出色的贡献者!
<a href="https://github.com/signoz/signoz/graphs/contributors">
<img src="https://contrib.rocks/image?repo=signoz/signoz" />
<img alt="SigNoz 贡献者" src="https://contrib.rocks/image?repo=signoz/signoz" />
</a>

View File

@@ -177,11 +177,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
return nil, err
}
azureCloudProviderModule := implcloudprovider.NewAzureCloudProvider(defStore)
gcpCloudProviderModule := implcloudprovider.NewGCPCloudProvider(defStore)
cloudProvidersMap := map[cloudintegrationtypes.CloudProviderType]cloudintegration.CloudProviderModule{
cloudintegrationtypes.CloudProviderTypeAWS: awsCloudProviderModule,
cloudintegrationtypes.CloudProviderTypeAzure: azureCloudProviderModule,
cloudintegrationtypes.CloudProviderTypeGCP: gcpCloudProviderModule,
}
return implcloudintegration.NewModule(pkgcloudintegration.NewStore(sqlStore), dashboardModule, global, zeus, gateway, licensing, serviceAccount, cloudProvidersMap, config)

View File

@@ -71,6 +71,10 @@ web:
sentry:
# Whether to enable Sentry in web.
enabled: false
# The Sentry DSN.
dsn: ""
# The Sentry tunnel URL.
tunnel: ""
pylon:
# Whether to enable Pylon in web.
enabled: false
@@ -141,6 +145,10 @@ querier:
flux_interval: 5m
# The maximum number of concurrent queries for missing ranges.
max_concurrent_queries: 4
# When filtering logs by trace_id, clamp the query window to the trace time
# range with padding to include slightly delayed log exports. Logs only; set
# to 0 to disable.
log_trace_id_window_padding: 5m
##################### TelemetryStore #####################
telemetrystore:

View File

@@ -1024,8 +1024,6 @@ components:
$ref: '#/components/schemas/CloudintegrationtypesAWSAccountConfig'
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureAccountConfig'
gcp:
$ref: '#/components/schemas/CloudintegrationtypesGCPAccountConfig'
type: object
CloudintegrationtypesAgentReport:
nullable: true
@@ -1171,8 +1169,6 @@ components:
$ref: '#/components/schemas/CloudintegrationtypesAWSConnectionArtifact'
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureConnectionArtifact'
gcp:
$ref: '#/components/schemas/CloudintegrationtypesGCPConnectionArtifact'
type: object
CloudintegrationtypesCredentials:
properties:
@@ -1203,46 +1199,6 @@ components:
nullable: true
type: array
type: object
CloudintegrationtypesGCPAccountConfig:
properties:
deploymentProjectId:
type: string
deploymentRegion:
type: string
projectIds:
items:
type: string
type: array
required:
- deploymentProjectId
- deploymentRegion
- projectIds
type: object
CloudintegrationtypesGCPConnectionArtifact:
type: object
CloudintegrationtypesGCPIntegrationConfig:
type: object
CloudintegrationtypesGCPServiceConfig:
properties:
logs:
$ref: '#/components/schemas/CloudintegrationtypesGCPServiceLogsConfig'
metrics:
$ref: '#/components/schemas/CloudintegrationtypesGCPServiceMetricsConfig'
type: object
CloudintegrationtypesGCPServiceLogsConfig:
properties:
enabled:
type: boolean
required:
- enabled
type: object
CloudintegrationtypesGCPServiceMetricsConfig:
properties:
enabled:
type: boolean
required:
- enabled
type: object
CloudintegrationtypesGettableAccountWithConnectionArtifact:
properties:
connectionArtifact:
@@ -1375,8 +1331,6 @@ components:
$ref: '#/components/schemas/CloudintegrationtypesAWSPostableAccountConfig'
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureAccountConfig'
gcp:
$ref: '#/components/schemas/CloudintegrationtypesGCPAccountConfig'
type: object
CloudintegrationtypesPostableAgentCheckIn:
properties:
@@ -1401,8 +1355,6 @@ components:
$ref: '#/components/schemas/CloudintegrationtypesAWSIntegrationConfig'
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureIntegrationConfig'
gcp:
$ref: '#/components/schemas/CloudintegrationtypesGCPIntegrationConfig'
type: object
CloudintegrationtypesService:
properties:
@@ -1447,8 +1399,6 @@ components:
$ref: '#/components/schemas/CloudintegrationtypesAWSServiceConfig'
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureServiceConfig'
gcp:
$ref: '#/components/schemas/CloudintegrationtypesGCPServiceConfig'
type: object
CloudintegrationtypesServiceDashboard:
properties:
@@ -1491,7 +1441,6 @@ components:
- cosmosdb
- cassandradb
- redis
- cloudsql
type: string
CloudintegrationtypesServiceMetadata:
properties:
@@ -1553,8 +1502,6 @@ components:
$ref: '#/components/schemas/CloudintegrationtypesAWSAccountConfig'
azure:
$ref: '#/components/schemas/CloudintegrationtypesUpdatableAzureAccountConfig'
gcp:
$ref: '#/components/schemas/CloudintegrationtypesUpdatableGCPAccountConfig'
type: object
CloudintegrationtypesUpdatableAzureAccountConfig:
properties:
@@ -1565,22 +1512,6 @@ components:
required:
- resourceGroups
type: object
CloudintegrationtypesUpdatableGCPAccountConfig:
properties:
deploymentProjectId:
type: string
deploymentRegion:
type: string
projectIds:
items:
type: string
nullable: true
type: array
required:
- deploymentProjectId
- deploymentRegion
- projectIds
type: object
CloudintegrationtypesUpdatableService:
properties:
config:
@@ -2622,17 +2553,6 @@ components:
url:
type: string
type: object
DashboardTextVariableSpec:
properties:
constant:
type: boolean
display:
$ref: '#/components/schemas/VariableDisplay'
name:
type: string
value:
type: string
type: object
DashboardtypesAxes:
properties:
isLogScale:
@@ -2742,6 +2662,22 @@ components:
updatedBy:
type: string
type: object
DashboardtypesDashboardPanelRef:
properties:
dashboardId:
type: string
dashboardName:
type: string
panelId:
type: string
panelName:
type: string
required:
- dashboardId
- dashboardName
- panelId
- panelName
type: object
DashboardtypesDashboardSpec:
properties:
datasources:
@@ -2814,24 +2750,23 @@ components:
DashboardtypesDatasourcePlugin:
discriminator:
mapping:
signoz/Datasource: '#/components/schemas/DashboardtypesDatasourcePluginVariantStruct'
signoz/Datasource: '#/components/schemas/DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpec'
propertyName: kind
oneOf:
- $ref: '#/components/schemas/DashboardtypesDatasourcePluginVariantStruct'
- $ref: '#/components/schemas/DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpec'
type: object
DashboardtypesDatasourcePluginKind:
enum:
- signoz/Datasource
type: string
DashboardtypesDatasourcePluginVariantStruct:
DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpec:
properties:
kind:
enum:
- signoz/Datasource
type: string
spec:
nullable: true
type: object
$ref: '#/components/schemas/DashboardtypesSigNozDatasourceSpec'
required:
- kind
- spec
@@ -2997,9 +2932,15 @@ components:
type: string
nullable: true
type: object
mode:
$ref: '#/components/schemas/DashboardtypesLegendMode'
position:
$ref: '#/components/schemas/DashboardtypesLegendPosition'
type: object
DashboardtypesLegendMode:
enum:
- list
type: string
DashboardtypesLegendPosition:
enum:
- bottom
@@ -3046,19 +2987,30 @@ components:
customAllValue:
type: string
defaultValue:
$ref: '#/components/schemas/VariableDefaultValue'
$ref: '#/components/schemas/DashboardtypesVariableDefaultValue'
display:
$ref: '#/components/schemas/DashboardtypesDisplay'
name:
minLength: 1
type: string
plugin:
$ref: '#/components/schemas/DashboardtypesVariablePlugin'
sort:
nullable: true
type: string
$ref: '#/components/schemas/DashboardtypesListVariableSpecSort'
required:
- display
- name
type: object
DashboardtypesListVariableSpecSort:
enum:
- none
- alphabetical-asc
- alphabetical-desc
- numerical-asc
- numerical-desc
- alphabetical-ci-asc
- alphabetical-ci-desc
type: string
DashboardtypesListableDashboardForUserV2:
properties:
dashboards:
@@ -3356,7 +3308,6 @@ components:
queries:
items:
$ref: '#/components/schemas/DashboardtypesQuery'
nullable: true
type: array
required:
- display
@@ -3559,6 +3510,8 @@ components:
required:
- queryValue
type: object
DashboardtypesSigNozDatasourceSpec:
type: object
DashboardtypesSource:
enum:
- user
@@ -3568,8 +3521,13 @@ components:
DashboardtypesSpanGaps:
properties:
fillLessThan:
description: The maximum gap size to connect when fillOnlyBelow is true.
Gaps larger than this duration are left disconnected.
type: string
fillOnlyBelow:
description: Controls whether lines connect across null values. When false
(default), all gaps are connected. When true, only gaps smaller than fillLessThan
are connected.
type: boolean
type: object
DashboardtypesStorableDashboardData:
@@ -3617,6 +3575,22 @@ components:
- color
- columnName
type: object
DashboardtypesTextVariableSpec:
properties:
constant:
type: boolean
display:
$ref: '#/components/schemas/DashboardtypesDisplay'
name:
minLength: 1
type: string
value:
type: string
required:
- display
- value
- name
type: object
DashboardtypesThresholdFormat:
enum:
- text
@@ -3636,7 +3610,6 @@ components:
required:
- value
- color
- label
type: object
DashboardtypesTimePreference:
enum:
@@ -3721,24 +3694,18 @@ components:
discriminator:
mapping:
ListVariable: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec'
TextVariable: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec'
TextVariable: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpec'
propertyName: kind
oneOf:
- $ref: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec'
- $ref: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec'
- $ref: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpec'
type: object
DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec:
properties:
kind:
enum:
- TextVariable
DashboardtypesVariableDefaultValue:
oneOf:
- type: string
- items:
type: string
spec:
$ref: '#/components/schemas/DashboardTextVariableSpec'
required:
- kind
- spec
type: object
type: array
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec:
properties:
kind:
@@ -3751,6 +3718,18 @@ components:
- kind
- spec
type: object
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpec:
properties:
kind:
enum:
- TextVariable
type: string
spec:
$ref: '#/components/schemas/DashboardtypesTextVariableSpec'
required:
- kind
- spec
type: object
DashboardtypesVariablePlugin:
discriminator:
mapping:
@@ -3824,15 +3803,12 @@ components:
type:
type: string
url:
nullable: true
type: string
required:
- type
- code
- message
- url
- errors
- retry
- suggestions
type: object
ErrorsResponseerroradditional:
@@ -3848,7 +3824,6 @@ components:
- suggestions
type: object
ErrorsResponseretryjson:
nullable: true
properties:
delay:
$ref: '#/components/schemas/TimeDuration'
@@ -5659,6 +5634,16 @@ components:
- widgetId
- widgetName
type: object
MetricsexplorertypesMetricDashboardPanelsResponse:
properties:
dashboards:
items:
$ref: '#/components/schemas/DashboardtypesDashboardPanelRef'
nullable: true
type: array
required:
- dashboards
type: object
MetricsexplorertypesMetricDashboardsResponse:
properties:
dashboards:
@@ -8187,17 +8172,6 @@ components:
required:
- id
type: object
VariableDefaultValue:
type: object
VariableDisplay:
properties:
description:
type: string
hidden:
type: boolean
name:
type: string
type: object
ZeustypesGettableHost:
properties:
hosts:
@@ -22832,6 +22806,75 @@ paths:
summary: Put profile in Zeus for a deployment.
tags:
- zeus
/api/v3/metrics/dashboards:
get:
deprecated: false
description: This endpoint returns associated v2 dashboards for a specified
metric
operationId: GetMetricDashboardsV2
parameters:
- description: The name of the metric. May contain slashes (e.g. cloud-provider
metrics like run.googleapis.com/request_latencies).
in: query
name: metricName
required: true
schema:
description: The name of the metric. May contain slashes (e.g. cloud-provider
metrics like run.googleapis.com/request_latencies).
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/MetricsexplorertypesMetricDashboardPanelsResponse'
status:
type: string
required:
- status
- data
type: object
description: OK
"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
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Get metric dashboards (v2)
tags:
- metrics
/api/v3/traces/{traceID}/flamegraph:
post:
deprecated: false

Binary file not shown.

After

Width:  |  Height:  |  Size: 629 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 563 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 783 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 482 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

View File

@@ -1,36 +0,0 @@
package implcloudprovider
import (
"context"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
)
type gcpcloudprovider struct {
serviceDefinitions cloudintegrationtypes.ServiceDefinitionStore
}
func NewGCPCloudProvider(defStore cloudintegrationtypes.ServiceDefinitionStore) cloudintegration.CloudProviderModule {
return &gcpcloudprovider{
serviceDefinitions: defStore,
}
}
func (g *gcpcloudprovider) BuildIntegrationConfig(ctx context.Context, account *cloudintegrationtypes.Account, services []*cloudintegrationtypes.StorableCloudIntegrationService) (*cloudintegrationtypes.ProviderIntegrationConfig, error) {
// for manual flow we don't have any integration config to return, so returning empty config for now.
return &cloudintegrationtypes.ProviderIntegrationConfig{}, nil
}
func (g *gcpcloudprovider) GetConnectionArtifact(ctx context.Context, account *cloudintegrationtypes.Account, req *cloudintegrationtypes.GetConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
// for manual flow we don't have any connection artifact to return, so returning empty artifact for now.
return &cloudintegrationtypes.ConnectionArtifact{}, nil
}
func (g *gcpcloudprovider) GetServiceDefinition(ctx context.Context, serviceID cloudintegrationtypes.ServiceID) (*cloudintegrationtypes.ServiceDefinition, error) {
return g.serviceDefinitions.Get(ctx, cloudintegrationtypes.CloudProviderTypeGCP, serviceID)
}
func (g *gcpcloudprovider) ListServiceDefinitions(ctx context.Context) ([]*cloudintegrationtypes.ServiceDefinition, error) {
return g.serviceDefinitions.List(ctx, cloudintegrationtypes.CloudProviderTypeGCP)
}

View File

@@ -290,6 +290,10 @@ func (module *module) GetByMetricNames(ctx context.Context, orgID valuer.UUID, m
return module.pkgDashboardModule.GetByMetricNames(ctx, orgID, metricNames)
}
func (module *module) GetByMetricNamesV2(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string][]dashboardtypes.DashboardPanelRef, error) {
return module.pkgDashboardModule.GetByMetricNamesV2(ctx, orgID, metricNames)
}
func (module *module) List(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.Dashboard, error) {
return module.pkgDashboardModule.List(ctx, orgID)
}

View File

@@ -152,3 +152,7 @@ func (f *formatter) LowerExpression(expression string) []byte {
sql = append(sql, ')')
return sql
}
func (f *formatter) EscapeLikePattern(value string) string {
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(value)
}

View File

@@ -4,5 +4,7 @@ VITE_FRONTEND_API_ENDPOINT="http://localhost:8080"
VITE_PYLON_APP_ID="pylon-app-id"
VITE_APPCUES_APP_ID="appcess-app-id"
VITE_PYLON_IDENTITY_SECRET="pylon-identity-secret"
VITE_SENTRY_DSN="sentry-dsn"
VITE_TUNNEL_URL="sentry-tunnel-url"
CI="1"

View File

@@ -35,6 +35,7 @@ import { PreferenceContextProvider } from 'providers/preferences/context/Prefere
import { QueryBuilderProvider } from 'providers/QueryBuilder';
import { LicenseStatus } from 'types/api/licensesV3/getActive';
import { extractDomain } from 'utils/app';
import { getWebSettings } from 'utils/bootSettings';
import { Home } from './pageComponents';
import PrivateRoute from './Private';
@@ -333,24 +334,19 @@ function App(): JSX.Element {
}, [user, isFetchingUser, isCloudUser, enableAnalytics]);
useEffect(() => {
const webSettings = getWebSettings();
if (isCloudUser || isEnterpriseSelfHostedUser) {
if (
(window.signozBootData?.settings?.posthog.enabled ?? true) &&
process.env.POSTHOG_KEY
) {
if ((webSettings?.posthog.enabled ?? true) && process.env.POSTHOG_KEY) {
posthog.init(process.env.POSTHOG_KEY, {
api_host: 'https://us.i.posthog.com',
person_profiles: 'identified_only', // or 'always' to create profiles for anonymous users as well
});
}
if (
!isSentryInitialized &&
(window.signozBootData?.settings?.sentry.enabled ?? true)
) {
if (!isSentryInitialized && (webSettings?.sentry.enabled ?? true)) {
Sentry.init({
dsn: process.env.SENTRY_DSN,
tunnel: process.env.TUNNEL_URL,
dsn: webSettings?.sentry.dsn,
tunnel: webSettings?.sentry.tunnel,
environment: process.env.ENVIRONMENT,
release: process.env.VERSION,
integrations: [

View File

@@ -26,6 +26,8 @@ import type {
GetMetricAttributesParams,
GetMetricDashboards200,
GetMetricDashboardsParams,
GetMetricDashboardsV2200,
GetMetricDashboardsV2Params,
GetMetricHighlights200,
GetMetricHighlightsParams,
GetMetricMetadata200,
@@ -1787,3 +1789,100 @@ export const useGetMetricsTreemap = <
> => {
return useMutation(getGetMetricsTreemapMutationOptions(options));
};
/**
* This endpoint returns associated v2 dashboards for a specified metric
* @summary Get metric dashboards (v2)
*/
export const getMetricDashboardsV2 = (
params: GetMetricDashboardsV2Params,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricDashboardsV2200>({
url: `/api/v3/metrics/dashboards`,
method: 'GET',
params,
signal,
});
};
export const getGetMetricDashboardsV2QueryKey = (
params?: GetMetricDashboardsV2Params,
) => {
return [`/api/v3/metrics/dashboards`, ...(params ? [params] : [])] as const;
};
export const getGetMetricDashboardsV2QueryOptions = <
TData = Awaited<ReturnType<typeof getMetricDashboardsV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params: GetMetricDashboardsV2Params,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricDashboardsV2>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetMetricDashboardsV2QueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getMetricDashboardsV2>>
> = ({ signal }) => getMetricDashboardsV2(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getMetricDashboardsV2>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetMetricDashboardsV2QueryResult = NonNullable<
Awaited<ReturnType<typeof getMetricDashboardsV2>>
>;
export type GetMetricDashboardsV2QueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get metric dashboards (v2)
*/
export function useGetMetricDashboardsV2<
TData = Awaited<ReturnType<typeof getMetricDashboardsV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params: GetMetricDashboardsV2Params,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricDashboardsV2>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricDashboardsV2QueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get metric dashboards (v2)
*/
export const invalidateGetMetricDashboardsV2 = async (
queryClient: QueryClient,
params: GetMetricDashboardsV2Params,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricDashboardsV2QueryKey(params) },
options,
);
return queryClient;
};

View File

@@ -2185,14 +2185,9 @@ export interface ErrorsResponseerroradditionalDTO {
suggestions: string[];
}
export type ErrorsResponseretryjsonDTOAnyOf = {
export interface ErrorsResponseretryjsonDTO {
delay: TimeDurationDTO;
};
/**
* @nullable
*/
export type ErrorsResponseretryjsonDTO = ErrorsResponseretryjsonDTOAnyOf | null;
}
export interface ErrorsJSONDTO {
/**
@@ -2207,7 +2202,7 @@ export interface ErrorsJSONDTO {
* @type string
*/
message: string;
retry: ErrorsResponseretryjsonDTO | null;
retry?: ErrorsResponseretryjsonDTO;
/**
* @type array
*/
@@ -2217,9 +2212,9 @@ export interface ErrorsJSONDTO {
*/
type: string;
/**
* @type string,null
* @type string
*/
url: string | null;
url?: string;
}
export interface AuthtypesOrgSessionContextDTO {
@@ -2635,25 +2630,9 @@ export interface CloudintegrationtypesAzureAccountConfigDTO {
resourceGroups: string[];
}
export interface CloudintegrationtypesGCPAccountConfigDTO {
/**
* @type string
*/
deploymentProjectId: string;
/**
* @type string
*/
deploymentRegion: string;
/**
* @type array
*/
projectIds: string[];
}
export interface CloudintegrationtypesAccountConfigDTO {
aws?: CloudintegrationtypesAWSAccountConfigDTO;
azure?: CloudintegrationtypesAzureAccountConfigDTO;
gcp?: CloudintegrationtypesGCPAccountConfigDTO;
}
export interface CloudintegrationtypesAccountDTO {
@@ -2761,29 +2740,9 @@ export interface CloudintegrationtypesAzureServiceConfigDTO {
metrics: CloudintegrationtypesAzureServiceMetricsConfigDTO;
}
export interface CloudintegrationtypesGCPServiceLogsConfigDTO {
/**
* @type boolean
*/
enabled: boolean;
}
export interface CloudintegrationtypesGCPServiceMetricsConfigDTO {
/**
* @type boolean
*/
enabled: boolean;
}
export interface CloudintegrationtypesGCPServiceConfigDTO {
logs?: CloudintegrationtypesGCPServiceLogsConfigDTO;
metrics?: CloudintegrationtypesGCPServiceMetricsConfigDTO;
}
export interface CloudintegrationtypesServiceConfigDTO {
aws?: CloudintegrationtypesAWSServiceConfigDTO;
azure?: CloudintegrationtypesAzureServiceConfigDTO;
gcp?: CloudintegrationtypesGCPServiceConfigDTO;
}
export enum CloudintegrationtypesServiceIDDTO {
@@ -2814,7 +2773,6 @@ export enum CloudintegrationtypesServiceIDDTO {
cosmosdb = 'cosmosdb',
cassandradb = 'cassandradb',
redis = 'redis',
cloudsql = 'cloudsql',
}
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
/**
@@ -2879,14 +2837,9 @@ export interface CloudintegrationtypesCollectedMetricDTO {
unit?: string;
}
export interface CloudintegrationtypesGCPConnectionArtifactDTO {
[key: string]: unknown;
}
export interface CloudintegrationtypesConnectionArtifactDTO {
aws?: CloudintegrationtypesAWSConnectionArtifactDTO;
azure?: CloudintegrationtypesAzureConnectionArtifactDTO;
gcp?: CloudintegrationtypesGCPConnectionArtifactDTO;
}
export interface CloudintegrationtypesCredentialsDTO {
@@ -2919,10 +2872,6 @@ export interface CloudintegrationtypesDataCollectedDTO {
metrics?: CloudintegrationtypesCollectedMetricDTO[] | null;
}
export interface CloudintegrationtypesGCPIntegrationConfigDTO {
[key: string]: unknown;
}
export interface CloudintegrationtypesGettableAccountWithConnectionArtifactDTO {
connectionArtifact: CloudintegrationtypesConnectionArtifactDTO;
/**
@@ -3014,7 +2963,6 @@ export type CloudintegrationtypesIntegrationConfigDTO =
export interface CloudintegrationtypesProviderIntegrationConfigDTO {
aws?: CloudintegrationtypesAWSIntegrationConfigDTO;
azure?: CloudintegrationtypesAzureIntegrationConfigDTO;
gcp?: CloudintegrationtypesGCPIntegrationConfigDTO;
}
export interface CloudintegrationtypesGettableAgentCheckInDTO {
@@ -3077,7 +3025,6 @@ export interface CloudintegrationtypesGettableServicesMetadataDTO {
export interface CloudintegrationtypesPostableAccountConfigDTO {
aws?: CloudintegrationtypesAWSPostableAccountConfigDTO;
azure?: CloudintegrationtypesAzureAccountConfigDTO;
gcp?: CloudintegrationtypesGCPAccountConfigDTO;
}
export interface CloudintegrationtypesPostableAccountDTO {
@@ -3207,25 +3154,9 @@ export interface CloudintegrationtypesUpdatableAzureAccountConfigDTO {
resourceGroups: string[];
}
export interface CloudintegrationtypesUpdatableGCPAccountConfigDTO {
/**
* @type string
*/
deploymentProjectId: string;
/**
* @type string
*/
deploymentRegion: string;
/**
* @type array,null
*/
projectIds: string[] | null;
}
export interface CloudintegrationtypesUpdatableAccountConfigDTO {
aws?: CloudintegrationtypesAWSAccountConfigDTO;
azure?: CloudintegrationtypesUpdatableAzureAccountConfigDTO;
gcp?: CloudintegrationtypesUpdatableGCPAccountConfigDTO;
}
export interface CloudintegrationtypesUpdatableAccountDTO {
@@ -3335,37 +3266,6 @@ export interface DashboardLinkDTO {
url?: string;
}
export interface VariableDisplayDTO {
/**
* @type string
*/
description?: string;
/**
* @type boolean
*/
hidden?: boolean;
/**
* @type string
*/
name?: string;
}
export interface DashboardTextVariableSpecDTO {
/**
* @type boolean
*/
constant?: boolean;
display?: VariableDisplayDTO;
/**
* @type string
*/
name?: string;
/**
* @type string
*/
value?: string;
}
export interface DashboardtypesAxesDTO {
/**
* @type boolean
@@ -3397,6 +3297,9 @@ export interface DashboardtypesPanelFormattingDTO {
unit?: string;
}
export enum DashboardtypesLegendModeDTO {
list = 'list',
}
export enum DashboardtypesLegendPositionDTO {
bottom = 'bottom',
right = 'right',
@@ -3416,6 +3319,7 @@ export interface DashboardtypesLegendDTO {
* @type object,null
*/
customColors?: DashboardtypesLegendDTOCustomColors;
mode?: DashboardtypesLegendModeDTO;
position?: DashboardtypesLegendPositionDTO;
}
@@ -3427,7 +3331,7 @@ export interface DashboardtypesThresholdWithLabelDTO {
/**
* @type string
*/
label: string;
label?: string;
/**
* @type string
*/
@@ -4018,33 +3922,43 @@ export interface DashboardtypesDashboardDTO {
updatedBy?: string;
}
export enum DashboardtypesDatasourcePluginVariantStructDTOKind {
export interface DashboardtypesDashboardPanelRefDTO {
/**
* @type string
*/
dashboardId: string;
/**
* @type string
*/
dashboardName: string;
/**
* @type string
*/
panelId: string;
/**
* @type string
*/
panelName: string;
}
export enum DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpecDTOKind {
'signoz/Datasource' = 'signoz/Datasource',
}
export type DashboardtypesDatasourcePluginVariantStructDTOSpecAnyOf = {
export interface DashboardtypesSigNozDatasourceSpecDTO {
[key: string]: unknown;
};
}
/**
* @nullable
*/
export type DashboardtypesDatasourcePluginVariantStructDTOSpec =
DashboardtypesDatasourcePluginVariantStructDTOSpecAnyOf | null;
export interface DashboardtypesDatasourcePluginVariantStructDTO {
export interface DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpecDTO {
/**
* @enum signoz/Datasource
* @type string
*/
kind: DashboardtypesDatasourcePluginVariantStructDTOKind;
/**
* @type object,null
*/
spec: DashboardtypesDatasourcePluginVariantStructDTOSpec;
kind: DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpecDTOKind;
spec: DashboardtypesSigNozDatasourceSpecDTO;
}
export type DashboardtypesDatasourcePluginDTO =
DashboardtypesDatasourcePluginVariantStructDTO;
DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpecDTO;
export interface DashboardtypesDatasourceSpecDTO {
/**
@@ -4094,10 +4008,12 @@ export enum DashboardtypesLineStyleDTO {
export interface DashboardtypesSpanGapsDTO {
/**
* @type string
* @description The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected.
*/
fillLessThan?: string;
/**
* @type boolean
* @description Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected.
*/
fillOnlyBelow?: boolean;
}
@@ -4637,9 +4553,9 @@ export interface DashboardtypesPanelSpecDTO {
links?: DashboardLinkDTO[];
plugin: DashboardtypesPanelPluginDTO;
/**
* @type array,null
* @type array
*/
queries: DashboardtypesQueryDTO[] | null;
queries: DashboardtypesQueryDTO[];
}
export interface DashboardtypesPanelDTO {
@@ -4669,9 +4585,7 @@ export type DashboardtypesLayoutDTO =
export enum DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTOKind {
ListVariable = 'ListVariable',
}
export interface VariableDefaultValueDTO {
[key: string]: unknown;
}
export type DashboardtypesVariableDefaultValueDTO = string | string[];
export enum DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDynamicVariableSpecDTOKind {
'signoz/DynamicVariable' = 'signoz/DynamicVariable',
@@ -4729,6 +4643,15 @@ export type DashboardtypesVariablePluginDTO =
| DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesQueryVariableSpecDTO
| DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesCustomVariableSpecDTO;
export enum DashboardtypesListVariableSpecSortDTO {
none = 'none',
'alphabetical-asc' = 'alphabetical-asc',
'alphabetical-desc' = 'alphabetical-desc',
'numerical-asc' = 'numerical-asc',
'numerical-desc' = 'numerical-desc',
'alphabetical-ci-asc' = 'alphabetical-ci-asc',
'alphabetical-ci-desc' = 'alphabetical-ci-desc',
}
export interface DashboardtypesListVariableSpecDTO {
/**
* @type boolean
@@ -4746,17 +4669,15 @@ export interface DashboardtypesListVariableSpecDTO {
* @type string
*/
customAllValue?: string;
defaultValue?: VariableDefaultValueDTO;
defaultValue?: DashboardtypesVariableDefaultValueDTO;
display: DashboardtypesDisplayDTO;
/**
* @type string
* @minLength 1
*/
name?: string;
name: string;
plugin?: DashboardtypesVariablePluginDTO;
/**
* @type string,null
*/
sort?: string | null;
sort?: DashboardtypesListVariableSpecSortDTO;
}
export interface DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTO {
@@ -4768,21 +4689,38 @@ export interface DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDash
spec: DashboardtypesListVariableSpecDTO;
}
export enum DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTOKind {
export enum DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTOKind {
TextVariable = 'TextVariable',
}
export interface DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTO {
export interface DashboardtypesTextVariableSpecDTO {
/**
* @type boolean
*/
constant?: boolean;
display: DashboardtypesDisplayDTO;
/**
* @type string
* @minLength 1
*/
name: string;
/**
* @type string
*/
value: string;
}
export interface DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTO {
/**
* @enum TextVariable
* @type string
*/
kind: DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTOKind;
spec: DashboardTextVariableSpecDTO;
kind: DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTOKind;
spec: DashboardtypesTextVariableSpecDTO;
}
export type DashboardtypesVariableDTO =
| DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTO
| DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTO;
| DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTO;
export interface DashboardtypesDashboardSpecDTO {
/**
@@ -7238,6 +7176,13 @@ export interface MetricsexplorertypesMetricDashboardDTO {
widgetName: string;
}
export interface MetricsexplorertypesMetricDashboardPanelsResponseDTO {
/**
* @type array,null
*/
dashboards: DashboardtypesDashboardPanelRefDTO[] | null;
}
export interface MetricsexplorertypesMetricDashboardsResponseDTO {
/**
* @type array,null
@@ -11519,6 +11464,22 @@ export type GetHosts200 = {
status: string;
};
export type GetMetricDashboardsV2Params = {
/**
* @type string
* @description The name of the metric. May contain slashes (e.g. cloud-provider metrics like run.googleapis.com/request_latencies).
*/
metricName: string;
};
export type GetMetricDashboardsV2200 = {
data: MetricsexplorertypesMetricDashboardPanelsResponseDTO;
/**
* @type string
*/
status: string;
};
export type GetFlamegraphPathParameters = {
traceID: string;
};

View File

@@ -0,0 +1,163 @@
.inviteMembers {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
width: 100%;
}
.table {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
width: 100%;
}
.header {
display: flex;
gap: var(--spacing-8);
align-items: center;
width: 100%;
}
.headerCellEmail {
flex: 1 1 0;
min-width: 0;
}
.headerCellRole {
flex: 0 0 160px;
width: 160px;
}
.headerCellAction {
flex: 0 0 32px;
width: 32px;
}
.rows {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
width: 100%;
}
.row {
display: flex;
gap: var(--spacing-8);
align-items: flex-start;
width: 100%;
}
.cellEmail {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
flex: 1 1 0;
min-width: 0;
--input-background: var(
--invite-members-field-background,
var(--l2-background)
);
--input-hover-background: var(
--invite-members-field-background,
var(--l2-background)
);
--input-focus-background: var(
--invite-members-field-background,
var(--l2-background)
);
--input-disabled-background: var(
--invite-members-field-background,
var(--l2-background)
);
input::placeholder {
color: var(--l3-foreground);
}
}
.cellRole {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
flex: 0 0 160px;
width: 160px;
:global(.roles-single-select) {
width: 100%;
:global(.ant-select-selector) {
background-color: var(
--invite-members-field-background,
var(--l2-background)
) !important;
}
}
}
.cellAction {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
flex: 0 0 32px;
width: 32px;
height: 32px;
align-items: center;
justify-content: center;
}
.errorText {
color: var(--danger-background);
}
.addRow {
display: flex;
justify-content: flex-start;
margin-top: var(--spacing-2);
}
.callout {
animation: shake 300ms ease-out;
&[data-type='success'] {
animation: none;
}
}
.results {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.resultsList {
margin: var(--spacing-2) 0 0 var(--spacing-8);
padding: 0;
li {
margin-bottom: var(--spacing-1);
}
}
@keyframes shake {
0% {
transform: translateX(0);
}
25% {
transform: translateX(5px);
}
50% {
transform: translateX(-5px);
}
75% {
transform: translateX(5px);
}
100% {
transform: translateX(0);
}
}

View File

@@ -0,0 +1,221 @@
import { CircleAlert, Plus, Trash2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Callout } from '@signozhq/ui/callout';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import RolesSelect from 'components/RolesSelect/RolesSelect';
import styles from './InviteMembers.module.scss';
import { InviteMembersProps } from './types';
import { useInviteMembers } from './useInviteMembers';
function InviteMembers({
className,
initialRowCount = 3,
minRows = 1,
emailPlaceholder = 'e.g. john@signoz.io',
showHeader = true,
showAddButton = true,
onSuccess,
onPartialSuccess,
onAllFailed,
renderFooter,
}: InviteMembersProps): JSX.Element {
const {
rows,
emailValidity,
hasInvalidEmails,
hasInvalidRoles,
isSubmitting,
inviteResults,
addRow,
removeRow,
updateEmail,
updateRole,
reset,
submit,
touchedRows,
failedResults,
successResults,
} = useInviteMembers({
initialRowCount,
onSuccess,
onPartialSuccess,
onAllFailed,
});
const canSubmit = !isSubmitting && touchedRows.length > 0;
const canRemoveRow = rows.length > minRows;
const getValidationErrorMessage = (): string => {
if (hasInvalidEmails && hasInvalidRoles) {
return 'Please enter valid emails and select roles for team members';
}
if (hasInvalidEmails) {
return 'Please enter valid emails for team members';
}
return 'Please select roles for team members';
};
const hasValidationErrors = hasInvalidEmails || hasInvalidRoles;
const hasResults = inviteResults !== null;
const hasFailures = failedResults.length > 0;
const hasSuccesses = successResults.length > 0;
return (
<div className={cx(styles.inviteMembers, className)}>
<div className={styles.table}>
{showHeader && (
<div className={styles.header}>
<Typography.Text
size="base"
weight="semibold"
className={styles.headerCellEmail}
>
Email address
</Typography.Text>
<Typography.Text
size="base"
weight="semibold"
className={styles.headerCellRole}
>
Role
</Typography.Text>
<div className={styles.headerCellAction} />
</div>
)}
<div className={styles.rows}>
{rows.map((row) => (
<div key={row.id} className={styles.row}>
<div className={styles.cellEmail}>
<Input
type="email"
placeholder={emailPlaceholder}
value={row.email}
onChange={(e): void => updateEmail(row.id, e.target.value)}
name={`invite-email-${row.id}`}
autoComplete="email"
data-testid={`invite-email-${row.id}`}
/>
{emailValidity[row.id] === false && row.email.trim() !== '' && (
<Typography.Text size="small" className={styles.errorText}>
Invalid email address
</Typography.Text>
)}
</div>
<div className={styles.cellRole}>
<RolesSelect
mode="single"
value={row.roleId || undefined}
onChange={(roleId): void => updateRole(row.id, roleId)}
placeholder="Select role"
allowClear={false}
id={`invite-role-${row.id}`}
/>
</div>
<div className={styles.cellAction}>
{canRemoveRow && (
<Button
variant="ghost"
color="destructive"
onClick={(): void => removeRow(row.id)}
aria-label="Remove row"
data-testid={`invite-remove-${row.id}`}
>
<Trash2 size={12} />
</Button>
)}
</div>
</div>
))}
</div>
{showAddButton && (
<div className={styles.addRow}>
<Button
variant="dashed"
color="secondary"
prefix={<Plus size={12} />}
onClick={addRow}
data-testid="invite-add-row"
>
Add another
</Button>
</div>
)}
</div>
{hasValidationErrors && (
<Callout
type="error"
size="small"
showIcon
icon={<CircleAlert size={12} />}
className={styles.callout}
data-testid="invite-validation-error"
>
{getValidationErrorMessage()}
</Callout>
)}
{hasResults && hasFailures && (
<Callout
type="error"
size="small"
showIcon
icon={<CircleAlert size={12} />}
className={styles.callout}
data-testid="invite-api-error"
>
<div className={styles.results}>
{hasSuccesses && (
<Typography.Text size="small">
{successResults.length} invite(s) sent successfully.
</Typography.Text>
)}
<Typography.Text size="small">
{failedResults.length} invite(s) failed:
</Typography.Text>
<ul className={styles.resultsList}>
{failedResults.map((result) => (
<li key={result.email}>
<Typography.Text size="small">
{result.email}: {result.error}
</Typography.Text>
</li>
))}
</ul>
</div>
</Callout>
)}
{hasResults && !hasFailures && hasSuccesses && (
<Callout
type="success"
size="small"
showIcon
className={styles.callout}
data-testid="invite-success"
>
<Typography.Text size="small">
{successResults.length} invite(s) sent successfully!
</Typography.Text>
</Callout>
)}
{renderFooter?.({
submit,
reset,
canSubmit,
isSubmitting,
touchedCount: touchedRows.length,
})}
</div>
);
}
export default InviteMembers;

View File

@@ -0,0 +1,240 @@
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import InviteMembers from '../InviteMembers';
import {
CREATE_USER_ENDPOINT,
createErrorHandler,
createRolesHandler,
createSuccessHandler,
VALID_EMAIL,
} from './testUtils';
describe('InviteMembers - Edge Cases', () => {
beforeEach(() => {
server.use(createRolesHandler(), createSuccessHandler());
});
afterEach(() => {
server.resetHandlers();
});
describe('reset behavior', () => {
it('clears all rows when reset is called', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
initialRowCount={2}
renderFooter={({ reset }): JSX.Element => (
<button data-testid="reset-btn" onClick={reset}>
Reset
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.type(emailInputs[1], 'bob@signoz.io');
await user.click(screen.getByTestId('reset-btn'));
const resetInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
expect(resetInputs).toHaveLength(2);
resetInputs.forEach((input) => {
expect(input).toHaveValue('');
});
});
it('clears results on reset', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
renderFooter={({ submit, reset }): JSX.Element => (
<>
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
<button data-testid="reset-btn" onClick={reset}>
Reset
</button>
</>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByTestId('invite-success'),
).resolves.toBeInTheDocument();
await user.click(screen.getByTestId('reset-btn'));
expect(screen.queryByTestId('invite-success')).not.toBeInTheDocument();
});
});
describe('results cleared on edit', () => {
it('clears API error when email is edited', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(createErrorHandler('already_exists', 'User already exists'));
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByTestId('invite-api-error'),
).resolves.toBeInTheDocument();
await user.type(emailInputs[0], 'x');
await waitFor(() => {
expect(screen.queryByTestId('invite-api-error')).not.toBeInTheDocument();
});
});
it('clears API error when role is changed', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(createErrorHandler('already_exists', 'User already exists'));
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByTestId('invite-api-error'),
).resolves.toBeInTheDocument();
const viewerElements = screen.getAllByText('Viewer');
await user.click(viewerElements[0]);
const editorOptions = await screen.findAllByText('Editor');
await user.click(editorOptions[editorOptions.length - 1]);
await waitFor(() => {
expect(screen.queryByTestId('invite-api-error')).not.toBeInTheDocument();
});
});
});
describe('empty submission', () => {
it('does not submit when no rows are touched', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onSuccess = jest.fn();
render(
<InviteMembers
onSuccess={onSuccess}
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
await user.click(screen.getByTestId('submit-btn'));
expect(onSuccess).not.toHaveBeenCalled();
});
});
describe('submitting state', () => {
it('disables submit while submitting', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
renderFooter={({ submit, isSubmitting }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit} disabled={isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
const submitBtn = screen.getByTestId('submit-btn');
await user.click(submitBtn);
await waitFor(() => {
expect(screen.queryByText('Submitting...')).not.toBeInTheDocument();
});
});
});
describe('whitespace handling', () => {
it('trims email whitespace before submission', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const calls: { email: string }[] = [];
server.use(
rest.post(CREATE_USER_ENDPOINT, async (req, res, ctx) => {
const body = await req.json();
calls.push(body);
return res(ctx.status(201), ctx.json({ data: { id: 'user-123' } }));
}),
);
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], ' alice@signoz.io ');
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await waitFor(() => {
expect(calls).toHaveLength(1);
expect(calls[0].email).toBe('alice@signoz.io');
});
});
});
});

View File

@@ -0,0 +1,95 @@
import { server } from 'mocks-server/server';
import { render, screen } from 'tests/test-utils';
import InviteMembers from '../InviteMembers';
import { createRolesHandler, createSuccessHandler } from './testUtils';
describe('InviteMembers - Rendering', () => {
beforeEach(() => {
server.use(createRolesHandler(), createSuccessHandler());
});
afterEach(() => {
server.resetHandlers();
});
it('renders default initial row count of 3', () => {
render(<InviteMembers />);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
expect(emailInputs).toHaveLength(3);
});
it('renders custom initial row count', () => {
render(<InviteMembers initialRowCount={5} />);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
expect(emailInputs).toHaveLength(5);
});
it('renders header by default', () => {
render(<InviteMembers />);
expect(screen.getByText('Email address')).toBeInTheDocument();
expect(screen.getByText('Role')).toBeInTheDocument();
});
it('hides header when showHeader is false', () => {
render(<InviteMembers showHeader={false} />);
expect(screen.queryByText('Email address')).not.toBeInTheDocument();
expect(screen.queryByText('Role')).not.toBeInTheDocument();
});
it('renders add button by default', () => {
render(<InviteMembers />);
expect(
screen.getByRole('button', { name: /add another/i }),
).toBeInTheDocument();
});
it('hides add button when showAddButton is false', () => {
render(<InviteMembers showAddButton={false} />);
expect(
screen.queryByRole('button', { name: /add another/i }),
).not.toBeInTheDocument();
});
it('renders custom email placeholder', () => {
render(<InviteMembers emailPlaceholder="custom@placeholder.com" />);
const emailInputs = screen.getAllByPlaceholderText('custom@placeholder.com');
expect(emailInputs).toHaveLength(3);
});
it('applies custom className', () => {
const { container } = render(<InviteMembers className="custom-class" />);
expect(container.querySelector('.custom-class')).toBeInTheDocument();
});
it('renders footer via renderFooter prop', () => {
render(
<InviteMembers
renderFooter={({ canSubmit }): JSX.Element => (
<button data-testid="custom-footer" disabled={!canSubmit}>
Custom Submit
</button>
)}
/>,
);
expect(screen.getByTestId('custom-footer')).toBeInTheDocument();
expect(screen.getByTestId('custom-footer')).toBeDisabled();
});
it('renders role select for each row', () => {
render(<InviteMembers initialRowCount={2} />);
const roleSelects = screen.getAllByText('Select role');
expect(roleSelects).toHaveLength(2);
});
});

View File

@@ -0,0 +1,95 @@
import { server } from 'mocks-server/server';
import { render, screen, userEvent } from 'tests/test-utils';
import InviteMembers from '../InviteMembers';
import { createRolesHandler, createSuccessHandler } from './testUtils';
describe('InviteMembers - Row Management', () => {
beforeEach(() => {
server.use(createRolesHandler(), createSuccessHandler());
});
afterEach(() => {
server.resetHandlers();
});
it('adds a row when "Add another" is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<InviteMembers initialRowCount={2} />);
expect(screen.getAllByPlaceholderText('e.g. john@signoz.io')).toHaveLength(2);
await user.click(screen.getByRole('button', { name: /add another/i }));
expect(screen.getAllByPlaceholderText('e.g. john@signoz.io')).toHaveLength(3);
});
it('removes a row when trash button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<InviteMembers initialRowCount={3} />);
const removeButtons = screen.getAllByRole('button', { name: /remove row/i });
expect(removeButtons).toHaveLength(3);
await user.click(removeButtons[0]);
expect(screen.getAllByPlaceholderText('e.g. john@signoz.io')).toHaveLength(2);
});
it('respects minRows constraint when removing rows', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<InviteMembers initialRowCount={2} minRows={2} />);
expect(screen.queryAllByRole('button', { name: /remove row/i })).toHaveLength(
0,
);
await user.click(screen.getByRole('button', { name: /add another/i }));
const removeButtons = screen.getAllByRole('button', { name: /remove row/i });
expect(removeButtons).toHaveLength(3);
await user.click(removeButtons[0]);
expect(screen.getAllByPlaceholderText('e.g. john@signoz.io')).toHaveLength(2);
expect(screen.queryAllByRole('button', { name: /remove row/i })).toHaveLength(
0,
);
});
it('cannot remove rows below minRows=1 default', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<InviteMembers initialRowCount={2} />);
const removeButtons = screen.getAllByRole('button', { name: /remove row/i });
await user.click(removeButtons[0]);
expect(screen.getAllByPlaceholderText('e.g. john@signoz.io')).toHaveLength(1);
expect(screen.queryAllByRole('button', { name: /remove row/i })).toHaveLength(
0,
);
});
it('preserves data in other rows when removing one', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<InviteMembers initialRowCount={3} />);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], 'first@signoz.io');
await user.type(emailInputs[2], 'third@signoz.io');
const removeButtons = screen.getAllByRole('button', { name: /remove row/i });
await user.click(removeButtons[1]);
const remainingInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
expect(remainingInputs).toHaveLength(2);
expect(remainingInputs[0]).toHaveValue('first@signoz.io');
expect(remainingInputs[1]).toHaveValue('third@signoz.io');
});
});

View File

@@ -0,0 +1,362 @@
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import InviteMembers from '../InviteMembers';
import {
CREATE_USER_ENDPOINT,
createErrorHandler,
createRolesHandler,
createSuccessHandler,
createTrackingHandler,
VALID_EMAIL,
} from './testUtils';
describe('InviteMembers - Submission', () => {
beforeEach(() => {
server.use(createRolesHandler(), createSuccessHandler());
});
afterEach(() => {
server.resetHandlers();
});
describe('API calls', () => {
it('calls createUser API for each touched row', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const { handler, calls } = createTrackingHandler();
server.use(handler);
render(
<InviteMembers
initialRowCount={3}
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], 'alice@signoz.io');
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await waitFor(() => {
expect(calls).toHaveLength(1);
expect(calls[0]).toMatchObject({
email: 'alice@signoz.io',
userRoles: [{ id: 'role-viewer' }],
});
});
});
it('calls createUser API for multiple touched rows', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const { handler, calls } = createTrackingHandler();
server.use(handler);
render(
<InviteMembers
initialRowCount={3}
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], 'alice@signoz.io');
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.type(emailInputs[1], 'bob@signoz.io');
await user.click(screen.getAllByText('Select role')[0]);
const editorOptions = await screen.findAllByText('Editor');
await user.click(editorOptions[editorOptions.length - 1]);
await user.type(emailInputs[2], 'charlie@signoz.io');
await user.click(screen.getAllByText('Select role')[0]);
const adminOptions = await screen.findAllByText('Admin');
await user.click(adminOptions[adminOptions.length - 1]);
await user.click(screen.getByTestId('submit-btn'));
await waitFor(() => {
expect(calls).toHaveLength(3);
});
expect(calls[0]).toMatchObject({
email: 'alice@signoz.io',
userRoles: [{ id: 'role-viewer' }],
});
expect(calls[1]).toMatchObject({
email: 'bob@signoz.io',
userRoles: [{ id: 'role-editor' }],
});
expect(calls[2]).toMatchObject({
email: 'charlie@signoz.io',
userRoles: [{ id: 'role-admin' }],
});
});
});
describe('callbacks', () => {
it('calls onSuccess when all invites succeed', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onSuccess = jest.fn();
render(
<InviteMembers
onSuccess={onSuccess}
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await waitFor(() => {
expect(onSuccess).toHaveBeenCalled();
});
});
it('calls onAllFailed when all invites fail', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onAllFailed = jest.fn();
server.use(createErrorHandler('already_exists', 'User already exists'));
render(
<InviteMembers
onAllFailed={onAllFailed}
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await waitFor(() => {
expect(onAllFailed).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
email: VALID_EMAIL,
success: false,
}),
]),
);
});
});
it('calls onPartialSuccess when some invites succeed and some fail', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onPartialSuccess = jest.fn();
const onSuccess = jest.fn();
const onAllFailed = jest.fn();
const apiCalls: string[] = [];
let callCount = 0;
server.use(
createRolesHandler(),
rest.post(CREATE_USER_ENDPOINT, async (req, res, ctx) => {
const body = await req.json();
apiCalls.push(body.email);
callCount++;
if (callCount === 1) {
return res(ctx.status(201), ctx.json({ data: { id: 'user-123' } }));
}
return res(
ctx.status(409),
ctx.json({
error: {
code: 'already_exists',
message: 'User already exists',
},
}),
);
}),
);
render(
<InviteMembers
initialRowCount={2}
onSuccess={onSuccess}
onPartialSuccess={onPartialSuccess}
onAllFailed={onAllFailed}
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], 'alice@signoz.io');
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.type(emailInputs[1], 'bob@signoz.io');
await user.click(screen.getAllByText('Select role')[0]);
const editorOptions = await screen.findAllByText('Editor');
await user.click(editorOptions[editorOptions.length - 1]);
await user.click(screen.getByTestId('submit-btn'));
await waitFor(() => {
expect(apiCalls).toHaveLength(2);
});
expect(apiCalls).toStrictEqual(['alice@signoz.io', 'bob@signoz.io']);
expect(onSuccess).not.toHaveBeenCalled();
expect(onAllFailed).not.toHaveBeenCalled();
expect(onPartialSuccess).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ email: 'alice@signoz.io', success: true }),
expect.objectContaining({
email: 'bob@signoz.io',
success: false,
error: 'User already exists',
}),
]),
);
await expect(
screen.findByTestId('invite-api-error'),
).resolves.toBeInTheDocument();
expect(
screen.getByText('1 invite(s) sent successfully.'),
).toBeInTheDocument();
expect(screen.getByText('1 invite(s) failed:')).toBeInTheDocument();
expect(
screen.getByText('bob@signoz.io: User already exists'),
).toBeInTheDocument();
});
});
describe('result display', () => {
it('shows success callout when all invites succeed', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByTestId('invite-success'),
).resolves.toBeInTheDocument();
});
it('shows error callout with failed emails when API fails', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(createErrorHandler('already_exists', 'User already exists'));
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByTestId('invite-api-error'),
).resolves.toBeInTheDocument();
expect(screen.getByText(/1 invite\(s\) failed/)).toBeInTheDocument();
});
});
describe('footer props', () => {
it('provides correct canSubmit state', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
renderFooter={({ canSubmit }): JSX.Element => (
<button data-testid="submit-btn" disabled={!canSubmit}>
Submit
</button>
)}
/>,
);
expect(screen.getByTestId('submit-btn')).toBeDisabled();
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
expect(screen.getByTestId('submit-btn')).not.toBeDisabled();
});
it('provides touchedCount', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
initialRowCount={3}
renderFooter={({ touchedCount }): JSX.Element => (
<span data-testid="touched-count">{touchedCount}</span>
)}
/>,
);
expect(screen.getByTestId('touched-count')).toHaveTextContent('0');
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], 'a@b.com');
expect(screen.getByTestId('touched-count')).toHaveTextContent('1');
await user.type(emailInputs[1], 'c@d.com');
expect(screen.getByTestId('touched-count')).toHaveTextContent('2');
});
});
});

View File

@@ -0,0 +1,217 @@
import { server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import InviteMembers from '../InviteMembers';
import {
createRolesHandler,
createSuccessHandler,
INVALID_EMAIL,
VALID_EMAIL,
} from './testUtils';
describe('InviteMembers - Validation', () => {
beforeEach(() => {
server.use(createRolesHandler(), createSuccessHandler());
});
afterEach(() => {
server.resetHandlers();
});
describe('email validation', () => {
it('shows email validation error when email is invalid and role is selected', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], INVALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByText('Please enter valid emails for team members'),
).resolves.toBeInTheDocument();
});
it('shows inline error for invalid email field', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], INVALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByText('Invalid email address'),
).resolves.toBeInTheDocument();
});
it('clears validation error when email is corrected', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], INVALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByText('Please enter valid emails for team members'),
).resolves.toBeInTheDocument();
await user.clear(emailInputs[0]);
await user.type(emailInputs[0], VALID_EMAIL);
await waitFor(() => {
expect(
screen.queryByText('Please enter valid emails for team members'),
).not.toBeInTheDocument();
});
});
});
describe('role validation', () => {
it('shows role validation error when role is missing', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByText('Please select roles for team members'),
).resolves.toBeInTheDocument();
});
it('clears role validation error when role is selected', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByText('Please select roles for team members'),
).resolves.toBeInTheDocument();
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await waitFor(() => {
expect(
screen.queryByText('Please select roles for team members'),
).not.toBeInTheDocument();
});
});
});
describe('combined validation', () => {
it('shows combined error when both email and role are invalid', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], INVALID_EMAIL);
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByText(
'Please enter valid emails and select roles for team members',
),
).resolves.toBeInTheDocument();
});
});
describe('touched rows', () => {
it('only validates touched rows (rows with email or role)', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
initialRowCount={3}
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
expect(
screen.queryByTestId('invite-validation-error'),
).not.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,54 @@
import { RestHandler } from 'msw';
import { rest } from 'mocks-server/server';
export const CREATE_USER_ENDPOINT = '*/api/v2/users';
export const LIST_ROLES_ENDPOINT = '*/api/v1/roles';
export const MOCK_ROLES = [
{ id: 'role-admin', name: 'Admin', description: 'Admin role' },
{ id: 'role-editor', name: 'Editor', description: 'Editor role' },
{ id: 'role-viewer', name: 'Viewer', description: 'Viewer role' },
];
export const VALID_EMAIL = 'alice@signoz.io';
export const INVALID_EMAIL = 'not-an-email';
export function createSuccessHandler(): RestHandler {
return rest.post(CREATE_USER_ENDPOINT, (_, res, ctx) =>
res(ctx.status(201), ctx.json({ data: { id: 'user-123' } })),
);
}
export function createErrorHandler(
code: string,
message: string,
status = 409,
): RestHandler {
return rest.post(CREATE_USER_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(status),
ctx.json({
errors: [{ code, msg: message }],
}),
),
);
}
export function createRolesHandler(): RestHandler {
return rest.get(LIST_ROLES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: MOCK_ROLES })),
);
}
export function createTrackingHandler(): {
handler: RestHandler;
calls: unknown[];
} {
const calls: unknown[] = [];
const handler = rest.post(CREATE_USER_ENDPOINT, async (req, res, ctx) => {
const body = await req.json();
calls.push(body);
return res(ctx.status(201), ctx.json({ data: { id: 'user-123' } }));
});
return { handler, calls };
}

View File

@@ -0,0 +1,64 @@
import { ReactNode } from 'react';
export interface InviteMemberRow {
id: string;
email: string;
roleId: string;
}
export interface InviteResult {
email: string;
success: boolean;
error?: string;
}
export interface FooterRenderProps {
submit: () => Promise<InviteResult[]>;
reset: () => void;
canSubmit: boolean;
isSubmitting: boolean;
touchedCount: number;
}
export interface UseInviteMembersOptions {
initialRowCount?: number;
onSuccess?: () => void;
onPartialSuccess?: (results: InviteResult[]) => void;
onAllFailed?: (results: InviteResult[]) => void;
}
export interface UseInviteMembersReturn {
rows: InviteMemberRow[];
emailValidity: Record<string, boolean>;
hasInvalidEmails: boolean;
hasInvalidRoles: boolean;
isSubmitting: boolean;
inviteResults: InviteResult[] | null;
addRow: () => void;
removeRow: (id: string) => void;
updateEmail: (id: string, email: string) => void;
updateRole: (id: string, roleId: string | undefined) => void;
reset: () => void;
submit: () => Promise<InviteResult[]>;
touchedRows: InviteMemberRow[];
failedResults: InviteResult[];
successResults: InviteResult[];
canSubmit: boolean;
}
export interface InviteMembersProps {
className?: string;
initialRowCount?: number;
minRows?: number;
emailPlaceholder?: string;
showHeader?: boolean;
showAddButton?: boolean;
onSuccess?: () => void;
onPartialSuccess?: (results: InviteResult[]) => void;
onAllFailed?: (results: InviteResult[]) => void;
renderFooter?: (props: FooterRenderProps) => ReactNode;
}

View File

@@ -0,0 +1,245 @@
import { useCallback, useMemo, useState } from 'react';
import { AxiosError } from 'axios';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { createUser } from 'api/generated/services/users';
import { cloneDeep, debounce } from 'lodash-es';
import { EMAIL_REGEX } from 'utils/app';
import { getBaseUrl } from 'utils/basePath';
import { v4 as uuid } from 'uuid';
import {
InviteMemberRow,
InviteResult,
UseInviteMembersOptions,
UseInviteMembersReturn,
} from './types';
const createEmptyRow = (): InviteMemberRow => ({
id: uuid(),
email: '',
roleId: '',
});
const isRowTouched = (row: InviteMemberRow): boolean =>
row.email.trim() !== '' || row.roleId !== '';
export function useInviteMembers(
options: UseInviteMembersOptions = {},
): UseInviteMembersReturn {
const {
initialRowCount = 3,
onSuccess,
onPartialSuccess,
onAllFailed,
} = options;
const [rows, setRows] = useState<InviteMemberRow[]>(() =>
Array.from({ length: initialRowCount }, () => createEmptyRow()),
);
const [emailValidity, setEmailValidity] = useState<Record<string, boolean>>(
{},
);
const [hasInvalidEmails, setHasInvalidEmails] = useState(false);
const [hasInvalidRoles, setHasInvalidRoles] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [inviteResults, setInviteResults] = useState<InviteResult[] | null>(
null,
);
const touchedRows = useMemo(() => rows.filter(isRowTouched), [rows]);
const failedResults = useMemo(
() => inviteResults?.filter((r) => !r.success) ?? [],
[inviteResults],
);
const successResults = useMemo(
() => inviteResults?.filter((r) => r.success) ?? [],
[inviteResults],
);
const debouncedValidateEmail = useMemo(
() =>
debounce((email: string, rowId: string) => {
const isValid = EMAIL_REGEX.test(email);
setEmailValidity((prev) => ({ ...prev, [rowId]: isValid }));
}, 500),
[],
);
const validateAllRows = useCallback((): boolean => {
let isValid = true;
let hasEmailErrors = false;
let hasRoleErrors = false;
const updatedEmailValidity: Record<string, boolean> = {};
const touched = rows.filter(isRowTouched);
touched.forEach((row) => {
const emailValid = EMAIL_REGEX.test(row.email);
const roleValid = row.roleId !== '';
if (!emailValid || !row.email) {
isValid = false;
hasEmailErrors = true;
}
if (!roleValid) {
isValid = false;
hasRoleErrors = true;
}
updatedEmailValidity[row.id] = emailValid;
});
setEmailValidity(updatedEmailValidity);
setHasInvalidEmails(hasEmailErrors);
setHasInvalidRoles(hasRoleErrors);
return isValid;
}, [rows]);
const addRow = useCallback((): void => {
setRows((prev) => [...prev, createEmptyRow()]);
}, []);
const removeRow = useCallback((id: string): void => {
setRows((prev) => prev.filter((r) => r.id !== id));
setEmailValidity((prev) => {
const updated = { ...prev };
delete updated[id];
return updated;
});
}, []);
const updateEmail = useCallback(
(id: string, email: string): void => {
setRows((prev) => {
const updated = cloneDeep(prev);
const row = updated.find((r) => r.id === id);
if (row) {
row.email = email;
}
return updated;
});
if (hasInvalidEmails) {
setHasInvalidEmails(false);
}
if (emailValidity[id] === false) {
setEmailValidity((prev) => ({ ...prev, [id]: true }));
}
if (inviteResults) {
setInviteResults(null);
}
debouncedValidateEmail(email, id);
},
[hasInvalidEmails, emailValidity, inviteResults, debouncedValidateEmail],
);
const updateRole = useCallback(
(id: string, roleId: string | undefined): void => {
setRows((prev) => {
const updated = cloneDeep(prev);
const row = updated.find((r) => r.id === id);
if (row) {
row.roleId = roleId ?? '';
}
return updated;
});
if (hasInvalidRoles) {
setHasInvalidRoles(false);
}
if (inviteResults) {
setInviteResults(null);
}
},
[hasInvalidRoles, inviteResults],
);
const reset = useCallback((): void => {
setRows(Array.from({ length: initialRowCount }, () => createEmptyRow()));
setEmailValidity({});
setHasInvalidEmails(false);
setHasInvalidRoles(false);
setInviteResults(null);
}, [initialRowCount]);
const submit = useCallback(async (): Promise<InviteResult[]> => {
if (!validateAllRows()) {
return [];
}
const touched = rows.filter(isRowTouched);
if (touched.length === 0) {
return [];
}
setIsSubmitting(true);
setInviteResults(null);
const results: InviteResult[] = [];
for (const row of touched) {
try {
await createUser({
email: row.email.trim(),
frontendBaseUrl: getBaseUrl(),
userRoles: [{ id: row.roleId }],
});
results.push({ email: row.email, success: true });
} catch (err) {
const apiErr = convertToApiError(err as AxiosError<RenderErrorResponseDTO>);
results.push({
email: row.email,
success: false,
error: apiErr?.getErrorMessage() ?? 'Unknown error',
});
}
}
setInviteResults(results);
setIsSubmitting(false);
const failures = results.filter((r) => !r.success);
const successes = results.filter((r) => r.success);
if (failures.length === 0) {
onSuccess?.();
} else if (successes.length > 0) {
onPartialSuccess?.(results);
} else {
onAllFailed?.(results);
}
return results;
}, [validateAllRows, rows, onSuccess, onPartialSuccess, onAllFailed]);
const canSubmit = useMemo(
() => !isSubmitting && touchedRows.length > 0,
[isSubmitting, touchedRows.length],
);
return {
rows,
emailValidity,
hasInvalidEmails,
hasInvalidRoles,
isSubmitting,
inviteResults,
addRow,
removeRow,
updateEmail,
updateRole,
reset,
submit,
touchedRows,
failedResults,
successResults,
canSubmit,
};
}

View File

@@ -48,6 +48,48 @@ describe('getAutoContexts', () => {
]);
});
it('includes the query in alert edit context', () => {
const ruleId = 'rule-edit';
const query = { queryType: 'builder', builder: { queryData: [] } };
const compositeQuery = encodeURIComponent(JSON.stringify(query));
const search = `?${QueryParams.ruleId}=${ruleId}&${QueryParams.compositeQuery}=${compositeQuery}`;
const contexts = getAutoContexts(ROUTES.EDIT_ALERTS, search);
expect(contexts).toStrictEqual([
{
source: 'auto',
type: 'alert',
resourceId: ruleId,
metadata: {
page: 'alert_edit',
ruleId,
query,
},
},
]);
});
it('includes the query in alert new context (no ruleId)', () => {
const query = { queryType: 'builder', builder: { queryData: [] } };
const compositeQuery = encodeURIComponent(JSON.stringify(query));
const search = `?${QueryParams.compositeQuery}=${compositeQuery}`;
const contexts = getAutoContexts(ROUTES.ALERTS_NEW, search);
expect(contexts).toStrictEqual([
{
source: 'auto',
type: 'alert',
resourceId: null,
metadata: {
page: 'alert_new',
query,
},
},
]);
});
it('returns triggered alerts context on alert history without ruleId', () => {
const contexts = getAutoContexts(ROUTES.ALERT_HISTORY, '');

View File

@@ -124,7 +124,9 @@ export function getAutoContexts(
}
}
// Alert edit — `/alerts/edit?ruleId=…`.
// Alert edit — `/alerts/edit?ruleId=…`. The form syncs its query-builder
// state to the URL (`useShareBuilderUrl`), so shared metadata carries the
// alert's query + time range, mirroring the dashboard panel editor.
if (matchPath(pathname, { path: ROUTES.EDIT_ALERTS, exact: true })) {
const ruleId = params.get(QueryParams.ruleId);
if (ruleId) {
@@ -133,19 +135,21 @@ export function getAutoContexts(
source: 'auto',
type: 'alert',
resourceId: ruleId,
metadata: { page: 'alert_edit', ruleId },
metadata: { page: 'alert_edit', ruleId, ...sharedMetadata },
},
];
}
}
// Alert new — `/alerts/new`. No rule id yet (draft), but the query-builder
// state is on the URL, so shared metadata carries the in-progress query.
if (matchPath(pathname, { path: ROUTES.ALERTS_NEW, exact: true })) {
return [
{
source: 'auto',
type: 'alert',
resourceId: null,
metadata: { page: 'alert_new' },
metadata: { page: 'alert_new', ...sharedMetadata },
},
];
}

View File

@@ -26,7 +26,12 @@ jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({
describe('Should check if the edit alert channel is properly displayed', () => {
beforeEach(() => {
render(<EditAlertChannels initialValue={editAlertChannelInitialValue} />);
render(
<EditAlertChannels
channelId="3"
initialValue={editAlertChannelInitialValue}
/>,
);
});
afterEach(() => {
jest.clearAllMocks();

View File

@@ -0,0 +1,81 @@
import EditAlertChannels from 'container/EditAlertChannels';
import { editAlertChannelInitialValue } from 'mocks-server/__mockdata__/alerts';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
jest.mock('hooks/useNotifications', () => ({
__esModule: true,
useNotifications: jest.fn(() => ({
notifications: { success: jest.fn(), error: jest.fn() },
})),
}));
jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({
MarkdownRenderer: jest.fn(() => <div>Mocked MarkdownRenderer</div>),
}));
interface EditRequest {
id: string;
body: { name: string; slack_configs: { send_resolved: boolean }[] };
}
// Captures the PUT /channels/:id request the edit form fires, so assertions can
// run against the real HTTP payload instead of a hand-mocked api client.
function mockEditChannel(): { calls: EditRequest[] } {
const result: { calls: EditRequest[] } = { calls: [] };
server.use(
rest.put('http://localhost/api/v1/channels/:id', async (req, res, ctx) => {
result.calls.push({
id: req.params.id as string,
body: await req.json(),
});
return res(
ctx.status(200),
ctx.json({ status: 'success', data: 'channel updated' }),
);
}),
);
return result;
}
describe('EditAlertChannels save', () => {
afterEach(() => jest.clearAllMocks());
it('sends the channelId in the edit request (regression: empty id)', async () => {
const edit = mockEditChannel();
render(
<EditAlertChannels
channelId="3"
initialValue={editAlertChannelInitialValue}
/>,
);
const user = userEvent.setup();
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() => expect(edit.calls).toHaveLength(1));
expect(edit.calls[0].id).toBe('3');
});
it('persists send_resolved toggle in the edit request', async () => {
const edit = mockEditChannel();
render(
<EditAlertChannels
channelId="3"
initialValue={editAlertChannelInitialValue}
/>,
);
const user = userEvent.setup();
const sendResolved = screen.getByTestId('field-send-resolved-checkbox');
expect(sendResolved).toBeChecked();
await user.click(sendResolved);
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() => expect(edit.calls).toHaveLength(1));
expect(edit.calls[0].id).toBe('3');
expect(edit.calls[0].body.slack_configs[0].send_resolved).toBe(false);
});
});

View File

@@ -32,6 +32,7 @@ import APIError from 'types/api/error';
function EditAlertChannels({
initialValue,
channelId: id,
}: EditAlertChannelsProps): JSX.Element {
// init namespace for translations
const { t } = useTranslation('channels');
@@ -53,11 +54,6 @@ function EditAlertChannels({
const [testingState, setTestingState] = useState<boolean>(false);
const { notifications } = useNotifications();
// Extract channelId from URL pathname since useParams doesn't work in nested routing
const { pathname } = window.location;
const channelIdMatch = pathname.match(/\/settings\/channels\/edit\/([^/]+)/);
const id = channelIdMatch ? channelIdMatch[1] : '';
const [type, setType] = useState<ChannelType>(
initialValue?.type ? (initialValue.type as ChannelType) : ChannelType.Slack,
);
@@ -520,6 +516,7 @@ interface EditAlertChannelsProps {
initialValue: {
[x: string]: unknown;
};
channelId: string;
}
export default EditAlertChannels;

View File

@@ -136,6 +136,7 @@ function FormAlertChannels({
<Form.Item>
<Button
data-testid="save-channel-button"
disabled={savingState}
loading={savingState}
type="primary"
@@ -144,6 +145,7 @@ function FormAlertChannels({
{t('button_save_channel')}
</Button>
<Button
data-testid="test-channel-button"
disabled={testingState}
loading={testingState}
onClick={(): void => onTestHandler(type)}
@@ -151,6 +153,7 @@ function FormAlertChannels({
{t('button_test_channel')}
</Button>
<Button
data-testid="return-button"
onClick={(): void => {
history.replace(ROUTES.ALL_CHANNELS);
}}

View File

@@ -13,6 +13,9 @@
}
.pageHeaderTitle {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
.title {
margin: 0;
font-size: var(--font-size-xl);

View File

@@ -1,3 +1,7 @@
import { Tabs } from '@signozhq/ui/tabs';
import { Typography } from '@signozhq/ui/typography';
import ModelCostTabPanel from './ModelCostTabPanel';
import styles from './LLMObservabilityModelPricing.module.scss';
function LLMObservabilityModelPricing(): JSX.Element {
@@ -8,12 +12,34 @@ function LLMObservabilityModelPricing(): JSX.Element {
>
<header className={styles.pageHeader}>
<div className={styles.pageHeaderTitle}>
<h1 className={styles.title}>Configuration</h1>
<p className={styles.subtitle}>
<Typography.Text as="h1" size="large" weight="semibold">
Configuration
</Typography.Text>
<Typography.Text color="muted">
Model pricing and cost estimation settings
</p>
</Typography.Text>
</div>
</header>
<Tabs
// Model costs is the only enabled tab for now, so default to it. When
// the unpriced-models tab lands, this can become a URL-backed param.
defaultValue="model-costs"
items={[
{
key: 'model-costs',
label: 'Model costs',
children: <ModelCostTabPanel />,
},
{
// Unpriced-models tab lands in a later PR.
key: 'unpriced-models',
label: 'Unpriced models',
disabled: true,
children: null,
},
]}
/>
</div>
);
}

View File

@@ -0,0 +1,7 @@
.pageError {
padding: var(--spacing-6) var(--spacing-8);
border-radius: var(--radius-2);
background: color-mix(in srgb, var(--bg-cherry-400) 8%, transparent);
color: var(--text-cherry-400);
font-size: var(--periscope-font-size-base);
}

View File

@@ -0,0 +1,61 @@
import { useMemo } from 'react';
import { useListLLMPricingRules } from 'api/generated/services/llmpricingrules';
import { type ListLLMPricingRulesParams } from 'api/generated/services/sigNoz.schemas';
import { useTableParams } from 'components/TanStackTableView';
import { Typography } from '@signozhq/ui/typography';
import { LIMIT_KEY, PAGE_KEY, PAGE_SIZE } from '../constants';
import styles from './ModelCostTabPanel.module.scss';
import ModelCostsTable from './components/ModelCostsTable';
import { type LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
function ModelCostTabPanel(): JSX.Element {
const { page, limit } = useTableParams(
{ page: PAGE_KEY, limit: LIMIT_KEY },
{ page: 1, limit: PAGE_SIZE },
);
// Search + source filters are intentionally omitted for now — the list API
// doesn't honour them yet. They'll be reintroduced here once it does.
const listParams: ListLLMPricingRulesParams = {
offset: (page - 1) * limit,
limit,
};
const { data, isLoading, isError } = useListLLMPricingRules(listParams);
const rules: LlmpricingruletypesLLMPricingRuleDTO[] = useMemo(
() => data?.data?.items || [],
[data],
);
const total = data?.data?.total ?? 0;
return (
<>
{isError && (
<div className={styles.pageError} role="alert">
Failed to load pricing rules. Please try again.
</div>
)}
{/* Read-only listing. Edit/Add wiring + the drawer land in the next PR. */}
<ModelCostsTable
rules={rules}
isLoading={isLoading}
total={total}
selectedRuleId={null}
canManage={false}
onEdit={(): void => undefined}
onDelete={(): void => undefined}
/>
<footer>
<Typography.Text color="muted" size="small">
All prices per 1M tokens (USD)
</Typography.Text>
</footer>
</>
);
}
export default ModelCostTabPanel;

View File

@@ -0,0 +1,8 @@
.actionButton {
opacity: 0.7;
transition: opacity 0.15s ease;
&:hover {
opacity: 1;
}
}

View File

@@ -0,0 +1,61 @@
import { useMemo } from 'react';
import { Ellipsis } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import { type LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
import styles from './ModelCostActionsMenu.module.scss';
interface ModelCostActionsMenuProps {
rule: LlmpricingruletypesLLMPricingRuleDTO;
canManage: boolean;
onEdit: (rule: LlmpricingruletypesLLMPricingRuleDTO) => void;
onDelete: (rule: LlmpricingruletypesLLMPricingRuleDTO) => void;
}
// Per-row kebab menu for the model-costs table. Only manage users get actions
// (Edit + Delete); view-only users have nothing to act on, so the cell stays
// empty rather than showing a single-item menu.
function ModelCostActionsMenu({
rule,
canManage,
onEdit,
onDelete,
}: ModelCostActionsMenuProps): JSX.Element | null {
const menuItems = useMemo<MenuItem[]>(
() => [
{
key: 'edit',
label: 'Edit',
onClick: (): void => onEdit(rule),
},
{
key: 'delete',
label: 'Delete',
danger: true,
onClick: (): void => onDelete(rule),
},
],
[onEdit, onDelete, rule],
);
if (!canManage) {
return null;
}
return (
<DropdownMenuSimple menu={{ items: menuItems }} align="end">
<Button
variant="ghost"
color="secondary"
size="icon"
className={styles.actionButton}
testId={`model-cost-actions-${rule.id}`}
>
<Ellipsis size={16} />
</Button>
</DropdownMenuSimple>
);
}
export default ModelCostActionsMenu;

View File

@@ -0,0 +1,20 @@
.modelCostsTable {
margin-top: var(--spacing-8);
--tanstack-table-row-height: 48px;
height: calc(100vh - 250px);
overflow-y: auto;
:global(table) tbody tr {
cursor: default;
}
}
.modelCostsEmpty {
display: flex;
align-items: center;
justify-content: center;
margin-top: var(--spacing-8);
min-height: 400px;
color: var(--text-vanilla-400);
font-size: var(--periscope-font-size-base);
}

View File

@@ -0,0 +1,73 @@
import { useMemo } from 'react';
import TanStackTable from 'components/TanStackTableView';
import {
LIMIT_KEY,
PAGE_KEY,
PAGE_SIZE,
SKELETON_ROW_COUNT,
} from '../../../constants';
import styles from './ModelCostsTable.module.scss';
import { getModelCostsColumns } from './TableConfig';
import { type LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
interface ModelCostsTableProps {
rules: LlmpricingruletypesLLMPricingRuleDTO[];
isLoading: boolean;
total: number;
selectedRuleId: string | null;
canManage: boolean;
onEdit: (rule: LlmpricingruletypesLLMPricingRuleDTO) => void;
onDelete: (rule: LlmpricingruletypesLLMPricingRuleDTO) => void;
}
// The table owns its own pagination URL state (page/limit) via enableQueryParams;
// ModelCostsTab reads the same keys to build the list request. Virtual scroll is
// disabled: a plain table renders fine at our page sizes (up to 100 rows) and the
// fixed-height scroll viewport (.modelCostsTable) keeps large pages scrolling
// inside the table.
function ModelCostsTable({
rules,
isLoading,
total,
selectedRuleId,
canManage,
onEdit,
onDelete,
}: ModelCostsTableProps): JSX.Element {
const columns = useMemo(
() => getModelCostsColumns({ canManage, onEdit, onDelete }),
[canManage, onEdit, onDelete],
);
if (!isLoading && rules.length === 0) {
return (
<div className={styles.modelCostsEmpty} data-testid="model-costs-empty">
No model costs yet.
</div>
);
}
return (
<TanStackTable<LlmpricingruletypesLLMPricingRuleDTO>
className={styles.modelCostsTable}
data={rules}
columns={columns}
isLoading={isLoading}
skeletonRowCount={SKELETON_ROW_COUNT}
getRowKey={(row): string => row.id}
isRowActive={(row): boolean => row.id === selectedRuleId}
disableVirtualScroll
testId="model-costs-table"
enableQueryParams={{ page: PAGE_KEY, limit: LIMIT_KEY }}
pagination={{
total,
defaultLimit: PAGE_SIZE,
showTotalCount: true,
totalCountLabel: 'models',
}}
/>
);
}
export default ModelCostsTable;

View File

@@ -0,0 +1 @@
export { getModelCostsColumns } from './table.config';

View File

@@ -0,0 +1,161 @@
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import type { TableColumnDef } from 'components/TanStackTableView';
import { startCase } from 'lodash-es';
import styles from './tableConfig.module.scss';
import ModelCostActionsMenu from '../ModelCostActionsMenu';
import { type LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
import {
formatPricePerMillion,
getCanonicalId,
getExtraBuckets,
getRelativeLastSeen,
getSourceLabel,
} from '../../../../utils';
interface ColumnsConfig {
canManage: boolean;
onEdit: (rule: LlmpricingruletypesLLMPricingRuleDTO) => void;
onDelete: (rule: LlmpricingruletypesLLMPricingRuleDTO) => void;
}
// Column definitions for the model-costs TanStackTable. Sorting is intentionally
// off across the board — the list API only accepts offset/limit, so there's no
// server-side ordering to back a sortable header yet.
export function getModelCostsColumns({
canManage,
onEdit,
onDelete,
}: ColumnsConfig): TableColumnDef<LlmpricingruletypesLLMPricingRuleDTO>[] {
return [
{
id: 'model',
header: 'Model',
accessorFn: (row): string => row.modelName ?? '',
// Flexes to absorb spare width alongside Extra buckets so the row fills
// the container instead of leaving a gap on the right.
width: { min: 240, default: '100%' },
enableMove: false,
enableRemove: false,
cell: ({ row }): JSX.Element => (
<div className={styles.modelCell}>
<Typography.Text
weight="semibold"
truncate={1}
testId={`model-cell-name-${row.id}`}
>
{row.modelName}
</Typography.Text>
<Typography.Text truncate={1}>{getCanonicalId(row)}</Typography.Text>
</div>
),
},
{
id: 'provider',
header: 'Provider',
accessorKey: 'provider',
width: { min: 140 },
enableMove: false,
cell: ({ row }): string => row.provider ?? '',
},
{
id: 'input',
header: 'Input / 1M',
width: { min: 120 },
enableMove: false,
cell: ({ row }): JSX.Element => (
<Typography.Text>
{formatPricePerMillion(row.pricing?.input)}
</Typography.Text>
),
},
{
id: 'output',
header: 'Output / 1M',
width: { min: 120 },
enableMove: false,
cell: ({ row }): JSX.Element => (
<Typography.Text>
{formatPricePerMillion(row.pricing?.output)}
</Typography.Text>
),
},
{
id: 'extraBuckets',
header: 'Extra buckets',
width: { min: 200, default: '100%' },
enableMove: false,
cell: ({ row }): JSX.Element => {
const buckets = getExtraBuckets(row);
if (buckets.length === 0) {
return (
<Typography.Text color="muted" as="span">
</Typography.Text>
);
}
return (
<div className={styles.extraBuckets}>
{buckets.map((bucket) => (
<Badge
key={bucket.key}
color="vanilla"
variant="outline"
className={styles.extraBucketsChip}
>
<Typography.Text as="span" size="small">
{startCase(bucket.key)}
</Typography.Text>
<Typography.Text as="span" size="small" weight="semibold">
{formatPricePerMillion(bucket.pricePerMillion)}
</Typography.Text>
</Badge>
))}
</div>
);
},
},
{
id: 'source',
header: 'Source',
width: { min: 130 },
enableMove: false,
cell: ({ row }): JSX.Element => (
<Badge
color={row.isOverride ? 'amber' : 'robin'}
variant="outline"
className={styles.sourceBadge}
data-testid={`source-badge-${row.id}`}
>
{getSourceLabel(row)}
</Badge>
),
},
{
id: 'lastSeen',
header: 'Last seen',
width: { min: 120 },
enableMove: false,
cell: ({ row }): string => getRelativeLastSeen(row),
},
{
id: 'actions',
header: '',
width: { fixed: '56px', ignoreLastColumnFill: true },
pin: 'right',
enableMove: false,
enableRemove: false,
cell: ({ row }): JSX.Element | null => (
<ModelCostActionsMenu
rule={row}
canManage={canManage}
onEdit={onEdit}
onDelete={onDelete}
/>
),
},
];
}

View File

@@ -0,0 +1,26 @@
.modelCell {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
min-width: 0;
}
.extraBuckets {
display: flex;
// Keep chips on a single line so the row stays at the table's fixed row
// height; the column flexes to 100% so there's room for both.
flex-wrap: nowrap;
gap: var(--spacing-3);
overflow: hidden;
}
.extraBucketsChip {
display: inline-flex;
align-items: center;
gap: var(--spacing-3);
margin: 0;
}
.sourceBadge {
margin: 0;
}

View File

@@ -0,0 +1 @@
export { default } from './ModelCostsTable';

View File

@@ -0,0 +1 @@
export { default } from './ModelCostTabPanel';

View File

@@ -0,0 +1,6 @@
export const PAGE_SIZE = 20;
export const PAGE_KEY = 'page';
export const LIMIT_KEY = 'limit';
export const SKELETON_ROW_COUNT = PAGE_SIZE;

View File

@@ -0,0 +1,4 @@
export interface ExtraBucket {
key: string;
pricePerMillion: number;
}

View File

@@ -0,0 +1,60 @@
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import type { ExtraBucket } from './types';
import type { LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
dayjs.extend(relativeTime);
const getRelativeTime = (
timestamp: string | number | Date | null | undefined,
): string => {
const parsed = timestamp != null ? dayjs(timestamp) : null;
return parsed?.isValid() ? parsed.fromNow() : '—';
};
// ─── Display helpers ─────────────────────────────────────────────────────────
export const formatPricePerMillion = (value: number | undefined): string => {
if (value === undefined || value === null) {
return '—';
}
// 2dp is enough for per-1M pricing. we can update this later we models have sub-cent pricing.
return `$${value.toFixed(2)}`;
};
export const getExtraBuckets = (
rule: LlmpricingruletypesLLMPricingRuleDTO,
): ExtraBucket[] => {
const cache = rule.pricing?.cache;
if (!cache) {
return [];
}
const buckets: ExtraBucket[] = [];
if (typeof cache.read === 'number' && cache.read > 0) {
buckets.push({ key: 'cache_read', pricePerMillion: cache.read });
}
if (typeof cache.write === 'number' && cache.write > 0) {
buckets.push({ key: 'cache_write', pricePerMillion: cache.write });
}
return buckets;
};
export const getSourceLabel = (
rule: LlmpricingruletypesLLMPricingRuleDTO,
): 'Auto' | 'User override' => (rule.isOverride ? 'User override' : 'Auto');
export const getRelativeLastSeen = (
rule: LlmpricingruletypesLLMPricingRuleDTO,
): string => getRelativeTime(rule.updatedAt || rule.syncedAt || rule.createdAt);
// Canonical id shown under the model name, e.g. "openai:gpt-4o". Both segments
// are lower-cased so the id is consistently normalised (providers/models can
// arrive with mixed casing).
export const getCanonicalId = (
rule: LlmpricingruletypesLLMPricingRuleDTO,
): string => {
const provider = rule.provider?.trim().toLowerCase() || 'unknown';
const model = rule.modelName?.trim().toLowerCase() || 'unknown';
return `${provider}:${model}`;
};

View File

@@ -177,7 +177,8 @@ describe('Tooltip', () => {
renderTooltip({ uPlotInstance, content });
const list = screen.getByTestId('uplot-tooltip-list');
expect(list).toHaveStyle({ height: '200px' });
// Measured height (200) + the scroll viewport's vertical padding (16)
expect(list).toHaveStyle({ height: '216px' });
});
it('sets tooltip list height based on content length when Virtuoso reports 0 height', () => {
@@ -188,8 +189,8 @@ describe('Tooltip', () => {
renderTooltip({ uPlotInstance, content });
const list = screen.getByTestId('uplot-tooltip-list');
// Falls back to content length: 2 items * 38px = 76px
expect(list).toHaveStyle({ height: '76px' });
// Falls back to content length (2 * 38 = 76) + vertical padding (16) = 92px
expect(list).toHaveStyle({ height: '92px' });
});
});

View File

@@ -13,6 +13,11 @@ import Styles from './TooltipList.module.scss';
// Fallback per-item height before Virtuoso reports the real total.
const TOOLTIP_ITEM_HEIGHT = 38;
const LIST_MAX_HEIGHT = 300;
// Vertical padding (spacing-4 top + bottom) the SCSS applies to the scroll
// viewport. Virtuoso's reported height covers only the items, so it must be
// added back — otherwise the box is short by this amount, clipping the last
// row and showing a scrollbar even when every row would fit.
const LIST_VERTICAL_PADDING = 16;
interface TooltipListProps {
id: string;
@@ -30,13 +35,13 @@ export default function TooltipList({
// Use the measured height from Virtuoso when available; fall back to a
// per-item estimate on first render. Math.ceil prevents a 1 px
// subpixel rounding gap from triggering a spurious scrollbar.
const height = useMemo(
() =>
totalListHeight > 0
? Math.ceil(Math.min(totalListHeight, LIST_MAX_HEIGHT))
: Math.min(content.length * TOOLTIP_ITEM_HEIGHT, LIST_MAX_HEIGHT),
[totalListHeight, content.length],
);
const height = useMemo(() => {
const contentHeight =
totalListHeight > 0 ? totalListHeight : content.length * TOOLTIP_ITEM_HEIGHT;
return Math.ceil(
Math.min(contentHeight + LIST_VERTICAL_PADDING, LIST_MAX_HEIGHT),
);
}, [totalListHeight, content.length]);
const handleScroll = useCallback(() => {
if (!isScrollEventTriggered.current) {

View File

@@ -176,6 +176,15 @@ export const handlers = [
res(ctx.status(200), ctx.json(getDashboardById)),
),
rest.post('http://localhost/api/v2/users', (_, res, ctx) =>
res(
ctx.status(201),
ctx.json({
data: { id: 'user-123' },
}),
),
),
rest.post('http://localhost/api/v1/invite', (_, res, ctx) =>
res(
ctx.status(200),

View File

@@ -2,6 +2,7 @@
import { useTranslation } from 'react-i18next';
import { useQuery } from 'react-query';
import { matchPath, useLocation } from 'react-router-dom';
import { Typography } from '@signozhq/ui/typography';
import get from 'api/channels/get';
import AlertBreadcrumb from 'components/AlertBreadcrumb';
@@ -24,10 +25,10 @@ import './ChannelsEdit.styles.scss';
function ChannelsEdit(): JSX.Element {
const { t } = useTranslation();
// Extract channelId from URL pathname
const { pathname } = window.location;
const channelIdMatch = pathname.match(/\/alerts\/channels\/edit\/([^/]+)/);
const channelId = channelIdMatch ? channelIdMatch[1] : undefined;
const { pathname } = useLocation();
const channelId = matchPath<{ channelId: string }>(pathname, {
path: ROUTES.CHANNELS_EDIT,
})?.params?.channelId;
const { isFetching, isError, data, error } = useQuery<
SuccessResponseV2<Channels>,
@@ -147,6 +148,7 @@ function ChannelsEdit(): JSX.Element {
<div className="edit-alert-channels-container">
<EditAlertChannels
{...{
channelId: channelId || '',
initialValue: {
...target.channel,
type: target.type,

View File

@@ -150,7 +150,7 @@ export function useVariableForm({
const next: VariableFormModel = {
...model,
name: trimmedName,
defaultValue: defaultValue ? { value: defaultValue } : undefined,
defaultValue: defaultValue || undefined,
};
const cycle = detectVariableCycle([...siblings, next]);

View File

@@ -1,5 +1,5 @@
import {
DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTOKind as TextEnvelopeKind,
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTOKind as TextEnvelopeKind,
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTOKind as ListEnvelopeKind,
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesCustomVariableSpecDTOKind as CustomPluginKind,
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDynamicVariableSpecDTOKind as DynamicPluginKind,
@@ -9,7 +9,7 @@ import type {
DashboardtypesListVariableSpecDTO,
DashboardtypesVariableDTO,
DashboardtypesVariablePluginDTO,
DashboardTextVariableSpecDTO,
DashboardtypesTextVariableSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
@@ -19,7 +19,6 @@ import {
signalForApi,
VARIABLE_SORT_DISABLED,
type VariableFormModel,
type VariableSort,
} from './variableFormModel';
/** DTO envelope → flat form model (for display / editing). */
@@ -37,7 +36,7 @@ export function dtoToFormModel(
// Text variable — a distinct envelope (no list plugin).
if (dto.kind === TextEnvelopeKind.TextVariable) {
const spec = dto.spec as DashboardTextVariableSpecDTO;
const spec = dto.spec as DashboardtypesTextVariableSpecDTO;
return {
...common,
type: 'TEXT',
@@ -52,7 +51,7 @@ export function dtoToFormModel(
...common,
multiSelect: spec.allowMultiple ?? false,
showAllOption: spec.allowAllValue ?? false,
sort: (spec.sort as VariableSort) ?? VARIABLE_SORT_DISABLED,
sort: spec.sort ?? VARIABLE_SORT_DISABLED,
defaultValue: spec.defaultValue,
};
const plugin = spec.plugin;

View File

@@ -1,5 +1,8 @@
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { VariableDefaultValueDTO } from 'api/generated/services/sigNoz.schemas';
import {
DashboardtypesListVariableSpecSortDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { DashboardtypesVariableDefaultValueDTO } from 'api/generated/services/sigNoz.schemas';
import { sortBy } from 'lodash-es';
/**
@@ -24,19 +27,19 @@ export const DYNAMIC_SIGNAL_ALL = 'all' as const;
export type DynamicSignalOption = TelemetrySignal | typeof DYNAMIC_SIGNAL_ALL;
/**
* Sort order for list-variable values. The wire (Perses) validates `sort`
* against a fixed method set. There is no generated TS enum for it
* (`DashboardtypesListOrderDTO` is the query-builder order, a different field),
* so we mirror the Perses `Sort` tokens here.
* Sort order for list-variable values, keyed by the generated wire enum so the
* form model and the DTO `sort` field share one source of truth. The friendly
* keys (`DISABLED` / `ASC` / …) are UI-facing; the values are the Perses `Sort`
* tokens the wire validates against.
*/
export const VARIABLE_SORT = {
DISABLED: 'none',
ASC: 'alphabetical-asc',
DESC: 'alphabetical-desc',
NUMERICAL_ASC: 'numerical-asc',
NUMERICAL_DESC: 'numerical-desc',
CI_ASC: 'alphabetical-ci-asc',
CI_DESC: 'alphabetical-ci-desc',
DISABLED: DashboardtypesListVariableSpecSortDTO.none,
ASC: DashboardtypesListVariableSpecSortDTO['alphabetical-asc'],
DESC: DashboardtypesListVariableSpecSortDTO['alphabetical-desc'],
NUMERICAL_ASC: DashboardtypesListVariableSpecSortDTO['numerical-asc'],
NUMERICAL_DESC: DashboardtypesListVariableSpecSortDTO['numerical-desc'],
CI_ASC: DashboardtypesListVariableSpecSortDTO['alphabetical-ci-asc'],
CI_DESC: DashboardtypesListVariableSpecSortDTO['alphabetical-ci-desc'],
} as const;
export type VariableSort = (typeof VARIABLE_SORT)[keyof typeof VARIABLE_SORT];
@@ -133,7 +136,7 @@ export interface VariableFormModel {
* Runtime-selected default, not editable in the management tab yet; carried
* through edits so saving a definition doesn't clobber it.
*/
defaultValue?: VariableDefaultValueDTO;
defaultValue?: DashboardtypesVariableDefaultValueDTO;
}
export function emptyVariableFormModel(): VariableFormModel {

View File

@@ -20,6 +20,8 @@ interface ConfigPaneProps {
/** The panel spec — the single editing surface (title/description + section slices). */
spec: DashboardtypesPanelSpecDTO;
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
/** Switch the panel to another visualization kind. */
onChangePanelKind: (kind: PanelKind) => void;
/** Panel's resolved series, provided to sections that need them (legend colors). */
legendSeries: LegendSeries[];
/** Table panel's resolved value columns, for the table-only editors. */
@@ -36,13 +38,14 @@ function ConfigPane({
panelKind,
spec,
onChangeSpec,
onChangePanelKind,
legendSeries,
tableColumns,
}: ConfigPaneProps): JSX.Element {
const definition = getPanelDefinition(panelKind);
const sections = definition.sections;
const signal = getBuilderQueries(spec.queries || [])[0]?.signal as
const signal = getBuilderQueries(spec.queries)[0]?.signal as
| TelemetrytypesSignalDTO
| undefined;
@@ -95,6 +98,8 @@ function ConfigPane({
legendSeries={legendSeries}
tableColumns={tableColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
/>
))}
</div>

View File

@@ -0,0 +1,6 @@
// Matches ConfigPane's `.field` so the switcher lines up with the title/description fields.
.field {
display: flex;
flex-direction: column;
gap: 8px;
}

View File

@@ -0,0 +1,53 @@
import { Typography } from '@signozhq/ui/typography';
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from '../../../Panels/registry';
import type { PanelKind } from '../../../Panels/types/panelKind';
import { PANEL_TYPES } from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/constants';
import ConfigSelect from '../controls/ConfigSelect/ConfigSelect';
import styles from './PanelTypeSwitcher.module.scss';
interface PanelTypeSwitcherProps {
/** The current panel kind (selected value). */
panelKind: PanelKind;
/** Panel's current datasource — drives the disabled rule. */
signal?: TelemetrytypesSignalDTO;
onChange: (kind: PanelKind) => void;
}
/**
* Visualization-type selector (rendered inside the Visualization section). Types whose
* supported signals exclude the panel's current datasource are disabled (V1 parity —
* e.g. List needs logs/traces, not metrics). The datasource is unknown for
* PromQL/ClickHouse queries, in which case no type is disabled.
*/
function PanelTypeSwitcher({
panelKind,
signal,
onChange,
}: PanelTypeSwitcherProps): JSX.Element {
const items = PANEL_TYPES.map(({ panelKind, label, Icon }) => {
const definition = getPanelDefinition(panelKind);
return {
value: panelKind,
label,
icon: <Icon size={14} />,
disabled: !!signal && !definition.supportedSignals.includes(signal),
};
});
return (
<div className={styles.field}>
<Typography.Text>Panel Type</Typography.Text>
<ConfigSelect
testId="panel-editor-v2-type-switcher"
value={panelKind}
items={items}
onChange={(value): void => onChange(value)}
/>
</div>
);
}
export default PanelTypeSwitcher;

View File

@@ -0,0 +1,73 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import PanelTypeSwitcher from '../PanelTypeSwitcher';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
jest.mock('pages/DashboardPageV2/DashboardContainer/Panels/registry', () => ({
getPanelDefinition: jest.fn(),
}));
const mockGetPanelDefinition = getPanelDefinition as unknown as jest.Mock;
function openDropdown(): void {
fireEvent.mouseDown(screen.getByRole('combobox'));
}
describe('PanelTypeSwitcher', () => {
beforeEach(() => {
jest.clearAllMocks();
// List supports only logs/traces; every other kind also supports metrics.
mockGetPanelDefinition.mockImplementation((kind: string) => ({
supportedSignals:
kind === 'signoz/ListPanel'
? ['logs', 'traces']
: ['metrics', 'logs', 'traces'],
}));
});
it('fires onChange with the chosen plugin kind', () => {
const onChange = jest.fn();
render(
<PanelTypeSwitcher panelKind="signoz/TimeSeriesPanel" onChange={onChange} />,
);
openDropdown();
fireEvent.click(screen.getByText('List'));
expect(onChange).toHaveBeenCalledWith('signoz/ListPanel');
});
it('disables types whose supported signals exclude the current datasource', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
signal={TelemetrytypesSignalDTO.metrics}
onChange={jest.fn()}
/>,
);
openDropdown();
const disabled = Array.from(
document.querySelectorAll('.ant-select-item-option-disabled'),
).map((el) => el.textContent);
// List can't render a metrics query, so it's disabled; Time Series stays enabled.
expect(disabled).toContain('List');
expect(disabled).not.toContain('Time Series');
});
it('does not disable any type when the datasource is unknown', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
onChange={jest.fn()}
/>,
);
openDropdown();
expect(
document.querySelectorAll('.ant-select-item-option-disabled'),
).toHaveLength(0);
});
});

View File

@@ -6,8 +6,10 @@ import {
type PanelFormattingSlice,
SECTION_METADATA,
type SectionConfig,
SectionKind,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import type { PanelKind } from '../../../Panels/types/panelKind';
import type { LegendSeries } from '../../hooks/useLegendSeries';
import type { TableColumnOption } from '../../hooks/useTableColumns';
import { resolveSectionEditor } from '../sectionRegistry';
@@ -23,6 +25,9 @@ interface SectionSlotProps {
tableColumns: TableColumnOption[];
/** Panel's telemetry signal, for editors that fetch field suggestions (List columns). */
signal?: TelemetrytypesSignalDTO;
/** Current panel kind + switch handler, for the visualization section's type switcher. */
panelKind: PanelKind;
onChangePanelKind: (kind: PanelKind) => void;
}
/**
@@ -38,6 +43,8 @@ function SectionSlot({
legendSeries,
tableColumns,
signal,
panelKind,
onChangePanelKind,
}: SectionSlotProps): JSX.Element | null {
// A kind can hide a section based on current spec state (e.g. Histogram legend once
// queries are merged) — skip it before resolving the editor.
@@ -60,7 +67,12 @@ function SectionSlot({
.formatting?.unit;
return (
<SettingsSection title={title} icon={<Icon size={15} />}>
<SettingsSection
title={title}
icon={<Icon size={15} />}
// Open Visualization by default so the type switcher is visible.
defaultOpen={config.kind === SectionKind.Visualization}
>
<Component
value={get(spec)}
controls={controls}
@@ -69,6 +81,8 @@ function SectionSlot({
yAxisUnit={yAxisUnit}
tableColumns={tableColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
/>
</SettingsSection>
);

View File

@@ -21,6 +21,7 @@ function renderConfigPane(
panelKind: 'signoz/TimeSeriesPanel',
spec: spec(),
onChangeSpec: jest.fn(),
onChangePanelKind: jest.fn(),
legendSeries: [],
tableColumns: [],
...overrides,

View File

@@ -1,5 +1,5 @@
.group {
width: min(350px, 100%);
width: 100%;
}
.segment {

View File

@@ -1,21 +1,22 @@
import type { ReactNode } from 'react';
import { Select } from 'antd';
import { SegmentIcon, type SegmentIconName } from '../segmentIcons';
import styles from './ConfigSelect.module.scss';
export interface ConfigSelectItem {
value: string;
export interface ConfigSelectItem<T extends string = string> {
value: T;
label: string;
icon?: SegmentIconName;
/** Optional leading icon node rendered before the label. */
icon?: ReactNode;
disabled?: boolean;
}
interface ConfigSelectProps {
interface ConfigSelectProps<T extends string = string> {
testId: string;
value: string | undefined;
value: T | undefined;
placeholder?: string;
items: ConfigSelectItem[];
onChange: (value: string) => void;
items: ConfigSelectItem<T>[];
onChange: (value: T) => void;
}
/**
@@ -23,15 +24,15 @@ interface ConfigSelectProps {
* `Select` so it matches the rest of the editor's antd controls; the menu portals to
* `document.body` (antd default) so the surrounding `overflow:auto` pane can't clip it.
*/
function ConfigSelect({
function ConfigSelect<T extends string = string>({
testId,
value,
placeholder,
items,
onChange,
}: ConfigSelectProps): JSX.Element {
}: ConfigSelectProps<T>): JSX.Element {
return (
<Select<string>
<Select<T>
className={styles.select}
data-testid={testId}
value={value}
@@ -40,9 +41,10 @@ function ConfigSelect({
virtual={false}
options={items.map((item) => ({
value: item.value,
disabled: item.disabled,
label: item.icon ? (
<span className={styles.item}>
<SegmentIcon name={item.icon} />
{item.icon}
{item.label}
</span>
) : (

View File

@@ -8,12 +8,12 @@ import type {
DashboardtypesPanelSpecDTO,
DashboardtypesTimeSeriesChartAppearanceDTO,
} from 'api/generated/services/sigNoz.schemas';
import type {
AnyThreshold,
PanelFormattingSlice,
SectionEditorProps,
import {
SectionKind,
SectionSpecMap,
type AnyThreshold,
type PanelFormattingSlice,
type SectionEditorProps,
type SectionSpecMap,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import AxesSection from './sections/AxesSection/AxesSection';
@@ -69,27 +69,27 @@ function updatePluginSlice(
export const SECTION_REGISTRY: {
[K in SectionKind]?: SectionDescriptor<K>;
} = {
formatting: {
[SectionKind.Formatting]: {
Component: FormattingSection,
get: (spec): PanelFormattingSlice | undefined =>
getPluginSlice<PanelFormattingSlice>(spec, 'formatting'),
update: (spec, formatting): PanelSpec =>
updatePluginSlice(spec, 'formatting', formatting),
},
axes: {
[SectionKind.Axes]: {
Component: AxesSection,
get: (spec): DashboardtypesAxesDTO | undefined =>
getPluginSlice<DashboardtypesAxesDTO>(spec, 'axes'),
update: (spec, axes): PanelSpec => updatePluginSlice(spec, 'axes', axes),
},
legend: {
[SectionKind.Legend]: {
Component: LegendSection,
get: (spec): DashboardtypesLegendDTO | undefined =>
getPluginSlice<DashboardtypesLegendDTO>(spec, 'legend'),
update: (spec, legend): PanelSpec =>
updatePluginSlice(spec, 'legend', legend),
},
chartAppearance: {
[SectionKind.ChartAppearance]: {
Component: ChartAppearanceSection,
get: (spec): DashboardtypesTimeSeriesChartAppearanceDTO | undefined =>
getPluginSlice<DashboardtypesTimeSeriesChartAppearanceDTO>(
@@ -99,7 +99,7 @@ export const SECTION_REGISTRY: {
update: (spec, chartAppearance): PanelSpec =>
updatePluginSlice(spec, 'chartAppearance', chartAppearance),
},
visualization: {
[SectionKind.Visualization]: {
Component: VisualizationSection,
get: (spec): DashboardtypesBarChartVisualizationDTO | undefined =>
getPluginSlice<DashboardtypesBarChartVisualizationDTO>(
@@ -109,14 +109,14 @@ export const SECTION_REGISTRY: {
update: (spec, visualization): PanelSpec =>
updatePluginSlice(spec, 'visualization', visualization),
},
buckets: {
[SectionKind.Buckets]: {
Component: BucketsSection,
get: (spec): DashboardtypesHistogramBucketsDTO | undefined =>
getPluginSlice<DashboardtypesHistogramBucketsDTO>(spec, 'histogramBuckets'),
update: (spec, buckets): PanelSpec =>
updatePluginSlice(spec, 'histogramBuckets', buckets),
},
contextLinks: {
[SectionKind.ContextLinks]: {
Component: ContextLinksSection,
// Panel-level slice (spec.links), not under the plugin spec — no cast needed.
get: (spec): DashboardLinkDTO[] | undefined => spec.links,
@@ -125,7 +125,7 @@ export const SECTION_REGISTRY: {
// One editor for every threshold variant (label / comparison / table); the kind's
// `controls.variant` picks the row editor + element shape. All persist to the same
// plugin.spec.thresholds key.
thresholds: {
[SectionKind.Thresholds]: {
Component: ThresholdsSection,
get: (spec): AnyThreshold[] | undefined =>
getPluginSlice<AnyThreshold[]>(spec, 'thresholds'),
@@ -157,6 +157,10 @@ export interface ErasedSectionDescriptor {
// The panel's telemetry signal; read by editors that fetch field-key
// suggestions scoped to it (List column picker).
signal?: unknown;
// Current panel kind + switch handler; read by the visualization section's
// type switcher.
panelKind?: unknown;
onChangePanelKind?: unknown;
}>;
get: (spec: PanelSpec) => unknown;
update: (spec: PanelSpec, value: unknown) => PanelSpec;

View File

@@ -1,7 +1,10 @@
import type { ChangeEvent } from 'react';
import { Typography } from '@signozhq/ui/typography';
import { Input } from 'antd';
import type { SectionEditorProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
@@ -22,7 +25,7 @@ function AxesSection({
value,
controls,
onChange,
}: SectionEditorProps<'axes'>): JSX.Element {
}: SectionEditorProps<SectionKind.Axes>): JSX.Element {
// An empty field clears the bound (null); otherwise parse to a number, ignoring
// transient non-numeric input (e.g. a lone "-") by leaving the bound unset.
const handleBound =

View File

@@ -2,7 +2,10 @@ import type { ChangeEvent } from 'react';
import { Typography } from '@signozhq/ui/typography';
import { Input } from 'antd';
import type { DashboardtypesHistogramBucketsDTO } from 'api/generated/services/sigNoz.schemas';
import type { SectionEditorProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
@@ -23,7 +26,7 @@ function BucketsSection({
value,
controls,
onChange,
}: SectionEditorProps<'buckets'>): JSX.Element {
}: SectionEditorProps<SectionKind.Buckets>): JSX.Element {
// Empty clears the bound to null (chart auto-sizes); otherwise parse to a number,
// ignoring transient non-numeric input by leaving it unset.
const handleNumber =

View File

@@ -6,11 +6,15 @@ import {
DashboardtypesLineInterpolationDTO,
DashboardtypesLineStyleDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { SectionEditorProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
import { SegmentIcon } from '../../controls/segmentIcons';
import styles from './ChartAppearanceSection.module.scss';
@@ -31,22 +35,22 @@ const LINE_INTERPOLATION_OPTIONS = [
{
value: DashboardtypesLineInterpolationDTO.linear,
label: 'Linear',
icon: 'interp-linear' as const,
icon: <SegmentIcon name="interp-linear" />,
},
{
value: DashboardtypesLineInterpolationDTO.spline,
label: 'Spline',
icon: 'interp-spline' as const,
icon: <SegmentIcon name="interp-spline" />,
},
{
value: DashboardtypesLineInterpolationDTO.step_before,
label: 'Step before',
icon: 'interp-step-before' as const,
icon: <SegmentIcon name="interp-step-before" />,
},
{
value: DashboardtypesLineInterpolationDTO.step_after,
label: 'Step after',
icon: 'interp-step-after' as const,
icon: <SegmentIcon name="interp-step-after" />,
},
];
@@ -77,7 +81,7 @@ function ChartAppearanceSection({
value,
controls,
onChange,
}: SectionEditorProps<'chartAppearance'>): JSX.Element {
}: SectionEditorProps<SectionKind.ChartAppearance>): JSX.Element {
// `spanGaps.fillLessThan` is a stringified seconds threshold: empty means "connect
// every gap" (the chart default), a number means "only bridge gaps shorter than this".
const handleSpanGaps = (e: ChangeEvent<HTMLInputElement>): void => {
@@ -114,7 +118,7 @@ function ChartAppearanceSection({
onChange={(next): void =>
onChange({
...value,
lineInterpolation: next as DashboardtypesLineInterpolationDTO,
lineInterpolation: next,
})
}
/>

View File

@@ -4,7 +4,10 @@ import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import { Input } from 'antd';
import type { DashboardLinkDTO } from 'api/generated/services/sigNoz.schemas';
import type { SectionEditorProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import styles from './ContextLinksSection.module.scss';
@@ -17,7 +20,7 @@ import styles from './ContextLinksSection.module.scss';
function ContextLinksSection({
value,
onChange,
}: SectionEditorProps<'contextLinks'>): JSX.Element {
}: SectionEditorProps<SectionKind.ContextLinks>): JSX.Element {
const links = value ?? [];
const updateAt = (index: number, patch: Partial<DashboardLinkDTO>): void =>

View File

@@ -2,7 +2,10 @@ import { Typography } from '@signozhq/ui/typography';
import { DashboardtypesPrecisionOptionDTO } from 'api/generated/services/sigNoz.schemas';
import YAxisUnitSelector from 'components/YAxisUnitSelector';
import { YAxisSource } from 'components/YAxisUnitSelector/types';
import type { SectionEditorProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import type { TableColumnOption } from '../../../hooks/useTableColumns';
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
@@ -10,7 +13,7 @@ import ColumnUnits from './ColumnUnits';
import styles from './FormattingSection.module.scss';
type FormattingSectionProps = SectionEditorProps<'formatting'> & {
type FormattingSectionProps = SectionEditorProps<SectionKind.Formatting> & {
/** Table panel's resolved value columns; required for the column-units editor. */
tableColumns?: TableColumnOption[];
};
@@ -65,7 +68,7 @@ function FormattingSection({
onChange={(next): void =>
onChange({
...value,
decimalPrecision: next as DashboardtypesPrecisionOptionDTO,
decimalPrecision: next,
})
}
/>

View File

@@ -1,6 +1,9 @@
import { Typography } from '@signozhq/ui/typography';
import { DashboardtypesLegendPositionDTO } from 'api/generated/services/sigNoz.schemas';
import type { SectionEditorProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
import LegendColors from '../../controls/LegendColors/LegendColors';
@@ -8,7 +11,7 @@ import type { LegendSeries } from '../../../hooks/useLegendSeries';
import styles from './LegendSection.module.scss';
type LegendSectionProps = SectionEditorProps<'legend'> & {
type LegendSectionProps = SectionEditorProps<SectionKind.Legend> & {
/** Panel's resolved series, forwarded by SectionSlot for the colors control. */
legendSeries?: LegendSeries[];
};

View File

@@ -1,26 +1,51 @@
import { Typography } from '@signozhq/ui/typography';
import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.schemas';
import type { SectionEditorProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import type { PanelKind } from '../../../../Panels/types/panelKind';
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
import PanelTypeSwitcher from '../../PanelTypeSwitcher/PanelTypeSwitcher';
import { TIME_PREFERENCE_OPTIONS } from './timePreferenceOptions';
import styles from './VisualizationSection.module.scss';
type VisualizationSectionProps =
SectionEditorProps<SectionKind.Visualization> & {
/** Current panel kind + switch handler, forwarded by SectionSlot for the type switcher. */
panelKind?: PanelKind;
onChangePanelKind?: (kind: PanelKind) => void;
/** Panel's datasource, forwarded by SectionSlot — scopes the switcher's disabled types. */
signal?: TelemetrytypesSignalDTO;
};
/**
* Edits the `visualization` slice: the per-panel time preference (all kinds), bar
* stacking (`stackedBarChart`, Bar only), and gap filling (`fillSpans`, TimeSeries
* only). Each control is gated by its `controls` flag, so a kind only renders — and only
* writes — the visualization fields its spec actually supports.
* Edits the `visualization` slice: the panel-type switcher (`switchPanelKind`, every
* kind), the per-panel time preference, bar stacking (`stackedBarChart`, Bar only), and
* gap filling (`fillSpans`, TimeSeries only). Each control is gated by its `controls`
* flag, so a kind only renders — and only writes — the fields its spec supports.
*/
function VisualizationSection({
value,
controls,
onChange,
}: SectionEditorProps<'visualization'>): JSX.Element {
panelKind,
onChangePanelKind,
signal,
}: VisualizationSectionProps): JSX.Element {
return (
<>
{controls.switchPanelKind && panelKind && onChangePanelKind && (
<PanelTypeSwitcher
panelKind={panelKind}
signal={signal}
onChange={onChangePanelKind}
/>
)}
{controls.timePreference && (
<div className={styles.field}>
<Typography.Text>Panel time preference</Typography.Text>
@@ -32,7 +57,7 @@ function VisualizationSection({
onChange={(next): void =>
onChange({
...value,
timePreference: next as DashboardtypesTimePreferenceDTO,
timePreference: next,
})
}
/>

View File

@@ -4,6 +4,14 @@ import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.s
import VisualizationSection from '../VisualizationSection';
// The type switcher resolves each kind's supported signals; stub it so the test
// doesn't pull the whole panel registry (renderers, chart libs).
jest.mock('pages/DashboardPageV2/DashboardContainer/Panels/registry', () => ({
getPanelDefinition: jest.fn(() => ({
supportedSignals: ['metrics', 'logs', 'traces'],
})),
}));
// Open the antd Select by clicking its selector, then pick the option by label.
async function pickOption(triggerTestId: string, label: string): Promise<void> {
const user = userEvent.setup();
@@ -17,7 +25,12 @@ describe('VisualizationSection', () => {
render(
<VisualizationSection
value={undefined}
controls={{ timePreference: true, stacking: true, fillSpans: true }}
controls={{
switchPanelKind: true,
timePreference: true,
stacking: true,
fillSpans: true,
}}
onChange={jest.fn()}
/>,
);
@@ -35,7 +48,10 @@ describe('VisualizationSection', () => {
render(
<VisualizationSection
value={undefined}
controls={{ timePreference: true }}
controls={{
switchPanelKind: true,
timePreference: true,
}}
onChange={jest.fn()}
/>,
);
@@ -56,7 +72,10 @@ describe('VisualizationSection', () => {
render(
<VisualizationSection
value={undefined}
controls={{ timePreference: true }}
controls={{
switchPanelKind: true,
timePreference: true,
}}
onChange={onChange}
/>,
);
@@ -74,7 +93,10 @@ describe('VisualizationSection', () => {
timePreference: DashboardtypesTimePreferenceDTO.global_time,
stackedBarChart: false,
}}
controls={{ stacking: true }}
controls={{
switchPanelKind: true,
stacking: true,
}}
onChange={onChange}
/>,
);
@@ -92,7 +114,10 @@ describe('VisualizationSection', () => {
render(
<VisualizationSection
value={{ fillSpans: false }}
controls={{ fillSpans: true }}
controls={{
switchPanelKind: true,
fillSpans: true,
}}
onChange={onChange}
/>,
);
@@ -101,4 +126,43 @@ describe('VisualizationSection', () => {
expect(onChange).toHaveBeenCalledWith({ fillSpans: true });
});
it('renders the type switcher and switches kind when switchPanelKind is set', async () => {
const onChangePanelKind = jest.fn();
render(
<VisualizationSection
value={undefined}
controls={{ switchPanelKind: true }}
onChange={jest.fn()}
panelKind="signoz/TimeSeriesPanel"
onChangePanelKind={onChangePanelKind}
/>,
);
expect(
screen.getByTestId('panel-editor-v2-type-switcher'),
).toBeInTheDocument();
await pickOption('panel-editor-v2-type-switcher', 'Table');
expect(onChangePanelKind).toHaveBeenCalledWith('signoz/TablePanel');
});
it('hides the type switcher when switchPanelKind is not set', () => {
render(
<VisualizationSection
value={undefined}
controls={{
switchPanelKind: false,
timePreference: true,
}}
onChange={jest.fn()}
panelKind="signoz/TimeSeriesPanel"
onChangePanelKind={jest.fn()}
/>,
);
expect(
screen.queryByTestId('panel-editor-v2-type-switcher'),
).not.toBeInTheDocument();
});
});

View File

@@ -4,15 +4,19 @@ import type { ConfigSelectItem } from '../../controls/ConfigSelect/ConfigSelect'
// Per-panel time scope. "Global Time" follows the dashboard's time picker; the rest pin
// the panel to a fixed relative window regardless of the dashboard range (V1 parity).
export const TIME_PREFERENCE_OPTIONS: ConfigSelectItem[] = [
{ value: DashboardtypesTimePreferenceDTO.global_time, label: 'Global Time' },
{ value: DashboardtypesTimePreferenceDTO.last_5_min, label: 'Last 5 min' },
{ value: DashboardtypesTimePreferenceDTO.last_15_min, label: 'Last 15 min' },
{ value: DashboardtypesTimePreferenceDTO.last_30_min, label: 'Last 30 min' },
{ value: DashboardtypesTimePreferenceDTO.last_1_hr, label: 'Last 1 hr' },
{ value: DashboardtypesTimePreferenceDTO.last_6_hr, label: 'Last 6 hr' },
{ value: DashboardtypesTimePreferenceDTO.last_1_day, label: 'Last 1 day' },
{ value: DashboardtypesTimePreferenceDTO.last_3_days, label: 'Last 3 days' },
{ value: DashboardtypesTimePreferenceDTO.last_1_week, label: 'Last 1 week' },
{ value: DashboardtypesTimePreferenceDTO.last_1_month, label: 'Last 1 month' },
];
export const TIME_PREFERENCE_OPTIONS: ConfigSelectItem<DashboardtypesTimePreferenceDTO>[] =
[
{ value: DashboardtypesTimePreferenceDTO.global_time, label: 'Global Time' },
{ value: DashboardtypesTimePreferenceDTO.last_5_min, label: 'Last 5 min' },
{ value: DashboardtypesTimePreferenceDTO.last_15_min, label: 'Last 15 min' },
{ value: DashboardtypesTimePreferenceDTO.last_30_min, label: 'Last 30 min' },
{ value: DashboardtypesTimePreferenceDTO.last_1_hr, label: 'Last 1 hr' },
{ value: DashboardtypesTimePreferenceDTO.last_6_hr, label: 'Last 6 hr' },
{ value: DashboardtypesTimePreferenceDTO.last_1_day, label: 'Last 1 day' },
{ value: DashboardtypesTimePreferenceDTO.last_3_days, label: 'Last 3 days' },
{ value: DashboardtypesTimePreferenceDTO.last_1_week, label: 'Last 1 week' },
{
value: DashboardtypesTimePreferenceDTO.last_1_month,
label: 'Last 1 month',
},
];

View File

@@ -0,0 +1,98 @@
import {
TelemetrytypesSignalDTO,
type DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import { defaultColumnsForSignal } from '../ListColumnsEditor/selectFields';
import { getSwitchedPluginSpec } from '../getSwitchedPluginSpec';
jest.mock('pages/DashboardPageV2/DashboardContainer/Panels/registry', () => ({
getPanelDefinition: jest.fn(),
}));
jest.mock('../ListColumnsEditor/selectFields', () => ({
defaultColumnsForSignal: jest.fn(),
}));
const mockGetPanelDefinition = getPanelDefinition as unknown as jest.Mock;
const mockDefaultColumnsForSignal =
defaultColumnsForSignal as unknown as jest.Mock;
function specWith(pluginSpec: unknown): DashboardtypesPanelSpecDTO {
return {
display: { name: 'Panel' },
plugin: { kind: 'signoz/TablePanel', spec: pluginSpec },
queries: [],
} as unknown as DashboardtypesPanelSpecDTO;
}
describe('getSwitchedPluginSpec', () => {
beforeEach(() => {
jest.clearAllMocks();
mockDefaultColumnsForSignal.mockReturnValue([]);
});
it('carries only unit + decimalPrecision when the new kind has a formatting section', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'formatting', controls: { unit: true, decimals: true } }],
});
const old = specWith({
formatting: { unit: 'ms', decimalPrecision: 2, columnUnits: { A: 'bytes' } },
axes: { logScale: true },
});
const result = getSwitchedPluginSpec(
old,
'signoz/TimeSeriesPanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.formatting).toStrictEqual({ unit: 'ms', decimalPrecision: 2 });
// Type-specific config from the old kind is dropped.
expect((result as { axes?: unknown }).axes).toBeUndefined();
});
it('does not carry formatting when the new kind has no formatting section', () => {
mockGetPanelDefinition.mockReturnValue({ sections: [{ kind: 'columns' }] });
const old = specWith({ formatting: { unit: 'ms' } });
const result = getSwitchedPluginSpec(
old,
'signoz/ListPanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.formatting).toBeUndefined();
});
it('seeds List columns from the signal when switching into a List', () => {
const columns = [{ name: 'body' }];
mockDefaultColumnsForSignal.mockReturnValue(columns);
mockGetPanelDefinition.mockReturnValue({ sections: [{ kind: 'columns' }] });
const result = getSwitchedPluginSpec(
specWith({}),
'signoz/ListPanel',
TelemetrytypesSignalDTO.logs,
);
expect(mockDefaultColumnsForSignal).toHaveBeenCalledWith(
TelemetrytypesSignalDTO.logs,
);
expect(result.selectFields).toBe(columns);
});
it('includes the kind section defaults (e.g. legend position)', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'legend', controls: { position: true } }],
});
const result = getSwitchedPluginSpec(
specWith({}),
'signoz/PieChartPanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.legend?.position).toBe('bottom');
});
});

View File

@@ -0,0 +1,70 @@
import type {
DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
TelemetrytypesTelemetryFieldKeyDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import type { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import {
SectionKind,
type PanelFormattingSlice,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import {
buildDefaultPluginSpec,
type DefaultPluginSpec,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/buildDefaultPluginSpec';
import { defaultColumnsForSignal } from './ListColumnsEditor/selectFields';
/**
* Plugin spec produced on a first-time switch to a new kind. A partial cross-section
* of the per-kind spec union; the caller assigns it to `plugin.spec` (typed `unknown`)
* at the boundary.
*/
export interface SwitchedPluginSpec extends DefaultPluginSpec {
formatting?: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'>;
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
}
/**
* Builds the plugin spec for a first-visit switch to `newKind`: the kind's declared
* section defaults (so the config pane opens populated, matching new-panel seeding) plus
* the only cross-kind config worth keeping — unit + decimal precision. Switching into a
* List seeds the current signal's default columns so the columns control isn't empty.
*
* Revisiting a kind restores its stashed spec instead, so this runs only on first visit.
*/
export function getSwitchedPluginSpec(
oldSpec: DashboardtypesPanelSpecDTO,
newKind: PanelKind,
signal: TelemetrytypesSignalDTO,
): SwitchedPluginSpec {
const sections = getPanelDefinition(newKind).sections;
const result: SwitchedPluginSpec = buildDefaultPluginSpec(sections);
if (sections.some((section) => section.kind === SectionKind.Formatting)) {
const oldFormatting = (
oldSpec.plugin.spec as {
formatting?: PanelFormattingSlice;
}
).formatting;
const carried: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'> = {
...(oldFormatting?.unit !== undefined && { unit: oldFormatting.unit }),
...(oldFormatting?.decimalPrecision !== undefined && {
decimalPrecision: oldFormatting.decimalPrecision,
}),
};
if (Object.keys(carried).length > 0) {
result.formatting = carried;
}
}
if (sections.some((section) => section.kind === SectionKind.Columns)) {
const columns = defaultColumnsForSignal(signal);
if (columns.length > 0) {
result.selectFields = columns;
}
}
return result;
}

View File

@@ -0,0 +1,190 @@
import { act, renderHook } from '@testing-library/react';
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { handleQueryChange } from 'container/NewWidget/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { getBuilderQueries } from '../../../Panels/utils/getBuilderQueries';
import { toPerses } from '../../../queryV5/persesQueryAdapters';
import { getSwitchedPluginSpec } from '../../getSwitchedPluginSpec';
import { usePanelTypeSwitch } from '../usePanelTypeSwitch';
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
jest.mock('container/NewWidget/utils', () => ({
handleQueryChange: jest.fn(),
PANEL_TYPE_TO_QUERY_TYPES: {
graph: ['builder', 'clickhouse', 'promql'],
table: ['builder', 'clickhouse'],
list: ['builder'],
value: ['builder', 'clickhouse', 'promql'],
bar: ['builder', 'clickhouse', 'promql'],
pie: ['builder', 'clickhouse'],
histogram: ['builder', 'clickhouse', 'promql'],
},
}));
jest.mock('../../../queryV5/persesQueryAdapters', () => ({
toPerses: jest.fn(),
}));
jest.mock('../../getSwitchedPluginSpec', () => ({
getSwitchedPluginSpec: jest.fn(),
}));
jest.mock('../../../Panels/utils/getBuilderQueries', () => ({
getBuilderQueries: jest.fn(),
}));
const mockUseQueryBuilder = useQueryBuilder as unknown as jest.Mock;
const mockHandleQueryChange = handleQueryChange as unknown as jest.Mock;
const mockToPerses = toPerses as unknown as jest.Mock;
const mockGetSwitchedPluginSpec = getSwitchedPluginSpec as unknown as jest.Mock;
const mockGetBuilderQueries = getBuilderQueries as unknown as jest.Mock;
// Opaque sentinels — the leaf utilities are mocked, so only identity matters.
const TABLE_PLUGIN_SPEC = { table: true } as unknown;
const TABLE_QUERIES = [{ id: 'table-q' }] as unknown as NonNullable<
DashboardtypesPanelSpecDTO['queries']
>;
const LIST_PLUGIN_SPEC = { list: true } as unknown;
const LIST_QUERIES = [{ id: 'list-q' }] as unknown as NonNullable<
DashboardtypesPanelSpecDTO['queries']
>;
const TRANSFORMED = {
id: 'transformed',
queryType: 'builder',
} as unknown as Query;
const CONVERTED = [{ id: 'converted' }] as unknown as NonNullable<
DashboardtypesPanelSpecDTO['queries']
>;
const SWITCHED_SPEC = { switched: true } as unknown;
function makeSpec(
kind: string,
pluginSpec: unknown,
queries: NonNullable<DashboardtypesPanelSpecDTO['queries']>,
): DashboardtypesPanelSpecDTO {
return {
display: { name: 'Panel' },
plugin: { kind, spec: pluginSpec },
queries,
} as unknown as DashboardtypesPanelSpecDTO;
}
const tableSpec = makeSpec(
'signoz/TablePanel',
TABLE_PLUGIN_SPEC,
TABLE_QUERIES,
);
const listSpec = makeSpec('signoz/ListPanel', LIST_PLUGIN_SPEC, LIST_QUERIES);
function builderState(currentQuery: Query): {
currentQuery: Query;
redirectWithQueryBuilderData: jest.Mock;
} {
return { currentQuery, redirectWithQueryBuilderData: jest.fn() };
}
describe('usePanelTypeSwitch', () => {
beforeEach(() => {
jest.clearAllMocks();
mockHandleQueryChange.mockReturnValue(TRANSFORMED);
mockToPerses.mockReturnValue(CONVERTED);
mockGetSwitchedPluginSpec.mockReturnValue(SWITCHED_SPEC);
mockGetBuilderQueries.mockReturnValue([{ signal: 'logs' }]);
});
it('does nothing when switching to the current kind', () => {
const setSpec = jest.fn();
const state = builderState({ id: 'q', queryType: 'builder' } as Query);
mockUseQueryBuilder.mockReturnValue(state);
const { result } = renderHook(() =>
usePanelTypeSwitch({
spec: tableSpec,
panelType: PANEL_TYPES.TABLE,
setSpec,
}),
);
act(() => result.current.onChangePanelKind('signoz/TablePanel'));
expect(setSpec).not.toHaveBeenCalled();
expect(state.redirectWithQueryBuilderData).not.toHaveBeenCalled();
});
it('on first visit: transforms the query and resets the spec to the new kind', () => {
const setSpec = jest.fn();
const tableQuery = { id: 'table-current', queryType: 'builder' } as Query;
const state = builderState(tableQuery);
mockUseQueryBuilder.mockReturnValue(state);
const { result } = renderHook(() =>
usePanelTypeSwitch({
spec: tableSpec,
panelType: PANEL_TYPES.TABLE,
setSpec,
}),
);
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
expect(setSpec).toHaveBeenCalledTimes(1);
const next = setSpec.mock.calls[0][0] as DashboardtypesPanelSpecDTO;
expect(next.plugin.kind).toBe('signoz/ListPanel');
expect(next.plugin.spec).toBe(SWITCHED_SPEC);
expect(next.queries).toBe(CONVERTED);
expect(state.redirectWithQueryBuilderData).toHaveBeenCalledWith(TRANSFORMED);
});
it('coerces the query type when the new kind disallows it (promql → List)', () => {
const setSpec = jest.fn();
const promQuery = { id: 'prom', queryType: 'promql' } as Query;
mockUseQueryBuilder.mockReturnValue(builderState(promQuery));
const { result } = renderHook(() =>
usePanelTypeSwitch({
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
}),
);
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
// List allows only Query Builder, so the promql query is coerced to 'builder'.
const [, queryArg] = mockHandleQueryChange.mock.calls[0];
expect((queryArg as Query).queryType).toBe('builder');
});
it('restores the original kind verbatim on switch-back (reversibility)', () => {
const setSpec = jest.fn();
const tableQuery = { id: 'table-current', queryType: 'builder' } as Query;
const listQuery = { id: 'list-current', queryType: 'builder' } as Query;
let state = builderState(tableQuery);
mockUseQueryBuilder.mockImplementation(() => state);
const { result, rerender } = renderHook(
(props: { spec: DashboardtypesPanelSpecDTO; panelType: PANEL_TYPES }) =>
usePanelTypeSwitch({ ...props, setSpec }),
{ initialProps: { spec: tableSpec, panelType: PANEL_TYPES.TABLE } },
);
// Leave Table for List (stashes Table in its pristine state).
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
// Parent re-renders as a List panel; the builder now holds the List query.
state = builderState(listQuery);
rerender({ spec: listSpec, panelType: PANEL_TYPES.LIST });
// Switch back to Table → restored from the stash, not re-transformed.
act(() => result.current.onChangePanelKind('signoz/TablePanel'));
const restored = setSpec.mock.calls[
setSpec.mock.calls.length - 1
][0] as DashboardtypesPanelSpecDTO;
expect(restored.plugin.kind).toBe('signoz/TablePanel');
expect(restored.plugin.spec).toBe(TABLE_PLUGIN_SPEC);
expect(restored.queries).toBe(TABLE_QUERIES);
expect(state.redirectWithQueryBuilderData).toHaveBeenCalledWith(tableQuery);
// The restore path must not run the query transform again.
expect(mockHandleQueryChange).toHaveBeenCalledTimes(1);
});
});

View File

@@ -40,7 +40,7 @@ export function useLegendSeries(
getTimeSeriesResults(data?.response),
data.legendMap,
);
const builderQueries = getBuilderQueries(panel?.spec?.queries || []);
const builderQueries = getBuilderQueries(panel.spec.queries);
const byLabel = new Map<string, string>();
series.forEach((s) => {

View File

@@ -57,8 +57,8 @@ export function usePanelEditorQuerySync({
const { currentQuery, stagedQuery, handleRunQuery } = useQueryBuilder();
// Saved queries, captured once: seed the builder and serve as the restore target.
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only snapshot
const savedQueries = useMemo(() => draft.spec?.queries ?? [], []);
const savedQueries = draft.spec.queries;
// A new panel has no saved query: seed from the kind's first supported signal
// instead of letting `fromPerses` fall back to the metrics default (which List
// doesn't support).
@@ -93,7 +93,7 @@ export function usePanelEditorQuerySync({
// No-op guard at the V5 envelope level: equivalent wrappers (bare
// `signoz/BuilderQuery` vs `signoz/CompositeQuery`) unwrap to the same
// envelopes, so a structural compare would falsely dirty the draft.
const current = draft.spec?.queries ?? [];
const current = draft.spec.queries;
if (isEqual(toQueryEnvelopes(next), toQueryEnvelopes(current))) {
return false;
}

View File

@@ -0,0 +1,137 @@
import { useCallback, useRef } from 'react';
import type {
DashboardtypesPanelPluginDTO,
DashboardtypesPanelSpecDTO,
DashboardtypesQueryDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
handleQueryChange,
PANEL_TYPE_TO_QUERY_TYPES,
type PartialPanelTypes,
} from 'container/NewWidget/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from '../../Panels/types/panelKind';
import { getBuilderQueries } from '../../Panels/utils/getBuilderQueries';
import { toPerses } from '../../queryV5/persesQueryAdapters';
import {
getSwitchedPluginSpec,
type SwitchedPluginSpec,
} from '../getSwitchedPluginSpec';
/** What a kind looks like when you leave it; restored verbatim if you return. */
interface KindState {
pluginSpec: DashboardtypesPanelPluginDTO['spec'];
queries: DashboardtypesQueryDTO[];
builderQuery: Query;
}
interface UsePanelTypeSwitchArgs {
spec: DashboardtypesPanelSpecDTO;
panelType: PANEL_TYPES;
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
}
interface UsePanelTypeSwitchApi {
/** Switch the panel to `newKind`, transforming/restoring its query + spec. */
onChangePanelKind: (newKind: PanelKind) => void;
}
/**
* Switches the edited panel's visualization kind. Mutating `plugin.kind` re-derives the
* renderer, config sections, query-builder tabs and request type for free; this hook adds
* the two things that don't: a per-kind session cache that makes switching reversible
* (`Table → List → Table` restores the original query + spec), and, on first visit to a
* kind, a query rebuild (`handleQueryChange`) + spec reset (`getSwitchedPluginSpec`).
*/
export function usePanelTypeSwitch({
spec,
panelType,
setSpec,
}: UsePanelTypeSwitchArgs): UsePanelTypeSwitchApi {
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const cacheRef = useRef<Map<PanelKind, KindState>>(new Map());
// Latest spec/query/type, read inside the stable callback without re-subscribing.
const specRef = useRef(spec);
specRef.current = spec;
const queryRef = useRef(currentQuery);
queryRef.current = currentQuery;
const panelTypeRef = useRef(panelType);
panelTypeRef.current = panelType;
const onChangePanelKind = useCallback(
(newKind: PanelKind): void => {
const currentSpec = specRef.current;
const oldKind = currentSpec.plugin.kind as PanelKind;
if (newKind === oldKind) {
return;
}
const query = queryRef.current;
cacheRef.current.set(oldKind, {
pluginSpec: currentSpec.plugin.spec,
queries: currentSpec.queries,
builderQuery: query,
});
const newPanelType = PANEL_KIND_TO_PANEL_TYPE[newKind];
// Only `plugin` needs a cast: it's a discriminated union over `kind`, and a
// dynamically-chosen kind can't be correlated with its spec statically (as in
// `createDefaultPanel`). The surrounding spec stays fully typed.
const buildSpec = (
pluginSpec: DashboardtypesPanelPluginDTO['spec'] | SwitchedPluginSpec,
queries: DashboardtypesQueryDTO[],
): DashboardtypesPanelSpecDTO => ({
...currentSpec,
plugin: {
...currentSpec.plugin,
kind: newKind,
spec: pluginSpec,
} as DashboardtypesPanelPluginDTO,
queries,
});
// Revisit → restore the stash verbatim (the reversibility path).
const cached = cacheRef.current.get(newKind);
if (cached) {
setSpec(buildSpec(cached.pluginSpec, cached.queries));
redirectWithQueryBuilderData(cached.builderQuery);
return;
}
// First visit → coerce the query type if the new panel disallows it, then
// rebuild the builder query for the new type.
const supported = PANEL_TYPE_TO_QUERY_TYPES[newPanelType] ?? [];
const queryType = supported.includes(query.queryType)
? query.queryType
: supported[0];
const transformed = handleQueryChange(
newPanelType as keyof PartialPanelTypes,
{ ...query, queryType },
panelTypeRef.current,
);
const signal = getBuilderQueries(currentSpec.queries)[0]
?.signal as TelemetrytypesSignalDTO;
setSpec(
buildSpec(
getSwitchedPluginSpec(currentSpec, newKind, signal),
toPerses(transformed, newPanelType),
),
);
redirectWithQueryBuilderData(transformed);
},
[setSpec, redirectWithQueryBuilderData],
);
return { onChangePanelKind };
}

View File

@@ -29,6 +29,7 @@ import { usePanelQuery } from '../hooks/usePanelQuery';
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
import { usePanelEditorQuerySync } from './hooks/usePanelEditorQuerySync';
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
import { usePanelTypeSwitch } from './hooks/usePanelTypeSwitch';
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
import { useSwitchColumnsOnSignalChange } from './hooks/useSwitchColumnsOnSignalChange';
import { useTableColumns } from './hooks/useTableColumns';
@@ -113,6 +114,9 @@ function PanelEditorContainer({
signal: defaultSignal,
});
// Switch the panel's visualization kind in place (reversible per session).
const { onChangePanelKind } = usePanelTypeSwitch({ spec, panelType, setSpec });
// Spec and query dirtiness are tracked independently so query re-serialization
// never false-dirties. A new panel is always savable (you're creating it).
const isDirty = isNew || isSpecDirty || isQueryDirty;
@@ -121,8 +125,8 @@ function PanelEditorContainer({
// values; cast at this boundary (as ConfigPane does) so the columns editor's
// field-key lookup is typed.
const listSignal =
(getBuilderQueries(spec.queries || [])[0]
?.signal as TelemetrytypesSignalDTO) || TelemetrytypesSignalDTO.logs;
(getBuilderQueries(spec.queries)[0]?.signal as TelemetrytypesSignalDTO) ||
TelemetrytypesSignalDTO.logs;
// Swap the List panel's columns to the new signal's defaults on signal change
// (V1 had a per-signal field list; V2 has one `selectFields`).
@@ -226,6 +230,7 @@ function PanelEditorContainer({
panelKind={draft.spec.plugin.kind}
spec={spec}
onChangeSpec={setSpec}
onChangePanelKind={onChangePanelKind}
legendSeries={legendSeries}
tableColumns={tableColumns}
/>

View File

@@ -49,7 +49,7 @@ function BarPanelRenderer({
);
const builderQueries = useMemo(
() => getBuilderQueries(panel.spec.queries || []),
() => getBuilderQueries(panel.spec.queries),
[panel.spec.queries],
);

View File

@@ -1,12 +1,15 @@
import type { SectionConfig } from '../../types/sections';
import { SectionKind, type SectionConfig } from '../../types/sections';
// Bar stacking lives in `visualization.stackedBarChart`, so it's a `visualization`
// control, not `chartAppearance`. fillSpans is TimeSeries-only, so Bar omits it (V1 parity).
export const sections: SectionConfig[] = [
{ kind: 'visualization', controls: { timePreference: true, stacking: true } },
{ kind: 'formatting', controls: { unit: true, decimals: true } },
{ kind: 'axes', controls: { minMax: true, logScale: true } },
{ kind: 'legend', controls: { position: true } },
{ kind: 'thresholds', controls: { variant: 'label' } },
{ kind: 'contextLinks' },
{
kind: SectionKind.Visualization,
controls: { switchPanelKind: true, timePreference: true, stacking: true },
},
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
{ kind: SectionKind.Legend, controls: { position: true } },
{ kind: SectionKind.Thresholds, controls: { variant: 'label' } },
{ kind: SectionKind.ContextLinks },
];

View File

@@ -41,7 +41,7 @@ function HistogramPanelRenderer({
);
const builderQueries = useMemo(
() => getBuilderQueries(panel.spec.queries || []),
() => getBuilderQueries(panel.spec.queries),
[panel.spec.queries],
);

View File

@@ -1,10 +1,14 @@
import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import type { SectionConfig } from '../../types/sections';
import { SectionKind, type SectionConfig } from '../../types/sections';
export const sections: SectionConfig[] = [
{
kind: 'legend',
kind: SectionKind.Visualization,
controls: { switchPanelKind: true },
},
{
kind: SectionKind.Legend,
controls: { position: true },
// Merging all queries collapses to one distribution with no legend.
isHidden: (spec): boolean =>
@@ -14,8 +18,8 @@ export const sections: SectionConfig[] = [
),
},
{
kind: 'buckets',
kind: SectionKind.Buckets,
controls: { count: true, width: true, mergeQueries: true },
},
{ kind: 'contextLinks' },
{ kind: SectionKind.ContextLinks },
];

View File

@@ -52,7 +52,7 @@ function ListPanelRenderer({
// and row-click behavior. Cast is safe — the query carries the same string values.
const signal = useMemo(
() =>
(getBuilderQueries(panel.spec.queries || [])[0]
(getBuilderQueries(panel.spec.queries)[0]
?.signal as TelemetrytypesSignalDTO) || TelemetrytypesSignalDTO.logs,
[panel.spec.queries],
);

View File

@@ -20,7 +20,7 @@ function panelWith(
): PanelOfKind<'signoz/ListPanel'> {
return {
kind: 'Panel',
spec: { plugin: { kind: 'signoz/ListPanel', spec } },
spec: { plugin: { kind: 'signoz/ListPanel', spec }, queries: [] },
} as unknown as PanelOfKind<'signoz/ListPanel'>;
}

View File

@@ -1,3 +1,8 @@
import type { SectionConfig } from '../../types/sections';
import { SectionKind, type SectionConfig } from '../../types/sections';
export const sections: SectionConfig[] = [];
export const sections: SectionConfig[] = [
{
kind: SectionKind.Visualization,
controls: { switchPanelKind: true },
},
];

View File

@@ -1,8 +1,11 @@
import type { SectionConfig } from '../../types/sections';
import { SectionKind, type SectionConfig } from '../../types/sections';
export const sections: SectionConfig[] = [
{ kind: 'visualization', controls: { timePreference: true } },
{ kind: 'formatting', controls: { unit: true, decimals: true } },
{ kind: 'thresholds', controls: { variant: 'comparison' } },
{ kind: 'contextLinks' },
{
kind: SectionKind.Visualization,
controls: { switchPanelKind: true, timePreference: true },
},
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
{ kind: SectionKind.Thresholds, controls: { variant: 'comparison' } },
{ kind: SectionKind.ContextLinks },
];

View File

@@ -1,10 +1,13 @@
import type { SectionConfig } from '../../types/sections';
import { SectionKind, type SectionConfig } from '../../types/sections';
// Pie has no axes, thresholds, or stacking — just value formatting and a legend.
// Legend `colors` is omitted: the pie legend is always interactive swatches.
export const sections: SectionConfig[] = [
{ kind: 'visualization', controls: { timePreference: true } },
{ kind: 'formatting', controls: { unit: true, decimals: true } },
{ kind: 'legend', controls: { position: true } },
{ kind: 'contextLinks' },
{
kind: SectionKind.Visualization,
controls: { switchPanelKind: true, timePreference: true },
},
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
{ kind: SectionKind.Legend, controls: { position: true } },
{ kind: SectionKind.ContextLinks },
];

View File

@@ -1,11 +1,17 @@
import type { SectionConfig } from '../../types/sections';
import { SectionKind, type SectionConfig } from '../../types/sections';
// A table panel renders one scalar result (the V5 backend joins every query into a
// single column set). It exposes the per-panel time scope, formatting (decimals +
// per-column units), per-column thresholds, and context links.
export const sections: SectionConfig[] = [
{ kind: 'visualization', controls: { timePreference: true } },
{ kind: 'formatting', controls: { decimals: true, columnUnits: true } },
{ kind: 'thresholds', controls: { variant: 'table' } },
{ kind: 'contextLinks' },
{
kind: SectionKind.Visualization,
controls: { switchPanelKind: true, timePreference: true },
},
{
kind: SectionKind.Formatting,
controls: { decimals: true, columnUnits: true },
},
{ kind: SectionKind.Thresholds, controls: { variant: 'table' } },
{ kind: SectionKind.ContextLinks },
];

View File

@@ -49,7 +49,7 @@ function TimeSeriesPanelRenderer({
);
const builderQueries = useMemo(
() => getBuilderQueries(panel.spec.queries || []),
() => getBuilderQueries(panel.spec.queries),
[panel.spec.queries],
);

View File

@@ -1,12 +1,15 @@
import type { SectionConfig } from '../../types/sections';
import { SectionKind, type SectionConfig } from '../../types/sections';
export const sections: SectionConfig[] = [
{ kind: 'visualization', controls: { timePreference: true, fillSpans: true } },
{ kind: 'formatting', controls: { unit: true, decimals: true } },
{ kind: 'axes', controls: { minMax: true, logScale: true } },
{ kind: 'legend', controls: { position: true, colors: true } },
{
kind: 'chartAppearance',
kind: SectionKind.Visualization,
controls: { switchPanelKind: true, timePreference: true, fillSpans: true },
},
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
{ kind: SectionKind.Legend, controls: { position: true, colors: true } },
{
kind: SectionKind.ChartAppearance,
controls: {
lineStyle: true,
lineInterpolation: true,
@@ -15,6 +18,6 @@ export const sections: SectionConfig[] = [
spanGaps: true,
},
},
{ kind: 'thresholds', controls: { variant: 'label' } },
{ kind: 'contextLinks' },
{ kind: SectionKind.Thresholds, controls: { variant: 'label' } },
{ kind: SectionKind.ContextLinks },
];

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