Compare commits

..

24 Commits

Author SHA1 Message Date
grandwizard28
0d97f543df fix(alerts-downtime): capture load-time GETs before navigation
Flow 1 registered cap.mark() AFTER page.goto() and then called
page.waitForResponse(/api/v2/rules) — but against a fast local backend
the GET /api/v2/rules response arrived during page.goto, before the
waiter could register, and the test timed out at 30s.

installCapture's page.on('response') listener runs from before the
navigation, so moving mark() above page.goto() and relying on
dumpSince's 500ms drain is enough. No lost precision.

One site only; the same pattern exists in later flows (via per-action
waitForResponse) and may surface similar races — those are left for a
follow-up once the backend-side 2095 migration lands on main (current
frontend still calls PATCH /api/v1/rules/:id which the spec's assertion
doesn't match anyway).
2026-04-21 00:48:14 +05:30
grandwizard28
be7099b2b4 feat(tests/e2e): surface seeder_url to Playwright via globalSetup
- bootstrap/setup.py: test_setup now depends on the seeder fixture and
  writes seeder_url into .signoz-backend.json alongside base_url.
- bootstrap/run.py: test_e2e exports SIGNOZ_E2E_SEEDER_URL to the
  subprocessed yarn test so Playwright specs can reach the seeder
  directly in the one-command path.
- global.setup.ts: if .signoz-backend.json carries seeder_url, populate
  process.env.SIGNOZ_E2E_SEEDER_URL. Remains optional — staging mode
  leaves it unset.

Playwright specs that want per-test telemetry can:
  await fetch(process.env.SIGNOZ_E2E_SEEDER_URL + '/telemetry/traces', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify([...])
  });
and await a truncate via DELETE on teardown.
2026-04-21 00:47:58 +05:30
grandwizard28
ab6e8291fe feat(fixtures/seeder): HTTP seeder container for fine-grained telemetry seeding
Adds a sibling container alongside signoz/clickhouse/postgres that exposes
HTTP endpoints for direct-ClickHouse telemetry seeding, so Playwright
tests can shape per-test data without going through OTel or the SigNoz
ingestion path.

tests/fixtures/seeder/:
- Dockerfile: python:3.13-slim + the shared fixtures/ tree so the
  container can import fixtures.traces and reuse the exact insert path
  used by pytest.
- server.py: FastAPI app with GET /healthz, POST /telemetry/traces
  (accepts a JSON list matching Traces.from_dict input; auto-tags each
  inserted row with resource seeder=true), DELETE /telemetry/traces
  (truncates all traces tables).
- requirements.txt: fastapi, uvicorn, clickhouse-connect, numpy plus
  sqlalchemy/pytest/testcontainers because fixtures/{__init__,types,
  traces}.py import them at module load.

tests/fixtures/seeder/__init__.py: pytest fixture (`seeder`, package-
scoped) that builds the image via docker-py (testcontainers DockerImage
had multi-segment dockerfile issues), starts the container on the
shared network wired to ClickHouse via env vars, and waits for
/healthz. Cache key + restore follow the dev.wrap pattern other
fixtures use for --reuse.

tests/.dockerignore: exclude .venv, caches, e2e node_modules, and test
outputs so the build context is small and deterministic.

tests/conftest.py: register fixtures.seeder as a pytest plugin.

Currently traces-only — logs + metrics follow the same pattern.
2026-04-21 00:47:43 +05:30
grandwizard28
0839c532bc refactor(fixtures/traces): extract insert + truncate helpers
Pull the ClickHouse insert path out of the insert_traces pytest fixture
into a plain module-level function insert_traces_to_clickhouse(conn,
traces), and move the per-table TRUNCATE loop into truncate_traces_tables
(conn, cluster). The fixture becomes a thin wrapper over both — zero
behavioural change.

Lets the HTTP seeder container (tests/fixtures/seeder/) reuse the exact
same insert + truncate code the pytest fixture uses, so the two stay in
sync as the trace schema evolves.
2026-04-21 00:47:23 +05:30
grandwizard28
5ef206a666 feat(tests/e2e): alerts-downtime regression suite (platform-pod/issues/2095)
Import the 34-step regression suite originally developed on
platform-pod/issues/2095-frontend. Targets the alerts and planned-downtime
frontend flows after their migration to generated OpenAPI clients and
generated react-query hooks.

- specs/alerts-downtime/: SUITE.md (the stable spec), README.md (scope +
  open observations from the original runs), results-schema.md (legacy
  per-run artifact shape, retained for context).
- tests/alerts-downtime/alerts-downtime.spec.ts: 881-line Playwright spec
  covering 6 flows — alert CRUD/toggle, alert detail 404, planned
  downtime CRUD, notification channel routing, anomaly alerts.

Integration with the shared suite:
- Uses baseURL + storageState from tests/e2e/playwright.config.ts (no
  separate config). page.goto calls use relative paths; SIGNOZ_E2E_*
  env vars from the pytest bootstrap drive auth.
- test.describe.configure({ mode: 'serial' }) at the top of the describe:
  the flows mutate shared tenant state, so parallel runs cause cross-
  flow interference (documented in the original 2095 config).
- Per-run artifacts (network captures + screenshots) land in
  tests/e2e/tests/alerts-downtime/run-spec-<ts>/ by default — gitignored.

Historical per-run artifacts (~7.5MB of screenshots across run-1 through
run-7) are not imported; they lived at e2e/2095/run-*/ on the original
branch and remain there if needed.
2026-04-20 23:34:12 +05:30
grandwizard28
fce92115a9 fix(tests/fixtures/signoz.py): anchor Docker build context to repo root
Previously used path="../../" which resolved to the repo root only when
pytest's cwd was tests/integration/. After hoisting the pytest project
to tests/, that same relative path pointed one level above the repo
root and the build failed with:

  Cannot locate specified Dockerfile: cmd/enterprise/Dockerfile.with-web.integration

Anchor the build context to an absolute path computed from __file__ so
the fixture works regardless of pytest cwd.
2026-04-20 21:45:27 +05:30
grandwizard28
9743002edf docs(tests): describe pytest-master workflow and shared fixture layout
- tests/README.md (new): top-level map of the shared pytest project,
  fixture-ownership rule (shared vs per-tree), and common commands.
- tests/e2e/README.md: lead with the one-command pytest run and the
  warm-backend dev loop; keep the staging fallback as option 2.
- tests/e2e/CLAUDE.md: updated commands so agent contexts reflect the
  pytest-driven lifecycle.
- tests/e2e/.env.example: drop unused SIGNOZ_E2E_ENV_TYPE; note the file
  is only needed for staging mode.
2026-04-20 21:06:32 +05:30
grandwizard28
0efde7b5ce feat(tests/e2e): pytest-driven backend bring-up, seeding, and playwright runner
Wire the Playwright suite into the shared pytest fixture graph so the
backend + its seeded state are provisioned locally instead of pointing
at remote staging.

Python side (owns lifecycle):
- tests/fixtures/dashboards.py — generic create/list/upsert_dashboard
  helpers (shared infra; testdata stays per-tree).
- tests/e2e/conftest.py — e2e-scoped pytest fixtures: seed_dashboards
  (idempotent upsert from tests/e2e/testdata/dashboards/*.json),
  seed_alert_rules (from tests/e2e/testdata/alerts/*.json, via existing
  create_alert_rule), seed_e2e_telemetry (fresh traces/logs across a
  few synthetic services so /home and Services pages have data).
- tests/e2e/src/bootstrap/setup.py — test_setup depends on the fixture
  graph and persists backend coordinates to tests/e2e/.signoz-backend.json;
  test_teardown is the --teardown target.
- tests/e2e/src/bootstrap/run.py — test_e2e: one-command entrypoint that
  brings up the backend + seeds, then subprocesses yarn test and asserts
  Playwright exits 0.
- tests/conftest.py — register fixtures.dashboards plugin.

Playwright side (just reads):
- tests/e2e/global.setup.ts — loads .signoz-backend.json and injects
  SIGNOZ_E2E_BASE_URL/USERNAME/PASSWORD. No-op when env is already
  populated (staging mode, or pytest-driven runs where env is pre-set).
- playwright.config.ts registers globalSetup.
- package.json gains test:staging; existing scripts unchanged.

Testdata layout: tests/e2e/testdata/{dashboards,alerts,channels}/*.json
— per-tree (integration has its own tests/integration/testdata/).
2026-04-20 21:03:52 +05:30
grandwizard28
8bdaecbe25 feat(tests/e2e): import Playwright suite from signoz-e2e
Relocate the standalone signoz-e2e repository into tests/e2e/ as a
sibling of tests/integration/. The suite still points at remote
staging by default; subsequent commits wire it to the shared pytest
fixture graph so the backend can be provisioned locally.

Excluded from the import: .git, .github (CI migration deferred),
.auth, node_modules, test-results, playwright-report.
2026-04-20 20:40:02 +05:30
grandwizard28
deb90abd9c refactor(tests): hoist pytest project to tests/ root for shared fixtures
Lift pyproject.toml, uv.lock, conftest.py, and fixtures/ up from
tests/integration/ so the pytest project becomes shared infrastructure
rather than integration's private property. A sibling tests/e2e/ can
reuse the same fixture graph (containers, auth, seeding) without
duplicating plugins.

Also:
- Merge tests/integration/src/querier/util.py into tests/fixtures/querier.py
  (response assertions and corrupt-metadata generators belong with the
  other querier helpers).
- Use --import-mode=importlib + pythonpath=["."] in pyproject so
  same-basename tests across src/*/ do not collide at the now-wider
  rootdir.
- Broaden python_files to "*/src/**/**.py" so future test trees under
  tests/e2e/src/ get discovered.
- Update Makefile py-* targets and integrationci.yaml to cd into tests/
  and reference integration/src/... paths.
2026-04-20 20:39:16 +05:30
Pandey
52992c0e80 chore(switch): switch for some time to @therealpandey (#11017) 2026-04-20 13:29:03 +00:00
Yunus M
5d6ada7a5b fix: semantic token issues in aws refactor (#11014)
* fix: semantic token issues in aws refactor

* fix: semantic token issues in aws refactor

* chore: remove unnecessary light mode styles
2026-04-20 12:34:25 +00:00
Nikhil Soni
dbe55d4ae0 chore: remove setting of trace cache since it was not getting used (#10986)
* chore: remove caching spans since v2 was not using it

So we can directly introduce redis instead of relying
on in-memory cache

* chore: remove unnecessary logs
2026-04-20 11:11:12 +00:00
Yunus M
837da705b3 refactor: aws integrations (#10937)
* chore: clean up integrations code for better code organisation and extensibility

* feat: render integration in new route

* refactor: reorganize AWS integration components and update imports

- Moved AWS-related components to a new directory structure for better organization.
- Updated import paths to reflect the new structure.
- Removed unused components and styles related to the previous integration setup.
- Adjusted constants and integration logic to ensure compatibility with the new structure.

* feat: enhance IntegrationDetailHeader with loading state and styles

* feat: improve light mode styles

* feat: improve light mode styles

* feat: add new Azure integration components and update existing ones

* refactor: update integration types and improve imports

* refactor: update integration types and improve imports

* feat: integrate azure account connect / edit APIs

* feat: integrate service update api

* fix: sorting logic for enabled and not enabled services

* fix: aws integration - minor ui improvements

* feat: add search functionality and no results UI for integrations

* feat: integrate disconnect integration api

* fix: update integrations util path to fix test case

* chore: move cursor rules to folder to follow the current format

* chore: remove cursor rules from gitignore

* chore: skip request integration service test in aws

* fix: show scrollbar in drawer for overflowing content

* fix: selected service getting reset on config update

* feat: use semantic tokens

* feat: update aws integrations as per new design

* refactor: enhance AWS service details and list UI with loading states and improved layout

* refactor: remove unused AWS service components and update connection status handling

* feat: add S3BucketsSelector component and integrate it into ServiceDetails

* feat: implement ServiceDetails for S3 Sync with comprehensive tests and mock data

* feat: maintain width of save - discard buttons

* feat: add react-hook-form for form handling in ServiceDetails and enhance S3BucketsSelector styles

* chore: downgrade react-hook-form to version 7.40.0 in package.json and update yarn.lock

* feat: enhance AzureAccountForm with react-hook-form integration and improve styling for form

* feat: refactor S3 Sync service tests to remove unnecessary act calls and add ResizeObserver mock

* chore: add @uiw/codemirror-theme-dracula theme

* fix: use copyToClipboard instead of navigator clipboard

* refactor: update cloud integration API types

* refactor: simplify service selection logic and update dashboard URL path

* refactor: remove Azure integrations files

* feat: add providerAccountId to AWS cloud account mapping and update related components

* feat: enhance AWS services list with empty state UI and improve connection handling

* fix: use new account invalidation method and correct region selection logic

* refactor: update mock data and API response structures for AWS integration tests

* fix: do not call connection_status for aws

* fix: clear s3 buckets if log collection is false

* refactor: improve AWS cloud account mapping and clean up unused code in account settings modal

* refactor: remove unused account services and status hooks

* refactor: remove AWS API integration and related hooks

* refactor: remove unused api and components

* refactor: remove duplicate files

* refactor: remove unused codemirror theme

* refactor: remove unused Azure account configuration interfaces

* feat: update image imports in ServicesList and IntegrationsList components

* refactor: update image imports to use URL constructor and unify toast imports from @signozhq/ui

* refactor: update integration icons to use imported assets for AWS and Azure logos

* fix: use semantic tokens

* fix: use semantic tokens

* fix: format style files

* refactor: remove unused SVG and test files, update styles and structure in Integrations components
2026-04-20 11:09:13 +00:00
swapnil-signoz
a9b458f1f6 refactor: moving types to cloud provider specific namespace/pkg (#10976)
* refactor: moving types to cloud provider specific namespace/pkg

* refactor: separating cloud provider types

* refactor: using upper case key for AWS
2026-04-20 10:42:13 +00:00
Naman Verma
ac26299c3d docs: perses schema for dashboards (#10609)
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
* docs: perses schema for dashboards

* chore: no need for Signal type in commons, only used once

* chore: no need for PageSize type in commons, only used once

* chore: rm comment

* chore: remove stub for time series chart

* chore: remove manually written manifest and package

* chore: remove validate file

* chore: no config folder

* chore: no config folder

* chore: no commons (for now)

* feat: validation script

* fix: remove fields from variable specs that are there in ListVariable

* chore: test file with way more examples

* chore: test file with way more examples

* chore: checkpoint for half correct setup

* chore: rearrange specs in package.json

* chore: py script not needed

* chore: rename

* chore: folders in schemas for arranging

* chore: folders in schemas for arranging

* fix: proper composite query schema

* feat: custom time series schema

* chore: comment explaining when to use composite query and when not

* feat: promql example

* chore: remove upstream import

* fix: promql fix

* docs: time series panel schema without upstream ref

* chore: object for visualization section

* docs: bar chart panel schema without upstream ref

* docs: number panel schema without upstream ref

* docs: number panel schema without upstream ref

* docs: pie chart panel schema without upstream ref

* docs: table chart panel schema without upstream ref

* docs: histogram chart panel schema without upstream ref

* docs: list panel schema without upstream ref

* chore: a more complex example

* chore: examples for panel types

* chore: remaining fields file

* fix: no more online validation

* chore: replace yAxisUnit by unit

* chore: no need for threshold prefix inside threshold obj

* chore: remove unimplemented join query schema

* fix: no nesting in context links

* fix: less verbose field names in dynamic var

* chore: actually name every panel as a panel

* chore: common package for panels' repeated definitions

* chore: common package for queries' repeated definitions

* chore: common package for variables' repeated definitions

* fix: functions in formula

* fix: only allow one of metric or expr aggregation in builder query

* fix: datasource in perses.json

* fix: promql step duration schema

* fix: proper type for selectFields

* chore: single version for all schemas

* fix: normalise enum defs

* chore: change attr name to name

* chore: common threshold type

* chore: doc for how to add a panel spec

* feat: textbox variable

* feat: go struct based schema for dashboardv2 with validations and some tests

* fix: go mod fix

* chore: perses folder not needed anymore

* chore: use perses updated/createdat

* fix: builder query validation (might need to revisit, 3 types seems bad)

* chore: go lint fixes

* chore: define constants for enum values

* chore: nil factory case not needed

* chore: nil factory case not needed

* chore: slight rearrange for builder spec readability

* feat: add TimeSeriesChartAppearance

* chore: no omit empty

* chore: span gaps in schema

* chore: context link not needed in plugins

* chore: remove format from threshold with label, rearrange structs

* test: fix unit tests

* chore: refer to common struct

* feat: query type and panel type matching

* test: unit tests improvement first pass

* test: unit tests improvement second pass

* test: unit tests improvement third pass

* test: unit tests improvement fourth pass

* test: unit test for dashboard with sections

* test: unit test for dashboard with sections

* fix: add missing dashboard metadata fields

* chore: go lint fixes

* chore: go lint fixes

* chore: changes for create v2 api

* chore: more info in StorableDashboardDataV2

* chore: diff check in update method

* chore: add required true tag to required fields

* feat: update metadata methods

* chore: go mod tidy

* chore: put id in metadata.name, authtypes for v2

* revert: only the schema for now in this PR

* chore: comment for why v1.DashboardSpec is chosen

* chore: change source to signal in DynamicVariableSpec

* fix: string values for precision option

* feat: literal options for comparison operator

* fix: missing required tag in threshold fields

* chore: use valuer.string for plugin kind enums

* chore: use only TelemetryFieldKey in ListPanelSpec

* chore: simplify variable plugin validation

* fix: do not allow nil panels

* fix: do not allow nil plugin spec

* fix: signal should be an enum not a string

* chore: rearrange enums to separate those with default values

* test: unit tests for invalid enum values

* fix: all enums should have a default value

* refactor: extract UnmarshalBuilderQueryBySignal to deduplicate signal dispatch

* refactor: proper struct for span gaps

* chore: back to normal strings for kind enums

* chore: ticks in err messages

* chore: ticks in err messages

* chore: remove unused struct

* chore: snake case for non-kind enum values

* chore: proper error wrapping

* chore: accept int values in PrecisionOption as fallback

* fix: actually update the plugin from map to custom struct

* feat: disallow unknown fields in plugins

* chore: make enums valuer.string

* chore: proper enum types in constants

* chore: rename value to avoid overriding valuer.string method

* test: db cycle test

* fix: lint fix in some other file

* test: remove collapse info from sections

* test: use testify package

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-04-20 09:29:03 +00:00
Nityananda Gohain
96d816bd1a feat: improve perf for queries with empty resource filter (#10861)
* feat: improve perf for queries with empty resource filter

* fix: update comment

* fix: add nolint:nilnil

* fix: address comments

* fix: update comment

---------

Co-authored-by: Ankit Nayan <ankit@signoz.io>
2026-04-20 03:30:16 +00:00
Pandey
f52d89c338 docs(go): add types.md covering core-type and Postable/Gettable/Storable conventions (#10998)
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
* docs(go): add types.md covering core-type and Postable/Gettable/Storable conventions

Document the core-type-first pattern used across pkg/types/: always define
the domain type X, and introduce Postable/Gettable/Updatable/Storable
flavors only when their shape actually differs from X. Walk through
Channel (core only), AuthDomain (all four flavors), and Rule (the
in-progress v2 split) as worked examples.

* docs(go): drop Rule migration example from types.md

* docs(go): allow both NewFromX and ToX conversion forms in types.md

* docs(go): move validation guidance to core type X in types.md

* docs(go): drop exact file:line refs in types.md, use inline examples

* docs(go): split spelling guidance into Updatable and Storable bullets

* docs(go): trim conversion examples to Channel and AuthDomain

* docs(go): swap remaining examples to AuthDomain/Channel where possible
2026-04-19 16:35:47 +00:00
aniketio-ctrl
691e919a41 feat(billing): add zeus put meters api (#10923)
* feat(billing): add zeus put meters api

* feat(billing): add zeus put meters api
2026-04-19 11:30:09 +00:00
Pandey
61ae49d4ab refactor(ruler): introduce v2 Rule read type and validate uuid on delete (#10997)
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
* refactor(ruler): add Rule v2 read type and rename storage Rule to StorableRule

* refactor(ruler): map GettableRule to Rule before responding on v2 routes

* docs(openapi): regenerate spec with RuletypesRule on v2 rules routes

* docs(frontend): regenerate API clients with RuletypesRuleDTO

* refactor(ruler): validate uuid-v7 on delete rule handler
2026-04-18 12:39:50 +00:00
Pandey
a5e1f71cf6 refactor(ruler): tighten rule and downtime OpenAPI schema (#10995)
* refactor(ruler): add Enum() on AlertType

* refactor(ruler): convert RepeatType and RepeatOn to valuer.String with Enum()

* refactor(ruler): mark required fields on Recurrence

* refactor(ruler): mark required tag on CumulativeSchedule.Type

* refactor(ruler): rename GettablePlannedMaintenance to PlannedMaintenance

* docs: regenerate OpenAPI spec and frontend clients with tightened schema

* refactor(ruler): add PostablePlannedMaintenance input type with Validate

* refactor(ruler): rename EditPlannedMaintenance to Update and GetAll to List

* refactor(ruler): switch Create/Update to *PostablePlannedMaintenance

* refactor(ruler): convert PlannedMaintenance.Id string to ID valuer.UUID

* refactor(ruler): return *PlannedMaintenance from CreatePlannedMaintenance

* docs: regenerate OpenAPI spec and frontend clients for Postable/ID changes

* refactor(ruler): type PlannedMaintenance.Status as MaintenanceStatus enum

* refactor(ruler): type PlannedMaintenance.Kind as MaintenanceKind enum

* refactor(ruler): mark GettableRule.Id required

* refactor(ruler): mark GettableRule.State required

* refactor(ruler): make GettableRule timestamps non-pointer and users nullable

* refactor(ruler): return bare array from v2 ListRules instead of wrapped object

* docs: regenerate OpenAPI spec and frontend clients for schema pass
2026-04-18 09:46:07 +00:00
Pandey
c9610df66d refactor(ruler): move rules and planned maintenance handlers to signozapiserver (#10957)
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
* refactor(ruler): define Ruler and Handler interfaces with signozruler implementation

Expand the Ruler interface with rule management and planned maintenance
methods matching rules.Manager signatures. Add Handler interface for
HTTP endpoints. Implement handler in signozruler wrapping ruler.Ruler,
and update provider to embed *rules.Manager for interface satisfaction.

* refactor(ruler): move eval_delay from query-service constants to ruler config

Replace constants.GetEvalDelay() with config.EvalDelay on ruler.Config,
defaulting to 2m. This removes the signozruler dependency on
pkg/query-service/constants.

* refactor(ruler): use time.Duration for eval_delay config

Match the convention used by all other configs in the codebase.
TextDuration is for preserving human-readable text through JSON
round-trips in user-facing rule definitions, not for internal config.

* refactor(ruler): add godoc comments and spacing to Ruler interface

* refactor(ruler): wire ruler handler through signoz.New and signozapiserver

- Add Start/Stop to Ruler interface for lifecycle management
- Add rulerCallback to signoz.New() for EE customization
- Wire ruler.Handler through Handlers, signozapiserver provider
- Register 12 routes in signozapiserver/ruler.go (7 rules, 5 downtime)
- Update cmd/community and cmd/enterprise to pass rulerCallback
- Move rules.Manager creation from server.go to signoz.New via callback
- Change APIHandler.ruleManager type from *rules.Manager to ruler.Ruler
- Remove makeRulesManager from both OSS and EE server.go

* refactor(ruler): remove old rules and downtime_schedules routes from http_handler

Remove 7 rules CRUD routes and 5 downtime_schedules routes plus their
handler methods from http_handler.go. These are now served by
signozapiserver/ruler.go via handler.New() with OpenAPIDef.

The 4 v1 history routes (stats, timeline, top_contributors,
overall_status) remain in http_handler.go as they depend on
interfaces.Reader and have v2 equivalents already in signozapiserver.

* refactor(ruler): use ProviderFactory pattern and register in factory.Registry

Replace the rulerCallback with rulerProviderFactories following the
standard ProviderFactory pattern (like auditorProviderFactories). The
ruler is now created via factory.NewProviderFromNamedMap and registered
in factory.Registry for lifecycle management. Start/Stop are no longer
called manually in server.go.

- Ruler interface embeds factory.Service (Start/Stop return error)
- signozruler.NewFactory accepts all deps including EE task funcs
- provider uses named field (not embedding) with explicit delegation
- cmd/community passes nil task funcs, cmd/enterprise passes EE funcs
- Remove NewRulerProviderFactories (replaced by callback from cmd/)
- Remove manual Start/Stop from both OSS and EE server.go

* fix(ruler): make Start block on stopC per factory.Service contract

rules.Manager.Start is non-blocking (run() just closes a channel).
Add stopC to provider so Start blocks until Stop closes it, matching
the factory.Service contract used by the Registry.

* refactor(ruler): remove unused RM() accessor from EE APIHandler

* refactor(ruler): remove RuleManager from APIHandlerOpts

Use Signoz.Ruler directly instead of passing it through opts.

* refactor(ruler): add /api/v1/rules/test and mark /api/v1/testRule as deprecated

* refactor(ruler): use binding.JSON.BindBody for downtime schedule decode

* refactor(ruler): add TODOs for raw string params on Ruler interface

Mark CreateRule, EditRule, PatchRule, TestNotification, and DeleteRule
with TODOs to accept typed params instead of raw JSON strings. Requires
changing the storage model since the manager stores raw JSON as Data.

* refactor(ruler): add TODO on MaintenanceStore to not expose store directly

* docs: regenerate OpenAPI spec and frontend API clients with ruler routes

* refactor(ruler): rename downtime_schedules tag to downtimeschedules

* refactor(ruler): add query params to ListDowntimeSchedules OpenAPIDef

Add ListPlannedMaintenanceParams struct with active/recurring fields.
Use binding.Query.BindQuery in the handler instead of raw URL parsing.
Add RequestQuery to the OpenAPIDef so params appear in the OpenAPI spec
and generated frontend client.

* refactor(ruler): add GettableTestRule response type to TestRule endpoint

Define GettableTestRule struct with AlertCount and Message fields.
Use it as the Response in TestRule OpenAPIDef so the generated frontend
client has a proper response type instead of string.

* refactor(ruler): tighten schema with oneOf unions and required fields

Surface the polymorphism in RuleThresholdData and EvaluationEnvelope via
JSONSchemaOneOf (the same pattern as QueryEnvelope), so the generated
TS types are discriminated unions with typed `spec` instead of unknown.
Also mark `alert`, `ruleType`, and `condition` required on PostableRule
so the generated TS types are non-optional for callers.

* refactor(ruler): add Enum() on EvaluationKind, ScheduleType, ThresholdKind

Surface the fixed set of accepted values for these valuer-wrapped kind
types so OpenAPI emits proper string-enum schemas and the generated TS
types become string-literal unions instead of plain string.

* refactor(ruler): mark required fields on nested rule and maintenance types

Surface fields already enforced by Validate()/UnmarshalJSON as required
in the OpenAPI schema so the generated TS types match runtime behavior.

Touches RuleCondition (compositeQuery, op, matchType), RuleThresholdData
(kind, spec), BasicRuleThreshold (name, target, op, matchType),
RollingWindow (evalWindow, frequency), CumulativeWindow (schedule,
frequency, timezone), EvaluationEnvelope (kind, spec), Schedule
(timezone), GettablePlannedMaintenance (name, schedule).

Does not mark server-populated fields (id, createdAt, updatedAt, status,
kind) on GettablePlannedMaintenance required, since the same struct is
reused for request bodies in MaintenanceStore.CreatePlannedMaintenance.

* refactor(ruler): tighten AlertCompositeQuery, QueryType, PanelType schema

Missed in the earlier tightening pass. AlertCompositeQuery.queries,
panelType, queryType are all required for a valid composite query;
QueryType and PanelType are valuer-wrapped with fixed value sets, so
expose them as enums in the OpenAPI schema.

* refactor(ruler): wrap sql.ErrNoRows as TypeNotFound in by-ID lookups

GetStoredRule and GetPlannedMaintenanceByID previously returned bun's
raw Scan error, so a missing ID leaked "sql: no rows in result set" to
the HTTP response with a 500 status. WrapNotFoundErrf converts
sql.ErrNoRows into TypeNotFound so render.Error emits 404 with a stable
`not_found` code, and passes other errors through unchanged.

* refactor(ruler): move migrated rules routes to /api/v2/rules

The 7 rules routes now live at /api/v2/rules, /api/v2/rules/{id}, and
/api/v2/rules/test — served via handler.New with render.Success and
render.Error. The legacy /api/v1/rules paths will be restored in the
query-service http handler in a follow-up so existing clients keep
receiving the SuccessResponse envelope unchanged.

Drop the /api/v1/testRule deprecated alias from signozapiserver; the
original lives on main's http_handler.go and is restored alongside the
other v1 paths.

Downtime schedule routes stay at /api/v1/downtime_schedules — single
track, no legacy restore planned.

* refactor(ruler): restore /api/v1/rules legacy handlers for back-compat

Bring the 7 rule CRUD/test handlers and their router.HandleFunc lines
back to http_handler.go so /api/v1/rules, /api/v1/rules/{id}, and
/api/v1/testRule continue to emit the legacy SuccessResponse envelope.
The v2 versions under signozapiserver are the new home for the render
envelope used by generated clients.

Delegation uses aH.ruleManager (populated from opts.Signoz.Ruler in
NewAPIHandler), so a single ruler.Ruler instance serves both paths — no
second rules.Manager is instantiated.

Downtime schedules stay single-track under signozapiserver; the 5
downtime handlers are not restored.

* docs: regenerate OpenAPI spec and frontend clients for /api/v2/rules

* refactor(ruler): return 201 Created on POST /api/v2/rules

A successful create now responds with 201 Created and the full
GettableRule body, matching REST convention for resource creation.
Regenerates the OpenAPI spec and frontend clients to reflect the new
status code.

* refactor(ruler): restore dropped sorter TODO in legacy listRules

The legacy listRules handler was copied verbatim from main during the
v1 back-compat restore, but an inner blank line and the load-bearing
`// todo(amol): need to add sorter` comment were stripped. Put them
back so the legacy block round-trips cleanly against main.

* refactor(ruler): return 201 Created on POST /api/v1/downtime_schedules

Match the REST convention already applied to POST /api/v2/rules:
successful creates respond with 201 Created. Response body remains
empty (nil); the generated frontend client surface is unchanged since
no response type was declared.

A richer "return the created resource" response body is a separate
follow-up — holding off until the ruletypes naming cleanup lands.

* fix(ruler): signal Healthy only after manager.Start closes m.block

The ruler provider didn't implement factory.Healthy, so the registry
fell back to factory.closedC and marked the service StateRunning the
instant its Start goroutine spawned — before rules.Manager.Start had
closed m.block. /api/v2/healthz therefore returned 200 while rule
evaluation was still gated, and integration tests that POSTed a rule
immediately after the readiness check saw their task goroutines stuck
on <-m.block until the next frequency tick.

Add a healthyC channel and close it inside Start only after
manager.Start returns; implement factory.Healthy so the registry and
/api/v2/healthz wait on the real readiness signal.

* fix: add the withhealthy interface

* fix(ruler): alias legacy RULES_EVAL_DELAY env var in backward-compat

The eval_delay config was moved from query-service constants (read from
RULES_EVAL_DELAY) onto ruler.Config (read via mapstructure from
SIGNOZ_RULER_EVAL__DELAY). That silently broke the legacy env var for
any existing deployment — notably the alerts integration-test fixture
which sets RULES_EVAL_DELAY=0s to let rules evaluate against just-
inserted data. The resulting default 2m delay pushed the query window
far enough back that the fixture's rate spike fell outside it, causing
8 of 24 parametrize cases in 02_basic_alert_conditions.py to fail with
"Expected N alerts to be fired but got 0 alerts".

Add RULES_EVAL_DELAY to mergeAndEnsureBackwardCompatibility alongside
the ~10 other aliased legacy env vars. Emits the standard deprecation
warning and overrides config.Ruler.EvalDelay.
2026-04-18 08:25:16 +00:00
Pandey
ef298af388 feat(apiserver): derive HTTP route prefix from global.external_url (#10943)
* feat(apiserver): derive HTTP route prefix from global.external_url

The path component of global.external_url is now used as the base path
for all HTTP routes (API and web frontend), enabling SigNoz to be served
behind a reverse proxy at a sub-path (e.g. https://example.com/signoz/).

The prefix is applied via http.StripPrefix at the outermost handler
level, requiring zero changes to route registration code. Health
endpoints (/api/v1/health, /api/v2/healthz, /api/v2/readyz,
/api/v2/livez) remain accessible without the prefix for container
healthchecks.

Removes web.prefix config in favor of the unified global.external_url
approach, avoiding the desync bugs seen in projects with separate
API/UI prefix configs (ArgoCD, Prometheus).

closes SigNoz/platform-pod#1775

* feat(web): template index.html with dynamic base href from global.external_url

Read index.html at startup, parse as Go template with [[ ]] delimiters,
execute with BasePath derived from global.external_url, and cache the
rendered bytes in memory. This injects <base href="/signoz/" /> (or
whatever the route prefix is) so the browser resolves relative URLs
correctly when SigNoz is served at a sub-path.

Inject global.Config into the routerweb provider via the factory closure
pattern. Static files (JS, CSS, images) are still served from disk
unchanged.

* refactor(web): extract index.html templating into web.NewIndex

Move the template parsing and execution logic from routerweb provider
into pkg/web/template.go. NewIndex logs and returns raw bytes on
template failure; NewIndexE returns the error for callers that need it.

Rename BasePath to BaseHref to match the HTML attribute it populates.
Inject global.Config into routerweb via the factory closure pattern.

* refactor(global): rename RoutePrefix to ExternalPath, add ExternalPathTrailing

Rename RoutePrefix() to ExternalPath() to accurately reflect what it
returns: the path component of the external URL. Add
ExternalPathTrailing() which returns the path with a trailing slash,
used for HTML base href injection.

* refactor(web): make index filename configurable via web.index

Move the hardcoded indexFileName const from routerweb/provider.go to
web.Config.Index with default "index.html". This allows overriding the
SPA entrypoint file via configuration.

* refactor(web): collapse testdata_basepath into testdata

Use a single testdata directory with a templated index.html for all
routerweb tests. Remove the redundant testdata_basepath directory.

* test(web): add no-template and invalid-template index test cases

Add three distinct index fixtures in testdata:
- index.html: correct [[ ]] template with BaseHref
- index_no_template.html: plain HTML, no placeholders
- index_invalid_template.html: malformed template syntax

Tests verify: template substitution works, plain files pass through
unchanged, and invalid templates fall back to serving raw bytes.
Consolidate test helpers into startServer/get.

* refactor(web): rename test fixtures to no_template, valid_template, invalid_template

Drop the index_ prefix from test fixtures. Use web instead of w for
the variable name in test helpers.

* test(web): add SPA fallback paths to no_template and invalid_template tests

Test /, /does-not-exist, and /assets in all three template test cases
to verify SPA fallback behavior (non-existent paths and directories
serve the index) regardless of template type.

* test(web): use exact match instead of contains in template tests

Match the full expected response body in TestServeTemplatedIndex
instead of using assert.Contains.

* style(web): use raw string literals for expected test values

* refactor(web): rename get test helper to httpGet

* refactor(web): use table-driven tests with named path cases

Replace for-loop path iteration with explicit table-driven test cases
for each path. Each path (root, non-existent, directory) is a named
subtest case in all three template tests.

* chore: remove redundant comments from added code

* style: add blank lines between logical blocks

* fix(web): resolve lint errors in provider and template

Fix errcheck on rw.Write in serveIndex, use ErrorContext instead of
Error in NewIndex for sloglint compliance. Move serveIndex below
ServeHTTP to order public methods before private ones.

* style: formatting and test cleanup from review

Restructure Validate nil check, rename expectErr to fail with
early-return, trim trailing newlines in test assertions, remove
t.Parallel from subtests, inline short config literals, restore
struct field comments in web.Config.

* fix: remove unused files

* fix: remove unused files

* perf(web): cache http.FileServer on provider instead of creating per-request

* refactor(web): use html/template for context-aware escaping in index rendering

---------

Co-authored-by: SagarRajput-7 <162284829+SagarRajput-7@users.noreply.github.com>
2026-04-18 06:47:17 +00:00
Yunus M
b5c146afdf chore: update @signozhq packages and adjust styles in AuthHeader and AnnouncementTooltip components (#10989)
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
2026-04-17 14:41:22 +00:00
379 changed files with 31744 additions and 59842 deletions

64
.github/CODEOWNERS vendored
View File

@@ -16,38 +16,38 @@ go.mod @therealpandey
# Scaffold Owners
/pkg/config/ @vikrantgupta25
/pkg/errors/ @vikrantgupta25
/pkg/factory/ @vikrantgupta25
/pkg/types/ @vikrantgupta25
/pkg/valuer/ @vikrantgupta25
/cmd/ @vikrantgupta25
.golangci.yml @vikrantgupta25
/pkg/config/ @therealpandey
/pkg/errors/ @therealpandey
/pkg/factory/ @therealpandey
/pkg/types/ @therealpandey
/pkg/valuer/ @therealpandey
/cmd/ @therealpandey
.golangci.yml @therealpandey
# Zeus Owners
/pkg/zeus/ @vikrantgupta25
/ee/zeus/ @vikrantgupta25
/pkg/licensing/ @vikrantgupta25
/ee/licensing/ @vikrantgupta25
/pkg/zeus/ @therealpandey
/ee/zeus/ @therealpandey
/pkg/licensing/ @therealpandey
/ee/licensing/ @therealpandey
# SQL Owners
/pkg/sqlmigration/ @vikrantgupta25
/ee/sqlmigration/ @vikrantgupta25
/pkg/sqlschema/ @vikrantgupta25
/ee/sqlschema/ @vikrantgupta25
/pkg/sqlmigration/ @therealpandey
/ee/sqlmigration/ @therealpandey
/pkg/sqlschema/ @therealpandey
/ee/sqlschema/ @therealpandey
# Analytics Owners
/pkg/analytics/ @vikrantgupta25
/pkg/statsreporter/ @vikrantgupta25
/pkg/analytics/ @therealpandey
/pkg/statsreporter/ @therealpandey
# Emailing Owners
/pkg/emailing/ @vikrantgupta25
/pkg/types/emailtypes/ @vikrantgupta25
/templates/email/ @vikrantgupta25
/pkg/emailing/ @therealpandey
/pkg/types/emailtypes/ @therealpandey
/templates/email/ @therealpandey
# Querier Owners
@@ -97,23 +97,23 @@ go.mod @therealpandey
# AuthN / AuthZ Owners
/pkg/authz/ @vikrantgupta25
/ee/authz/ @vikrantgupta25
/pkg/authn/ @vikrantgupta25
/ee/authn/ @vikrantgupta25
/pkg/modules/user/ @vikrantgupta25
/pkg/modules/session/ @vikrantgupta25
/pkg/modules/organization/ @vikrantgupta25
/pkg/modules/authdomain/ @vikrantgupta25
/pkg/modules/role/ @vikrantgupta25
/pkg/authz/ @therealpandey
/ee/authz/ @therealpandey
/pkg/authn/ @therealpandey
/ee/authn/ @therealpandey
/pkg/modules/user/ @therealpandey
/pkg/modules/session/ @therealpandey
/pkg/modules/organization/ @therealpandey
/pkg/modules/authdomain/ @therealpandey
/pkg/modules/role/ @therealpandey
# IdentN Owners
/pkg/identn/ @vikrantgupta25
/pkg/http/middleware/identn.go @vikrantgupta25
/pkg/identn/ @therealpandey
/pkg/http/middleware/identn.go @therealpandey
# Integration tests
/tests/integration/ @vikrantgupta25
/tests/integration/ @therealpandey
# OpenAPI types generator

View File

@@ -25,11 +25,11 @@ jobs:
uses: astral-sh/setup-uv@v4
- name: install
run: |
cd tests/integration && uv sync
cd tests && uv sync
- name: fmt
run: |
make py-fmt
git diff --exit-code -- tests/integration/
git diff --exit-code -- tests/
- name: lint
run: |
make py-lint
@@ -79,7 +79,7 @@ jobs:
uses: astral-sh/setup-uv@v4
- name: install
run: |
cd tests/integration && uv sync
cd tests && uv sync
- name: webdriver
run: |
wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
@@ -99,10 +99,10 @@ jobs:
google-chrome-stable --version
- name: run
run: |
cd tests/integration && \
cd tests && \
uv run pytest \
--basetemp=./tmp/ \
src/${{matrix.src}} \
integration/src/${{matrix.src}} \
--sqlstore-provider ${{matrix.sqlstore-provider}} \
--sqlite-mode ${{matrix.sqlite-mode}} \
--postgres-version ${{matrix.postgres-version}} \

View File

@@ -201,26 +201,26 @@ docker-buildx-enterprise: go-build-enterprise js-build
# python commands
##############################################################
.PHONY: py-fmt
py-fmt: ## Run black for integration tests
@cd tests/integration && uv run black .
py-fmt: ## Run black across the shared tests project
@cd tests && uv run black .
.PHONY: py-lint
py-lint: ## Run lint for integration tests
@cd tests/integration && uv run isort .
@cd tests/integration && uv run autoflake .
@cd tests/integration && uv run pylint .
py-lint: ## Run lint across the shared tests project
@cd tests && uv run isort .
@cd tests && uv run autoflake .
@cd tests && uv run pylint .
.PHONY: py-test-setup
py-test-setup: ## Runs integration tests
@cd tests/integration && uv run pytest --basetemp=./tmp/ -vv --reuse --capture=no src/bootstrap/setup.py::test_setup
py-test-setup: ## Bring up the shared SigNoz backend used by integration and e2e tests
@cd tests && uv run pytest --basetemp=./tmp/ -vv --reuse --capture=no integration/src/bootstrap/setup.py::test_setup
.PHONY: py-test-teardown
py-test-teardown: ## Runs integration tests with teardown
@cd tests/integration && uv run pytest --basetemp=./tmp/ -vv --teardown --capture=no src/bootstrap/setup.py::test_teardown
py-test-teardown: ## Tear down the shared SigNoz backend
@cd tests && uv run pytest --basetemp=./tmp/ -vv --teardown --capture=no integration/src/bootstrap/setup.py::test_teardown
.PHONY: py-test
py-test: ## Runs integration tests
@cd tests/integration && uv run pytest --basetemp=./tmp/ -vv --capture=no src/
@cd tests && uv run pytest --basetemp=./tmp/ -vv --capture=no integration/src/
.PHONY: py-clean
py-clean: ## Clear all pycache and pytest cache from tests directory recursively

View File

@@ -7,6 +7,7 @@ import (
"github.com/spf13/cobra"
"github.com/SigNoz/signoz/cmd"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/authn"
@@ -14,6 +15,7 @@ import (
"github.com/SigNoz/signoz/pkg/authz/openfgaauthz"
"github.com/SigNoz/signoz/pkg/authz/openfgaschema"
"github.com/SigNoz/signoz/pkg/authz/openfgaserver"
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/gateway"
@@ -26,14 +28,20 @@ import (
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/query-service/app"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/ruler"
"github.com/SigNoz/signoz/pkg/ruler/signozruler"
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/version"
"github.com/SigNoz/signoz/pkg/zeus"
"github.com/SigNoz/signoz/pkg/zeus/noopzeus"
@@ -75,7 +83,7 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
},
signoz.NewEmailingProviderFactories(),
signoz.NewCacheProviderFactories(),
signoz.NewWebProviderFactories(),
signoz.NewWebProviderFactories(config.Global),
func(sqlstore sqlstore.SQLStore) factory.NamedMap[factory.ProviderFactory[sqlschema.SQLSchema, sqlschema.Config]] {
return signoz.NewSQLSchemaProviderFactories(sqlstore)
},
@@ -107,6 +115,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
func(_ sqlstore.SQLStore, _ global.Global, _ zeus.Zeus, _ gateway.Gateway, _ licensing.Licensing, _ serviceaccount.Module, _ cloudintegration.Config) (cloudintegration.Module, error) {
return implcloudintegration.NewModule(), nil
},
func(c cache.Cache, am alertmanager.Alertmanager, ss sqlstore.SQLStore, ts telemetrystore.TelemetryStore, ms telemetrytypes.MetadataStore, p prometheus.Prometheus, og organization.Getter, rsh rulestatehistory.Module, q querier.Querier, qp queryparser.QueryParser) factory.NamedMap[factory.ProviderFactory[ruler.Ruler, ruler.Config]] {
return factory.MustNewNamedMap(signozruler.NewFactory(c, am, ss, ts, ms, p, og, rsh, q, qp, nil, nil))
},
)
if err != nil {
logger.ErrorContext(ctx, "failed to create signoz", errors.Attr(err))

View File

@@ -22,14 +22,17 @@ import (
"github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard"
eequerier "github.com/SigNoz/signoz/ee/querier"
enterpriseapp "github.com/SigNoz/signoz/ee/query-service/app"
eerules "github.com/SigNoz/signoz/ee/query-service/rules"
"github.com/SigNoz/signoz/ee/sqlschema/postgressqlschema"
"github.com/SigNoz/signoz/ee/sqlstore/postgressqlstore"
enterprisezeus "github.com/SigNoz/signoz/ee/zeus"
"github.com/SigNoz/signoz/ee/zeus/httpzeus"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/authn"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/gateway"
@@ -40,15 +43,21 @@ import (
"github.com/SigNoz/signoz/pkg/modules/dashboard"
pkgimpldashboard "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/ruler"
"github.com/SigNoz/signoz/pkg/ruler/signozruler"
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstorehook"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/version"
"github.com/SigNoz/signoz/pkg/zeus"
)
@@ -96,7 +105,7 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
},
signoz.NewEmailingProviderFactories(),
signoz.NewCacheProviderFactories(),
signoz.NewWebProviderFactories(),
signoz.NewWebProviderFactories(config.Global),
func(sqlstore sqlstore.SQLStore) factory.NamedMap[factory.ProviderFactory[sqlschema.SQLSchema, sqlschema.Config]] {
existingFactories := signoz.NewSQLSchemaProviderFactories(sqlstore)
if err := existingFactories.Add(postgressqlschema.NewFactory(sqlstore)); err != nil {
@@ -166,6 +175,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
return implcloudintegration.NewModule(pkgcloudintegration.NewStore(sqlStore), global, zeus, gateway, licensing, serviceAccount, cloudProvidersMap, config)
},
func(c cache.Cache, am alertmanager.Alertmanager, ss sqlstore.SQLStore, ts telemetrystore.TelemetryStore, ms telemetrytypes.MetadataStore, p prometheus.Prometheus, og organization.Getter, rsh rulestatehistory.Module, q querier.Querier, qp queryparser.QueryParser) factory.NamedMap[factory.ProviderFactory[ruler.Ruler, ruler.Config]] {
return factory.MustNewNamedMap(signozruler.NewFactory(c, am, ss, ts, ms, p, og, rsh, q, qp, eerules.PrepareTaskFunc, eerules.TestNotification))
},
)
if err != nil {
logger.ErrorContext(ctx, "failed to create signoz", errors.Attr(err))

View File

@@ -6,6 +6,8 @@
##################### Global #####################
global:
# the url under which the signoz apiserver is externally reachable.
# the path component (e.g. /signoz in https://example.com/signoz) is used
# as the base path for all HTTP routes (both API and web frontend).
external_url: <unset>
# the url where the SigNoz backend receives telemetry data (traces, metrics, logs) from instrumented applications.
ingestion_url: <unset>
@@ -50,8 +52,8 @@ pprof:
web:
# Whether to enable the web frontend
enabled: true
# The prefix to serve web on
prefix: /
# The index file to use as the SPA entrypoint.
index: index.html
# The directory containing the static build files.
directory: /etc/signoz/web

File diff suppressed because it is too large Load Diff

View File

@@ -20,3 +20,4 @@ We **recommend** (almost enforce) reviewing these guides before contributing to
- [Packages](packages.md) - Naming, layout, and conventions for `pkg/` packages
- [Service](service.md) - Managed service lifecycle with `factory.Service`
- [SQL](sql.md) - Database and SQL patterns
- [Types](types.md) - Domain types, request/response bodies, and storage rows in `pkg/types/`

View File

@@ -0,0 +1,152 @@
# Types
Domain types in `pkg/types/<domain>/` live on three serialization boundaries — inbound HTTP, outbound HTTP, and SQL — on top of an in-memory domain representation. SigNoz's convention is **core-type-first**: every domain defines a single canonical type `X`, and specialized flavors (`PostableX`, `GettableX`, `UpdatableX`, `StorableX`) are introduced **only when they actually differ from `X`**. This guide spells out when each flavor is warranted and how they relate to each other.
Before reading, make sure you have read [abstractions.md](abstractions.md) — the rules here build on its guidance that every new type must earn its place.
## The core type is required
Every domain package in `pkg/types/<domain>/` defines exactly one core type `X`: `AuthDomain`, `Channel`, `Rule`, `Dashboard`, `Role`, `PlannedMaintenance`. This is the canonical in-memory representation of the domain object. Domain methods, validation invariants, and business logic hang off `X` — not off the flavor types.
Two rules shape how the core type behaves:
- **Conversions can be either `New<Output>From<Input>` or a receiver-style `(x *X) ToY()` method.** Either form is fine; pick whichever reads best at the call site:
```go
// Constructor form
func NewGettableAuthDomainFromAuthDomain(d *AuthDomain, info *AuthNProviderInfo) *GettableAuthDomain
// Receiver form
func (m *PlannedMaintenanceWithRules) ToPlannedMaintenance() *PlannedMaintenance
```
- **`X` can double as the storage row** when the DB shape would be identical. `Channel` embeds `bun.BaseModel` directly, and there is no `StorableChannel`. This is the preferred shape when it works.
Domain packages under `pkg/types/` must not import from other `pkg/` packages. Keep the core type's methods lightweight and push orchestration out to the module layer.
## Add a flavor only when it differs
For each of the four flavors, create it only if its shape diverges from `X`. If a flavor would have the same fields and tags as `X`, reuse `X` directly, or declare a type alias. Every flavor must earn its place per [abstractions.md](abstractions.md) rule 6 ("Wrappers must add semantics, not just rename").
| Flavor | Create it when it differs in… |
| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PostableX` | JSON shape differs from `X` — typically no `Id`, no audit fields, no server-computed fields. Often owns input validation via `Validate()` or a custom `UnmarshalJSON`. |
| `GettableX` | Response shape adds server-computed fields that are not persisted — e.g., `GettableAuthDomain` adds `AuthNProviderInfo`, which is resolved at read time. |
| `UpdatableX` | Only a strict subset of `PostableX` is replaceable on PUT. If the updatable shape equals `PostableX`, reuse `PostableX`. |
| `StorableX` | DB row shape differs from `X` — usually `X` carries nested typed config while `StorableX` carries a flat `Data string` JSON column, plus bun tags, audit mixins, and an `OrgID`. If `X` already has those, skip the flavor. |
The failure mode this rule exists to prevent: minting all four flavors on reflex for every new resource, even when two or three are structurally identical. Each unnecessary flavor is another type contributors must understand and another conversion that can drift.
## Worked examples
### Channel — core type only
```go
type Channels = []*Channel
type GettableChannels = []*Channel
type Channel struct {
bun.BaseModel `bun:"table:notification_channel"`
types.Identifiable
types.TimeAuditable
Name string `json:"name" required:"true" bun:"name"`
Type string `json:"type" required:"true" bun:"type"`
Data string `json:"data" required:"true" bun:"data"`
OrgID string `json:"orgId" required:"true" bun:"org_id"`
}
```
`Channel` is both the domain type and the bun row. `GettableChannels` is a **type alias** because `*Channel` already serializes correctly as a response. There is no `StorableChannel`, `PostableChannel`, or `UpdatableChannel` — those would be identical to `Channel` and so do not exist. Prefer this shape when it works.
### AuthDomain — all four flavors
```go
type AuthDomain struct {
storableAuthDomain *StorableAuthDomain
authDomainConfig *AuthDomainConfig
}
type StorableAuthDomain struct {
bun.BaseModel `bun:"table:auth_domain"`
types.Identifiable
Name string `bun:"name"`
Data string `bun:"data"` // AuthDomainConfig serialized as JSON
OrgID valuer.UUID `bun:"org_id"`
types.TimeAuditable
}
type PostableAuthDomain struct {
Config AuthDomainConfig `json:"config"`
Name string `json:"name"`
}
type UpdateableAuthDomain struct {
Config AuthDomainConfig `json:"config"` // Name intentionally absent
}
type GettableAuthDomain struct {
*StorableAuthDomain
*AuthDomainConfig
AuthNProviderInfo *AuthNProviderInfo `json:"authNProviderInfo"`
}
```
Each flavor exists for a concrete reason:
- `StorableAuthDomain` stores the typed config as an opaque `Data string` column, so the schema does not need to migrate every time a config field is added.
- `PostableAuthDomain` carries the config as a structured object (not a string) for the request.
- `UpdateableAuthDomain` excludes `Name` because a domain's name cannot change after creation.
- `GettableAuthDomain` adds `AuthNProviderInfo`, which is derived at read time and never persisted.
The core `AuthDomain` holds the two live halves — `storableAuthDomain` and `authDomainConfig` — and owns business methods such as `Update(config)`. Conversions use the `New<Output>From<Input>` form: `NewAuthDomainFromConfig`, `NewAuthDomainFromStorableAuthDomain`, `NewGettableAuthDomainFromAuthDomain`.
## Conventions that tie the flavors together
- **Conversions** use either a `New<Output>From<Input>` constructor — e.g. `NewChannelFromReceiver`, `NewGettableAuthDomainFromAuthDomain` — or a receiver-style `ToY()` method. Both forms coexist in the codebase; use whichever fits the call site.
- **Validation belongs on the core type `X`.** Putting it on `X` means every write path — HTTP create, HTTP update, in-process migration, replay — runs the same checks. `Validate()` on `PostableX` is reserved for checks that are specific to the request shape and do not apply to `X`. `UnmarshalJSON` on `PostableX` is a separate tool that lives there because decoding only happens at the HTTP boundary — `PostableAuthDomain.UnmarshalJSON` rejecting a malformed domain name at decode time is the canonical example.
```go
// Domain invariants: every write path re-runs these.
func (x *X) Validate() error { ... }
// Request-shape-only: checks that do not apply once the value is persisted.
func (p *PostableX) Validate() error { ... }
```
- **Type aliases, not wrappers**, when two shapes are identical. `type GettableChannels = []*Channel` is correct because it adds no semantics beyond the underlying type.
- **Serialization tags** follow [handler.md](handler.md): `required:"true"` means the JSON key must be present, `nullable:"true"` is required on any slice or map that may serialize as `null`, and types with a fixed value set must implement `Enum() []any`.
## A note on `UpdatableX` and `PatchableX`
- `UpdatableX` — the body for PUT (full replace) when the shape is a strict subset of `PostableX`. If the updatable shape equals `PostableX`, reuse `PostableX`.
- `PatchableX` — the body for PATCH (partial update); only the fields a client is allowed to patch. For example, `PatchableRole` carries a single `Description` field even though `Role` has many — clients may patch the description but not anything else.
```go
type PatchableRole struct {
Description string `json:"description"`
}
```
Both are optional. Do not introduce them if `PostableX` already covers the case.
## What to avoid
- **Do not mint a flavor that mirrors the core type.** If `StorableX` would have the same fields as `X`, use `X` directly with `bun.BaseModel` embedded. `Channel` is the canonical example.
- **Do not bolt domain methods onto `StorableX`.** Storage types are data carriers. Domain methods live on `X`.
- **Do not invent new suffixes** (`Creatable`, `Fetchable`, `Savable`). The core type plus `Postable` / `Gettable` / `Updatable` / `Patchable` / `Storable` covers every case that exists today.
- **Spelling — `Updatable`, not `Updateable`.** `Updateable` is a common typo. Prefer the shorter form when introducing new types, and rename any stragglers you come across.
- **Spelling — `Storable`, not `Storeable`.** `Storeable` is a common typo. Prefer the shorter form when introducing new types, and rename any stragglers you come across.
## What should I remember?
- Every domain package defines the core type `X`. Only `X` is mandatory.
- Add `PostableX` / `GettableX` / `UpdatableX` / `StorableX` one at a time, only when the shape actually diverges from `X`.
- Domain logic lives on `X`, not on the flavor types.
- Conversions can be a `New<Output>From<Input>` constructor or a receiver-style `ToY()` method — pick whichever reads best at the call site.
- Use a type alias when two shapes are truly identical.
- `pkg/types/<domain>/` must not import from other `pkg/` packages.
## Further reading
- [abstractions.md](abstractions.md) — when to introduce a new type at all.
- [handler.md](handler.md) — struct tag rules at the HTTP boundary.
- [packages.md](packages.md) — where types live under `pkg/types/`.
- [sql.md](sql.md) — star-schema requirements for `StorableX`.

View File

@@ -19,11 +19,11 @@ func NewAWSCloudProvider(defStore cloudintegrationtypes.ServiceDefinitionStore)
}
func (provider *awscloudprovider) GetConnectionArtifact(ctx context.Context, account *cloudintegrationtypes.Account, req *cloudintegrationtypes.GetConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
baseURL := fmt.Sprintf(cloudintegrationtypes.CloudFormationQuickCreateBaseURL.StringValue(), req.Config.Aws.DeploymentRegion)
baseURL := fmt.Sprintf(cloudintegrationtypes.CloudFormationQuickCreateBaseURL.StringValue(), req.Config.AWS.DeploymentRegion)
u, _ := url.Parse(baseURL)
q := u.Query()
q.Set("region", req.Config.Aws.DeploymentRegion)
q.Set("region", req.Config.AWS.DeploymentRegion)
u.Fragment = "/stacks/quickcreate"
u.RawQuery = q.Encode()
@@ -39,9 +39,7 @@ func (provider *awscloudprovider) GetConnectionArtifact(ctx context.Context, acc
q.Set("param_IngestionKey", req.Credentials.IngestionKey)
return &cloudintegrationtypes.ConnectionArtifact{
Aws: &cloudintegrationtypes.AWSConnectionArtifact{
ConnectionURL: u.String() + "?&" + q.Encode(), // this format is required by AWS
},
AWS: cloudintegrationtypes.NewAWSConnectionArtifact(u.String() + "?&" + q.Encode()), // this format is required by AWS
}, nil
}
@@ -124,9 +122,6 @@ func (provider *awscloudprovider) BuildIntegrationConfig(
}
return &cloudintegrationtypes.ProviderIntegrationConfig{
AWS: &cloudintegrationtypes.AWSIntegrationConfig{
EnabledRegions: account.Config.AWS.Regions,
TelemetryCollectionStrategy: collectionStrategy,
},
AWS: cloudintegrationtypes.NewAWSIntegrationConfig(account.Config.AWS.Regions, collectionStrategy),
}, nil
}

View File

@@ -14,7 +14,6 @@ import (
"github.com/SigNoz/signoz/pkg/query-service/app/logparsingpipeline"
"github.com/SigNoz/signoz/pkg/query-service/interfaces"
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
rules "github.com/SigNoz/signoz/pkg/query-service/rules"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/version"
@@ -23,7 +22,6 @@ import (
type APIHandlerOptions struct {
DataConnector interfaces.Reader
RulesManager *rules.Manager
UsageManager *usage.Manager
IntegrationsController *integrations.Controller
CloudIntegrationsController *cloudintegrations.Controller
@@ -43,7 +41,6 @@ type APIHandler struct {
func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz, config signoz.Config) (*APIHandler, error) {
baseHandler, err := baseapp.NewAPIHandler(baseapp.APIHandlerOpts{
Reader: opts.DataConnector,
RuleManager: opts.RulesManager,
IntegrationsController: opts.IntegrationsController,
CloudIntegrationsController: opts.CloudIntegrationsController,
LogsParsingPipelineController: opts.LogsParsingPipelineController,
@@ -64,10 +61,6 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz, config signoz.
return ah, nil
}
func (ah *APIHandler) RM() *rules.Manager {
return ah.opts.RulesManager
}
func (ah *APIHandler) UM() *usage.Manager {
return ah.opts.UsageManager
}

View File

@@ -12,10 +12,6 @@ import (
"github.com/SigNoz/signoz/pkg/cache/memorycache"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/gorilla/handlers"
@@ -23,18 +19,10 @@ import (
"github.com/soheilhy/cmux"
"github.com/SigNoz/signoz/ee/query-service/app/api"
"github.com/SigNoz/signoz/ee/query-service/rules"
"github.com/SigNoz/signoz/ee/query-service/usage"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/http/middleware"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/web"
"log/slog"
@@ -49,7 +37,6 @@ import (
opAmpModel "github.com/SigNoz/signoz/pkg/query-service/app/opamp/model"
baseconst "github.com/SigNoz/signoz/pkg/query-service/constants"
"github.com/SigNoz/signoz/pkg/query-service/healthcheck"
baserules "github.com/SigNoz/signoz/pkg/query-service/rules"
"github.com/SigNoz/signoz/pkg/query-service/utils"
)
@@ -57,7 +44,6 @@ import (
type Server struct {
config signoz.Config
signoz *signoz.SigNoz
ruleManager *baserules.Manager
// public http router
httpConn net.Listener
@@ -97,24 +83,6 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
nil,
)
rm, err := makeRulesManager(
signoz.Cache,
signoz.Alertmanager,
signoz.SQLStore,
signoz.TelemetryStore,
signoz.TelemetryMetadataStore,
signoz.Prometheus,
signoz.Modules.OrgGetter,
signoz.Modules.RuleStateHistory,
signoz.Querier,
signoz.Instrumentation.ToProviderSettings(),
signoz.QueryParser,
)
if err != nil {
return nil, err
}
// initiate opamp
opAmpModel.Init(signoz.SQLStore, signoz.Instrumentation.Logger(), signoz.Modules.OrgGetter)
@@ -152,7 +120,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
}
// start the usagemanager
usageManager, err := usage.New(signoz.Licensing, signoz.TelemetryStore.ClickhouseDB(), signoz.Zeus, signoz.Modules.OrgGetter)
usageManager, err := usage.New(signoz.Licensing, signoz.TelemetryStore.ClickhouseDB(), signoz.Zeus, signoz.Modules.OrgGetter, signoz.Flagger)
if err != nil {
return nil, err
}
@@ -163,7 +131,6 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
apiOpts := api.APIHandlerOptions{
DataConnector: reader,
RulesManager: rm,
UsageManager: usageManager,
IntegrationsController: integrationsController,
CloudIntegrationsController: cloudIntegrationsController,
@@ -180,8 +147,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
s := &Server{
config: config,
signoz: signoz,
ruleManager: rm,
signoz: signoz,
httpHostPort: baseconst.HTTPHostPort,
unavailableChannel: make(chan healthcheck.Status),
usageManager: usageManager,
@@ -262,6 +228,20 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
return nil, err
}
routePrefix := s.config.Global.ExternalPath()
if routePrefix != "" {
prefixed := http.StripPrefix(routePrefix, handler)
handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
switch req.URL.Path {
case "/api/v1/health", "/api/v2/healthz", "/api/v2/readyz", "/api/v2/livez":
r.ServeHTTP(w, req)
return
}
prefixed.ServeHTTP(w, req)
})
}
return &http.Server{
Handler: handler,
}, nil
@@ -288,8 +268,6 @@ func (s *Server) initListeners() error {
// Start listening on http and private http port concurrently
func (s *Server) Start(ctx context.Context) error {
s.ruleManager.Start(ctx)
err := s.initListeners()
if err != nil {
return err
@@ -333,47 +311,9 @@ func (s *Server) Stop(ctx context.Context) error {
s.opampServer.Stop()
if s.ruleManager != nil {
s.ruleManager.Stop(ctx)
}
// stop usage manager
s.usageManager.Stop(ctx)
return nil
}
func makeRulesManager(cache cache.Cache, alertmanager alertmanager.Alertmanager, sqlstore sqlstore.SQLStore, telemetryStore telemetrystore.TelemetryStore, metadataStore telemetrytypes.MetadataStore, prometheus prometheus.Prometheus, orgGetter organization.Getter, ruleStateHistoryModule rulestatehistory.Module, querier querier.Querier, providerSettings factory.ProviderSettings, queryParser queryparser.QueryParser) (*baserules.Manager, error) {
ruleStore := sqlrulestore.NewRuleStore(sqlstore, queryParser, providerSettings)
maintenanceStore := sqlrulestore.NewMaintenanceStore(sqlstore)
// create manager opts
managerOpts := &baserules.ManagerOptions{
TelemetryStore: telemetryStore,
MetadataStore: metadataStore,
Prometheus: prometheus,
Context: context.Background(),
Querier: querier,
Logger: providerSettings.Logger,
Cache: cache,
EvalDelay: baseconst.GetEvalDelay(),
PrepareTaskFunc: rules.PrepareTaskFunc,
PrepareTestRuleFunc: rules.TestNotification,
Alertmanager: alertmanager,
OrgGetter: orgGetter,
RuleStore: ruleStore,
MaintenanceStore: maintenanceStore,
SQLStore: sqlstore,
QueryParser: queryParser,
RuleStateHistoryModule: ruleStateHistoryModule,
}
// create Manager
manager, err := baserules.NewManager(managerOpts)
if err != nil {
return nil, fmt.Errorf("rule manager error: %v", err)
}
slog.Info("rules manager is ready")
return manager, nil
}

View File

@@ -16,9 +16,11 @@ import (
"github.com/SigNoz/signoz/ee/query-service/model"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/query-service/utils/encryption"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
"github.com/SigNoz/signoz/pkg/zeus"
)
@@ -43,15 +45,18 @@ type Manager struct {
zeus zeus.Zeus
orgGetter organization.Getter
flagger flagger.Flagger
}
func New(licenseService licensing.Licensing, clickhouseConn clickhouse.Conn, zeus zeus.Zeus, orgGetter organization.Getter) (*Manager, error) {
func New(licenseService licensing.Licensing, clickhouseConn clickhouse.Conn, zeus zeus.Zeus, orgGetter organization.Getter, flagger flagger.Flagger) (*Manager, error) {
m := &Manager{
clickhouseConn: clickhouseConn,
licenseService: licenseService,
scheduler: gocron.NewScheduler(time.UTC).Every(1).Day().At("00:00"), // send usage every at 00:00 UTC
zeus: zeus,
orgGetter: orgGetter,
flagger: flagger,
}
return m, nil
}
@@ -168,7 +173,14 @@ func (lm *Manager) UploadUsage(ctx context.Context) {
return
}
errv2 = lm.zeus.PutMeters(ctx, payload.LicenseKey.String(), body)
evalCtx := featuretypes.NewFlaggerEvaluationContext(organization.ID)
useZeus := lm.flagger.BooleanOrEmpty(ctx, flagger.FeaturePutMetersInZeus, evalCtx)
if useZeus {
errv2 = lm.zeus.PutMetersV2(ctx, payload.LicenseKey.String(), body)
} else {
errv2 = lm.zeus.PutMeters(ctx, payload.LicenseKey.String(), body)
}
if errv2 != nil {
slog.ErrorContext(ctx, "failed to upload usage", errors.Attr(errv2))
// not returning error here since it is captured in the failed count

View File

@@ -136,6 +136,18 @@ func (provider *Provider) PutMeters(ctx context.Context, key string, data []byte
return err
}
func (provider *Provider) PutMetersV2(ctx context.Context, key string, data []byte) error {
_, err := provider.do(
ctx,
provider.config.URL.JoinPath("/v1/meters"),
http.MethodPost,
key,
data,
)
return err
}
func (provider *Provider) PutProfile(ctx context.Context, key string, profile *zeustypes.PostableProfile) error {
body, err := json.Marshal(profile)
if err != nil {

39763
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -48,22 +48,23 @@
"@radix-ui/react-tooltip": "1.0.7",
"@sentry/react": "8.41.0",
"@sentry/vite-plugin": "2.22.6",
"@signozhq/button": "0.0.2",
"@signozhq/calendar": "0.0.0",
"@signozhq/callout": "0.0.2",
"@signozhq/checkbox": "0.0.2",
"@signozhq/combobox": "0.0.2",
"@signozhq/command": "0.0.0",
"@signozhq/button": "0.0.5",
"@signozhq/calendar": "0.1.1",
"@signozhq/callout": "0.0.4",
"@signozhq/checkbox": "0.0.4",
"@signozhq/combobox": "0.0.4",
"@signozhq/command": "0.0.2",
"@signozhq/design-tokens": "2.1.4",
"@signozhq/dialog": "^0.0.2",
"@signozhq/drawer": "0.0.4",
"@signozhq/dialog": "0.0.4",
"@signozhq/drawer": "0.0.6",
"@signozhq/icons": "0.1.0",
"@signozhq/input": "0.0.2",
"@signozhq/popover": "0.0.0",
"@signozhq/radio-group": "0.0.2",
"@signozhq/resizable": "0.0.0",
"@signozhq/table": "0.3.7",
"@signozhq/toggle-group": "0.0.1",
"@signozhq/input": "0.0.4",
"@signozhq/popover": "0.1.2",
"@signozhq/radio-group": "0.0.4",
"@signozhq/resizable": "0.0.2",
"@signozhq/tabs": "0.0.11",
"@signozhq/table": "0.3.8",
"@signozhq/toggle-group": "0.0.3",
"@signozhq/ui": "0.0.5",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.13.22",
@@ -125,7 +126,6 @@
"react": "18.2.0",
"react-addons-update": "15.6.3",
"react-beautiful-dnd": "13.1.1",
"react-chartjs-2": "4",
"react-dnd": "16.0.1",
"react-dnd-html5-backend": "16.0.1",
"react-dom": "18.2.0",
@@ -149,7 +149,6 @@
"redux": "^4.0.5",
"redux-thunk": "^2.3.0",
"rehype-raw": "7.0.0",
"remark-gfm": "^3.0.1",
"rollup-plugin-visualizer": "7.0.0",
"rrule": "2.8.1",
"stream": "^0.0.2",

View File

@@ -1,9 +0,0 @@
# SigNoz AI Assistant
1. Chat interface (Side Drawer View)
1. Should be able to expand the view to full screen (open in a new route - with converstation ID)
2. Conversation would be stream (for in process message), the older messages would be listed (Virtualized) - older - newest
2. Input Section
1. Users should be able to upload images / files to the chat

View File

@@ -221,8 +221,7 @@ function App(): JSX.Element {
useEffect(() => {
if (
pathname === ROUTES.ONBOARDING ||
pathname.startsWith('/public/dashboard/') ||
pathname.startsWith('/ai-assistant/')
pathname.startsWith('/public/dashboard/')
) {
window.Pylon?.('hideChatBubble');
} else {

View File

@@ -244,12 +244,18 @@ export const ShortcutsPage = Loadable(
() => import(/* webpackChunkName: "ShortcutsPage" */ 'pages/Settings'),
);
export const InstalledIntegrations = Loadable(
export const Integrations = Loadable(
() =>
import(
/* webpackChunkName: "InstalledIntegrations" */ 'pages/IntegrationsModulePage'
),
);
export const IntegrationsDetailsPage = Loadable(
() =>
import(
/* webpackChunkName: "IntegrationsDetailsPage" */ 'pages/IntegrationsDetailsPage'
),
);
export const MessagingQueuesMainPage = Loadable(
() =>
@@ -311,10 +317,3 @@ export const MeterExplorerPage = Loadable(
() =>
import(/* webpackChunkName: "Meter Explorer Page" */ 'pages/MeterExplorer'),
);
export const AIAssistantPage = Loadable(
() =>
import(
/* webpackChunkName: "AI Assistant Page" */ 'pages/AIAssistantPage/AIAssistantPage'
),
);

View File

@@ -2,7 +2,6 @@ import { RouteProps } from 'react-router-dom';
import ROUTES from 'constants/routes';
import {
AIAssistantPage,
AlertHistory,
AlertOverview,
AlertTypeSelectionPage,
@@ -19,7 +18,8 @@ import {
ForgotPassword,
Home,
InfrastructureMonitoring,
InstalledIntegrations,
Integrations,
IntegrationsDetailsPage,
LicensePage,
ListAllALertsPage,
LiveLogs,
@@ -390,10 +390,17 @@ const routes: AppRoutes[] = [
isPrivate: true,
key: 'WORKSPACE_ACCESS_RESTRICTED',
},
{
path: ROUTES.INTEGRATIONS_DETAIL,
exact: true,
component: IntegrationsDetailsPage,
isPrivate: true,
key: 'INTEGRATIONS_DETAIL',
},
{
path: ROUTES.INTEGRATIONS,
exact: true,
component: InstalledIntegrations,
component: Integrations,
isPrivate: true,
key: 'INTEGRATIONS',
},
@@ -489,13 +496,6 @@ const routes: AppRoutes[] = [
key: 'API_MONITORING',
isPrivate: true,
},
{
path: ROUTES.AI_ASSISTANT,
exact: true,
component: AIAssistantPage,
key: 'AI_ASSISTANT',
isPrivate: true,
},
];
export const SUPPORT_ROUTE: AppRoutes = {

View File

@@ -1,19 +0,0 @@
import axios from 'api';
import { ErrorResponse, SuccessResponse } from 'types/api';
const removeAwsIntegrationAccount = async (
accountId: string,
): Promise<SuccessResponse<Record<string, never>> | ErrorResponse> => {
const response = await axios.post(
`/cloud-integrations/aws/accounts/${accountId}/disconnect`,
);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
};
export default removeAwsIntegrationAccount;

View File

@@ -1,467 +0,0 @@
/**
* AI Assistant API client.
*
* Flow:
* 1. POST /api/v1/assistant/threads → { threadId }
* 2. POST /api/v1/assistant/threads/{threadId}/messages → { executionId }
* 3. GET /api/v1/assistant/executions/{executionId}/events → SSE stream (closes on 'done')
*
* For subsequent messages in the same thread, repeat steps 23.
* Approval/clarification events pause the stream; use approveExecution/clarifyExecution
* to resume, which each return a new executionId to open a fresh SSE stream.
*/
import getLocalStorageApi from 'api/browser/localstorage/get';
import { LOCALSTORAGE } from 'constants/localStorage';
// Direct URL to the AI backend — set VITE_AI_BACKEND_URL in .env to override.
const AI_BACKEND =
import.meta.env.VITE_AI_BACKEND_URL || 'http://localhost:8001';
const BASE = `${AI_BACKEND}/api/v1/assistant`;
function authHeaders(): Record<string, string> {
const token = getLocalStorageApi(LOCALSTORAGE.AUTH_TOKEN) || '';
return token ? { Authorization: `Bearer ${token}` } : {};
}
// ---------------------------------------------------------------------------
// SSE event types
// ---------------------------------------------------------------------------
export type SSEEvent =
| { type: 'status'; executionId: string; state: string; eventId: number }
| {
type: 'message';
executionId: string;
messageId: string;
delta: string;
done: boolean;
actions: unknown[] | null;
eventId: number;
}
| {
type: 'thinking';
executionId: string;
content: string;
eventId: number;
}
| {
type: 'tool_call';
executionId: string;
toolName: string;
toolInput: unknown;
eventId: number;
}
| {
type: 'tool_result';
executionId: string;
toolName: string;
result: unknown;
eventId: number;
}
| {
type: 'approval';
executionId: string;
approvalId: string;
actionType: string;
resourceType: string;
summary: string;
diff: { before: unknown; after: unknown } | null;
eventId: number;
}
| {
type: 'clarification';
executionId: string;
clarificationId: string;
message: string;
discoveredContext: Record<string, unknown> | null;
fields: ClarificationFieldRaw[];
eventId: number;
}
| {
type: 'error';
executionId: string;
error: { type: string; code: string; message: string; details: unknown };
retryAction: 'auto' | 'manual' | 'none';
eventId: number;
}
| { type: 'conversation'; threadId: string; title: string; eventId: number }
| {
type: 'done';
executionId: string;
tokenInput: number;
tokenOutput: number;
latencyMs: number;
toolCallCount?: number;
retryCount?: number;
eventId: number;
};
export interface ClarificationFieldRaw {
id: string;
type: string;
label: string;
required?: boolean;
options?: string[] | null;
default?: string | string[] | null;
}
// ---------------------------------------------------------------------------
// Step 1 — Create thread
// POST /api/v1/assistant/threads → { threadId }
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Thread listing & detail
// ---------------------------------------------------------------------------
export interface ThreadSummary {
threadId: string;
title: string | null;
state: string | null;
activeExecutionId: string | null;
archived: boolean;
createdAt: string;
updatedAt: string;
}
export interface ThreadListResponse {
threads: ThreadSummary[];
nextCursor: string | null;
hasMore: boolean;
}
export interface MessageSummaryBlock {
type: string;
content?: string;
toolCallId?: string;
toolName?: string;
toolInput?: unknown;
result?: unknown;
success?: boolean;
}
export interface MessageSummary {
messageId: string;
role: string;
contentType: string;
content: string | null;
complete: boolean;
toolCalls: Record<string, unknown>[] | null;
blocks: MessageSummaryBlock[] | null;
actions: unknown[] | null;
feedbackRating: 'positive' | 'negative' | null;
feedbackComment: string | null;
executionId: string | null;
createdAt: string;
updatedAt: string;
}
export interface ThreadDetailResponse {
threadId: string;
title: string | null;
state: string | null;
activeExecutionId: string | null;
archived: boolean;
createdAt: string;
updatedAt: string;
messages: MessageSummary[];
pendingApproval: unknown | null;
pendingClarification: unknown | null;
}
export async function listThreads(
cursor?: string | null,
limit = 20,
): Promise<ThreadListResponse> {
const params = new URLSearchParams({ limit: String(limit) });
if (cursor) {
params.set('cursor', cursor);
}
const res = await fetch(`${BASE}/threads?${params.toString()}`, {
headers: { ...authHeaders() },
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(
`Failed to list threads: ${res.status} ${res.statusText}${body}`,
);
}
return res.json();
}
export async function updateThread(
threadId: string,
update: { title?: string | null; archived?: boolean | null },
): Promise<ThreadSummary> {
const res = await fetch(`${BASE}/threads/${threadId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(update),
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(
`Failed to update thread: ${res.status} ${res.statusText}${body}`,
);
}
return res.json();
}
export async function getThreadDetail(
threadId: string,
): Promise<ThreadDetailResponse> {
const res = await fetch(`${BASE}/threads/${threadId}`, {
headers: { ...authHeaders() },
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(
`Failed to get thread: ${res.status} ${res.statusText}${body}`,
);
}
return res.json();
}
// ---------------------------------------------------------------------------
// Thread creation
// ---------------------------------------------------------------------------
export async function createThread(signal?: AbortSignal): Promise<string> {
const res = await fetch(`${BASE}/threads`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({}),
signal,
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(
`Failed to create thread: ${res.status} ${res.statusText}${body}`,
);
}
const data: { threadId: string } = await res.json();
return data.threadId;
}
// ---------------------------------------------------------------------------
// Step 2 — Send message
// POST /api/v1/assistant/threads/{threadId}/messages → { executionId }
// ---------------------------------------------------------------------------
/** Fetches the thread's active executionId for reconnect on thread_busy (409). */
async function getActiveExecutionId(threadId: string): Promise<string | null> {
const res = await fetch(`${BASE}/threads/${threadId}`, {
headers: { ...authHeaders() },
});
if (!res.ok) {
return null;
}
const data: { activeExecutionId?: string | null } = await res.json();
return data.activeExecutionId ?? null;
}
export async function sendMessage(
threadId: string,
content: string,
signal?: AbortSignal,
): Promise<string> {
const res = await fetch(`${BASE}/threads/${threadId}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ content }),
signal,
});
if (res.status === 409) {
// Thread has an active execution — reconnect to it instead of failing.
const executionId = await getActiveExecutionId(threadId);
if (executionId) {
return executionId;
}
}
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(
`Failed to send message: ${res.status} ${res.statusText}${body}`,
);
}
const data: { executionId: string } = await res.json();
return data.executionId;
}
// ---------------------------------------------------------------------------
// Step 3 — Stream execution events
// GET /api/v1/assistant/executions/{executionId}/events → SSE
// ---------------------------------------------------------------------------
function parseSSELine(line: string): SSEEvent | null {
if (!line.startsWith('data: ')) {
return null;
}
const json = line.slice('data: '.length).trim();
if (!json || json === '[DONE]') {
return null;
}
try {
return JSON.parse(json) as SSEEvent;
} catch {
return null;
}
}
function parseSSEChunk(chunk: string): SSEEvent[] {
return chunk
.split('\n\n')
.map((part) => part.split('\n').find((l) => l.startsWith('data: ')) ?? '')
.map(parseSSELine)
.filter((e): e is SSEEvent => e !== null);
}
async function* readSSEReader(
reader: ReadableStreamDefaultReader<Uint8Array>,
): AsyncGenerator<SSEEvent> {
const decoder = new TextDecoder();
let lineBuffer = '';
try {
// eslint-disable-next-line no-constant-condition
while (true) {
// eslint-disable-next-line no-await-in-loop
const { done, value } = await reader.read();
if (done) {
break;
}
lineBuffer += decoder.decode(value, { stream: true });
const parts = lineBuffer.split('\n\n');
lineBuffer = parts.pop() ?? '';
yield* parts.flatMap(parseSSEChunk);
}
yield* parseSSEChunk(lineBuffer);
} finally {
reader.releaseLock();
}
}
export async function* streamEvents(
executionId: string,
signal?: AbortSignal,
): AsyncGenerator<SSEEvent> {
const res = await fetch(`${BASE}/executions/${executionId}/events`, {
headers: { ...authHeaders() },
signal,
});
if (!res.ok || !res.body) {
throw new Error(`SSE stream failed: ${res.status} ${res.statusText}`);
}
yield* readSSEReader(res.body.getReader());
}
// ---------------------------------------------------------------------------
// Approval / Clarification / Cancel actions
// ---------------------------------------------------------------------------
/** Approve a pending action. Returns a new executionId — open a fresh SSE stream for it. */
export async function approveExecution(
approvalId: string,
signal?: AbortSignal,
): Promise<string> {
const res = await fetch(`${BASE}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ approvalId }),
signal,
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(
`Failed to approve: ${res.status} ${res.statusText}${body}`,
);
}
const data: { executionId: string } = await res.json();
return data.executionId;
}
/** Reject a pending action. */
export async function rejectExecution(
approvalId: string,
signal?: AbortSignal,
): Promise<void> {
const res = await fetch(`${BASE}/reject`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ approvalId }),
signal,
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(
`Failed to reject: ${res.status} ${res.statusText}${body}`,
);
}
}
/** Submit clarification answers. Returns a new executionId — open a fresh SSE stream for it. */
export async function clarifyExecution(
clarificationId: string,
answers: Record<string, unknown>,
signal?: AbortSignal,
): Promise<string> {
const res = await fetch(`${BASE}/clarify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ clarificationId, answers }),
signal,
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(
`Failed to clarify: ${res.status} ${res.statusText}${body}`,
);
}
const data: { executionId: string } = await res.json();
return data.executionId;
}
/** Cancel the active execution on a thread. */
export async function cancelExecution(
threadId: string,
signal?: AbortSignal,
): Promise<void> {
const res = await fetch(`${BASE}/cancel`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ threadId }),
signal,
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(
`Failed to cancel: ${res.status} ${res.statusText}${body}`,
);
}
}
// ---------------------------------------------------------------------------
// Feedback
// ---------------------------------------------------------------------------
export type FeedbackRating = 'positive' | 'negative';
export async function submitFeedback(
messageId: string,
rating: FeedbackRating,
comment?: string,
): Promise<void> {
const res = await fetch(`${BASE}/messages/${messageId}/feedback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ rating, comment: comment ?? null }),
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(
`Failed to submit feedback: ${res.status} ${res.statusText}${body}`,
);
}
}

View File

@@ -0,0 +1,496 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import type {
InvalidateOptions,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import { useMutation, useQuery } from 'react-query';
import type { BodyType, ErrorType } from '../../../generatedAPIInstance';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type {
CreateDowntimeSchedule201,
DeleteDowntimeScheduleByIDPathParameters,
GetDowntimeScheduleByID200,
GetDowntimeScheduleByIDPathParameters,
ListDowntimeSchedules200,
ListDowntimeSchedulesParams,
RenderErrorResponseDTO,
RuletypesPostablePlannedMaintenanceDTO,
UpdateDowntimeScheduleByIDPathParameters,
} from '../sigNoz.schemas';
/**
* This endpoint lists all planned maintenance / downtime schedules
* @summary List downtime schedules
*/
export const listDowntimeSchedules = (
params?: ListDowntimeSchedulesParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListDowntimeSchedules200>({
url: `/api/v1/downtime_schedules`,
method: 'GET',
params,
signal,
});
};
export const getListDowntimeSchedulesQueryKey = (
params?: ListDowntimeSchedulesParams,
) => {
return [`/api/v1/downtime_schedules`, ...(params ? [params] : [])] as const;
};
export const getListDowntimeSchedulesQueryOptions = <
TData = Awaited<ReturnType<typeof listDowntimeSchedules>>,
TError = ErrorType<RenderErrorResponseDTO>
>(
params?: ListDowntimeSchedulesParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listDowntimeSchedules>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getListDowntimeSchedulesQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listDowntimeSchedules>>
> = ({ signal }) => listDowntimeSchedules(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listDowntimeSchedules>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListDowntimeSchedulesQueryResult = NonNullable<
Awaited<ReturnType<typeof listDowntimeSchedules>>
>;
export type ListDowntimeSchedulesQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List downtime schedules
*/
export function useListDowntimeSchedules<
TData = Awaited<ReturnType<typeof listDowntimeSchedules>>,
TError = ErrorType<RenderErrorResponseDTO>
>(
params?: ListDowntimeSchedulesParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listDowntimeSchedules>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListDowntimeSchedulesQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary List downtime schedules
*/
export const invalidateListDowntimeSchedules = async (
queryClient: QueryClient,
params?: ListDowntimeSchedulesParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListDowntimeSchedulesQueryKey(params) },
options,
);
return queryClient;
};
/**
* This endpoint creates a new planned maintenance / downtime schedule
* @summary Create downtime schedule
*/
export const createDowntimeSchedule = (
ruletypesPostablePlannedMaintenanceDTO: BodyType<RuletypesPostablePlannedMaintenanceDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateDowntimeSchedule201>({
url: `/api/v1/downtime_schedules`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: ruletypesPostablePlannedMaintenanceDTO,
signal,
});
};
export const getCreateDowntimeScheduleMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createDowntimeSchedule>>,
TError,
{ data: BodyType<RuletypesPostablePlannedMaintenanceDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createDowntimeSchedule>>,
TError,
{ data: BodyType<RuletypesPostablePlannedMaintenanceDTO> },
TContext
> => {
const mutationKey = ['createDowntimeSchedule'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createDowntimeSchedule>>,
{ data: BodyType<RuletypesPostablePlannedMaintenanceDTO> }
> = (props) => {
const { data } = props ?? {};
return createDowntimeSchedule(data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateDowntimeScheduleMutationResult = NonNullable<
Awaited<ReturnType<typeof createDowntimeSchedule>>
>;
export type CreateDowntimeScheduleMutationBody = BodyType<RuletypesPostablePlannedMaintenanceDTO>;
export type CreateDowntimeScheduleMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Create downtime schedule
*/
export const useCreateDowntimeSchedule = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createDowntimeSchedule>>,
TError,
{ data: BodyType<RuletypesPostablePlannedMaintenanceDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createDowntimeSchedule>>,
TError,
{ data: BodyType<RuletypesPostablePlannedMaintenanceDTO> },
TContext
> => {
const mutationOptions = getCreateDowntimeScheduleMutationOptions(options);
return useMutation(mutationOptions);
};
/**
* This endpoint deletes a downtime schedule by ID
* @summary Delete downtime schedule
*/
export const deleteDowntimeScheduleByID = ({
id,
}: DeleteDowntimeScheduleByIDPathParameters) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/downtime_schedules/${id}`,
method: 'DELETE',
});
};
export const getDeleteDowntimeScheduleByIDMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteDowntimeScheduleByID>>,
TError,
{ pathParams: DeleteDowntimeScheduleByIDPathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteDowntimeScheduleByID>>,
TError,
{ pathParams: DeleteDowntimeScheduleByIDPathParameters },
TContext
> => {
const mutationKey = ['deleteDowntimeScheduleByID'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof deleteDowntimeScheduleByID>>,
{ pathParams: DeleteDowntimeScheduleByIDPathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteDowntimeScheduleByID(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteDowntimeScheduleByIDMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteDowntimeScheduleByID>>
>;
export type DeleteDowntimeScheduleByIDMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Delete downtime schedule
*/
export const useDeleteDowntimeScheduleByID = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteDowntimeScheduleByID>>,
TError,
{ pathParams: DeleteDowntimeScheduleByIDPathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteDowntimeScheduleByID>>,
TError,
{ pathParams: DeleteDowntimeScheduleByIDPathParameters },
TContext
> => {
const mutationOptions = getDeleteDowntimeScheduleByIDMutationOptions(options);
return useMutation(mutationOptions);
};
/**
* This endpoint returns a downtime schedule by ID
* @summary Get downtime schedule by ID
*/
export const getDowntimeScheduleByID = (
{ id }: GetDowntimeScheduleByIDPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetDowntimeScheduleByID200>({
url: `/api/v1/downtime_schedules/${id}`,
method: 'GET',
signal,
});
};
export const getGetDowntimeScheduleByIDQueryKey = ({
id,
}: GetDowntimeScheduleByIDPathParameters) => {
return [`/api/v1/downtime_schedules/${id}`] as const;
};
export const getGetDowntimeScheduleByIDQueryOptions = <
TData = Awaited<ReturnType<typeof getDowntimeScheduleByID>>,
TError = ErrorType<RenderErrorResponseDTO>
>(
{ id }: GetDowntimeScheduleByIDPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getDowntimeScheduleByID>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetDowntimeScheduleByIDQueryKey({ id });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getDowntimeScheduleByID>>
> = ({ signal }) => getDowntimeScheduleByID({ id }, signal);
return {
queryKey,
queryFn,
enabled: !!id,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getDowntimeScheduleByID>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetDowntimeScheduleByIDQueryResult = NonNullable<
Awaited<ReturnType<typeof getDowntimeScheduleByID>>
>;
export type GetDowntimeScheduleByIDQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get downtime schedule by ID
*/
export function useGetDowntimeScheduleByID<
TData = Awaited<ReturnType<typeof getDowntimeScheduleByID>>,
TError = ErrorType<RenderErrorResponseDTO>
>(
{ id }: GetDowntimeScheduleByIDPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getDowntimeScheduleByID>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetDowntimeScheduleByIDQueryOptions({ id }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary Get downtime schedule by ID
*/
export const invalidateGetDowntimeScheduleByID = async (
queryClient: QueryClient,
{ id }: GetDowntimeScheduleByIDPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetDowntimeScheduleByIDQueryKey({ id }) },
options,
);
return queryClient;
};
/**
* This endpoint updates a downtime schedule by ID
* @summary Update downtime schedule
*/
export const updateDowntimeScheduleByID = (
{ id }: UpdateDowntimeScheduleByIDPathParameters,
ruletypesPostablePlannedMaintenanceDTO: BodyType<RuletypesPostablePlannedMaintenanceDTO>,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/downtime_schedules/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: ruletypesPostablePlannedMaintenanceDTO,
});
};
export const getUpdateDowntimeScheduleByIDMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateDowntimeScheduleByID>>,
TError,
{
pathParams: UpdateDowntimeScheduleByIDPathParameters;
data: BodyType<RuletypesPostablePlannedMaintenanceDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateDowntimeScheduleByID>>,
TError,
{
pathParams: UpdateDowntimeScheduleByIDPathParameters;
data: BodyType<RuletypesPostablePlannedMaintenanceDTO>;
},
TContext
> => {
const mutationKey = ['updateDowntimeScheduleByID'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateDowntimeScheduleByID>>,
{
pathParams: UpdateDowntimeScheduleByIDPathParameters;
data: BodyType<RuletypesPostablePlannedMaintenanceDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateDowntimeScheduleByID(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateDowntimeScheduleByIDMutationResult = NonNullable<
Awaited<ReturnType<typeof updateDowntimeScheduleByID>>
>;
export type UpdateDowntimeScheduleByIDMutationBody = BodyType<RuletypesPostablePlannedMaintenanceDTO>;
export type UpdateDowntimeScheduleByIDMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update downtime schedule
*/
export const useUpdateDowntimeScheduleByID = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateDowntimeScheduleByID>>,
TError,
{
pathParams: UpdateDowntimeScheduleByIDPathParameters;
data: BodyType<RuletypesPostablePlannedMaintenanceDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateDowntimeScheduleByID>>,
TError,
{
pathParams: UpdateDowntimeScheduleByIDPathParameters;
data: BodyType<RuletypesPostablePlannedMaintenanceDTO>;
},
TContext
> => {
const mutationOptions = getUpdateDowntimeScheduleByIDMutationOptions(options);
return useMutation(mutationOptions);
};

View File

@@ -6,17 +6,24 @@
*/
import type {
InvalidateOptions,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import { useQuery } from 'react-query';
import { useMutation, useQuery } from 'react-query';
import type { ErrorType } from '../../../generatedAPIInstance';
import type { BodyType, ErrorType } from '../../../generatedAPIInstance';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type {
CreateRule201,
DeleteRuleByIDPathParameters,
GetRuleByID200,
GetRuleByIDPathParameters,
GetRuleHistoryFilterKeys200,
GetRuleHistoryFilterKeysParams,
GetRuleHistoryFilterKeysPathParameters,
@@ -35,9 +42,548 @@ import type {
GetRuleHistoryTopContributors200,
GetRuleHistoryTopContributorsParams,
GetRuleHistoryTopContributorsPathParameters,
ListRules200,
PatchRuleByID200,
PatchRuleByIDPathParameters,
RenderErrorResponseDTO,
RuletypesPostableRuleDTO,
TestRule200,
UpdateRuleByIDPathParameters,
} from '../sigNoz.schemas';
/**
* This endpoint lists all alert rules with their current evaluation state
* @summary List alert rules
*/
export const listRules = (signal?: AbortSignal) => {
return GeneratedAPIInstance<ListRules200>({
url: `/api/v2/rules`,
method: 'GET',
signal,
});
};
export const getListRulesQueryKey = () => {
return [`/api/v2/rules`] as const;
};
export const getListRulesQueryOptions = <
TData = Awaited<ReturnType<typeof listRules>>,
TError = ErrorType<RenderErrorResponseDTO>
>(options?: {
query?: UseQueryOptions<Awaited<ReturnType<typeof listRules>>, TError, TData>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListRulesQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof listRules>>> = ({
signal,
}) => listRules(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listRules>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListRulesQueryResult = NonNullable<
Awaited<ReturnType<typeof listRules>>
>;
export type ListRulesQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List alert rules
*/
export function useListRules<
TData = Awaited<ReturnType<typeof listRules>>,
TError = ErrorType<RenderErrorResponseDTO>
>(options?: {
query?: UseQueryOptions<Awaited<ReturnType<typeof listRules>>, TError, TData>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListRulesQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary List alert rules
*/
export const invalidateListRules = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListRulesQueryKey() },
options,
);
return queryClient;
};
/**
* This endpoint creates a new alert rule
* @summary Create alert rule
*/
export const createRule = (
ruletypesPostableRuleDTO: BodyType<RuletypesPostableRuleDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateRule201>({
url: `/api/v2/rules`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: ruletypesPostableRuleDTO,
signal,
});
};
export const getCreateRuleMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createRule>>,
TError,
{ data: BodyType<RuletypesPostableRuleDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createRule>>,
TError,
{ data: BodyType<RuletypesPostableRuleDTO> },
TContext
> => {
const mutationKey = ['createRule'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createRule>>,
{ data: BodyType<RuletypesPostableRuleDTO> }
> = (props) => {
const { data } = props ?? {};
return createRule(data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateRuleMutationResult = NonNullable<
Awaited<ReturnType<typeof createRule>>
>;
export type CreateRuleMutationBody = BodyType<RuletypesPostableRuleDTO>;
export type CreateRuleMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Create alert rule
*/
export const useCreateRule = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createRule>>,
TError,
{ data: BodyType<RuletypesPostableRuleDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createRule>>,
TError,
{ data: BodyType<RuletypesPostableRuleDTO> },
TContext
> => {
const mutationOptions = getCreateRuleMutationOptions(options);
return useMutation(mutationOptions);
};
/**
* This endpoint deletes an alert rule by ID
* @summary Delete alert rule
*/
export const deleteRuleByID = ({ id }: DeleteRuleByIDPathParameters) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/rules/${id}`,
method: 'DELETE',
});
};
export const getDeleteRuleByIDMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteRuleByID>>,
TError,
{ pathParams: DeleteRuleByIDPathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteRuleByID>>,
TError,
{ pathParams: DeleteRuleByIDPathParameters },
TContext
> => {
const mutationKey = ['deleteRuleByID'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof deleteRuleByID>>,
{ pathParams: DeleteRuleByIDPathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteRuleByID(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteRuleByIDMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteRuleByID>>
>;
export type DeleteRuleByIDMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Delete alert rule
*/
export const useDeleteRuleByID = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteRuleByID>>,
TError,
{ pathParams: DeleteRuleByIDPathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteRuleByID>>,
TError,
{ pathParams: DeleteRuleByIDPathParameters },
TContext
> => {
const mutationOptions = getDeleteRuleByIDMutationOptions(options);
return useMutation(mutationOptions);
};
/**
* This endpoint returns an alert rule by ID
* @summary Get alert rule by ID
*/
export const getRuleByID = (
{ id }: GetRuleByIDPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetRuleByID200>({
url: `/api/v2/rules/${id}`,
method: 'GET',
signal,
});
};
export const getGetRuleByIDQueryKey = ({ id }: GetRuleByIDPathParameters) => {
return [`/api/v2/rules/${id}`] as const;
};
export const getGetRuleByIDQueryOptions = <
TData = Awaited<ReturnType<typeof getRuleByID>>,
TError = ErrorType<RenderErrorResponseDTO>
>(
{ id }: GetRuleByIDPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getRuleByID>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetRuleByIDQueryKey({ id });
const queryFn: QueryFunction<Awaited<ReturnType<typeof getRuleByID>>> = ({
signal,
}) => getRuleByID({ id }, signal);
return {
queryKey,
queryFn,
enabled: !!id,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getRuleByID>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetRuleByIDQueryResult = NonNullable<
Awaited<ReturnType<typeof getRuleByID>>
>;
export type GetRuleByIDQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get alert rule by ID
*/
export function useGetRuleByID<
TData = Awaited<ReturnType<typeof getRuleByID>>,
TError = ErrorType<RenderErrorResponseDTO>
>(
{ id }: GetRuleByIDPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getRuleByID>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetRuleByIDQueryOptions({ id }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary Get alert rule by ID
*/
export const invalidateGetRuleByID = async (
queryClient: QueryClient,
{ id }: GetRuleByIDPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetRuleByIDQueryKey({ id }) },
options,
);
return queryClient;
};
/**
* This endpoint applies a partial update to an alert rule by ID
* @summary Patch alert rule
*/
export const patchRuleByID = (
{ id }: PatchRuleByIDPathParameters,
ruletypesPostableRuleDTO: BodyType<RuletypesPostableRuleDTO>,
) => {
return GeneratedAPIInstance<PatchRuleByID200>({
url: `/api/v2/rules/${id}`,
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
data: ruletypesPostableRuleDTO,
});
};
export const getPatchRuleByIDMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof patchRuleByID>>,
TError,
{
pathParams: PatchRuleByIDPathParameters;
data: BodyType<RuletypesPostableRuleDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof patchRuleByID>>,
TError,
{
pathParams: PatchRuleByIDPathParameters;
data: BodyType<RuletypesPostableRuleDTO>;
},
TContext
> => {
const mutationKey = ['patchRuleByID'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof patchRuleByID>>,
{
pathParams: PatchRuleByIDPathParameters;
data: BodyType<RuletypesPostableRuleDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return patchRuleByID(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type PatchRuleByIDMutationResult = NonNullable<
Awaited<ReturnType<typeof patchRuleByID>>
>;
export type PatchRuleByIDMutationBody = BodyType<RuletypesPostableRuleDTO>;
export type PatchRuleByIDMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Patch alert rule
*/
export const usePatchRuleByID = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof patchRuleByID>>,
TError,
{
pathParams: PatchRuleByIDPathParameters;
data: BodyType<RuletypesPostableRuleDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof patchRuleByID>>,
TError,
{
pathParams: PatchRuleByIDPathParameters;
data: BodyType<RuletypesPostableRuleDTO>;
},
TContext
> => {
const mutationOptions = getPatchRuleByIDMutationOptions(options);
return useMutation(mutationOptions);
};
/**
* This endpoint updates an alert rule by ID
* @summary Update alert rule
*/
export const updateRuleByID = (
{ id }: UpdateRuleByIDPathParameters,
ruletypesPostableRuleDTO: BodyType<RuletypesPostableRuleDTO>,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/rules/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: ruletypesPostableRuleDTO,
});
};
export const getUpdateRuleByIDMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateRuleByID>>,
TError,
{
pathParams: UpdateRuleByIDPathParameters;
data: BodyType<RuletypesPostableRuleDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateRuleByID>>,
TError,
{
pathParams: UpdateRuleByIDPathParameters;
data: BodyType<RuletypesPostableRuleDTO>;
},
TContext
> => {
const mutationKey = ['updateRuleByID'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateRuleByID>>,
{
pathParams: UpdateRuleByIDPathParameters;
data: BodyType<RuletypesPostableRuleDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateRuleByID(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateRuleByIDMutationResult = NonNullable<
Awaited<ReturnType<typeof updateRuleByID>>
>;
export type UpdateRuleByIDMutationBody = BodyType<RuletypesPostableRuleDTO>;
export type UpdateRuleByIDMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update alert rule
*/
export const useUpdateRuleByID = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateRuleByID>>,
TError,
{
pathParams: UpdateRuleByIDPathParameters;
data: BodyType<RuletypesPostableRuleDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateRuleByID>>,
TError,
{
pathParams: UpdateRuleByIDPathParameters;
data: BodyType<RuletypesPostableRuleDTO>;
},
TContext
> => {
const mutationOptions = getUpdateRuleByIDMutationOptions(options);
return useMutation(mutationOptions);
};
/**
* Returns distinct label keys from rule history entries for the selected range.
* @summary Get rule history filter keys
@@ -742,3 +1288,87 @@ export const invalidateGetRuleHistoryTopContributors = async (
return queryClient;
};
/**
* This endpoint fires a test notification for the given rule definition
* @summary Test alert rule
*/
export const testRule = (
ruletypesPostableRuleDTO: BodyType<RuletypesPostableRuleDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<TestRule200>({
url: `/api/v2/rules/test`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: ruletypesPostableRuleDTO,
signal,
});
};
export const getTestRuleMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof testRule>>,
TError,
{ data: BodyType<RuletypesPostableRuleDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof testRule>>,
TError,
{ data: BodyType<RuletypesPostableRuleDTO> },
TContext
> => {
const mutationKey = ['testRule'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof testRule>>,
{ data: BodyType<RuletypesPostableRuleDTO> }
> = (props) => {
const { data } = props ?? {};
return testRule(data);
};
return { mutationFn, ...mutationOptions };
};
export type TestRuleMutationResult = NonNullable<
Awaited<ReturnType<typeof testRule>>
>;
export type TestRuleMutationBody = BodyType<RuletypesPostableRuleDTO>;
export type TestRuleMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Test alert rule
*/
export const useTestRule = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof testRule>>,
TError,
{ data: BodyType<RuletypesPostableRuleDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof testRule>>,
TError,
{ data: BodyType<RuletypesPostableRuleDTO> },
TContext
> => {
const mutationOptions = getTestRuleMutationOptions(options);
return useMutation(mutationOptions);
};

View File

@@ -4529,6 +4529,20 @@ export interface RulestatehistorytypesGettableRuleStateWindowDTO {
state: RuletypesAlertStateDTO;
}
export interface RuletypesAlertCompositeQueryDTO {
panelType: RuletypesPanelTypeDTO;
/**
* @type array
* @nullable true
*/
queries: Querybuildertypesv5QueryEnvelopeDTO[] | null;
queryType: RuletypesQueryTypeDTO;
/**
* @type string
*/
unit?: string;
}
export enum RuletypesAlertStateDTO {
inactive = 'inactive',
pending = 'pending',
@@ -4537,6 +4551,513 @@ export enum RuletypesAlertStateDTO {
nodata = 'nodata',
disabled = 'disabled',
}
export enum RuletypesAlertTypeDTO {
METRIC_BASED_ALERT = 'METRIC_BASED_ALERT',
TRACES_BASED_ALERT = 'TRACES_BASED_ALERT',
LOGS_BASED_ALERT = 'LOGS_BASED_ALERT',
EXCEPTIONS_BASED_ALERT = 'EXCEPTIONS_BASED_ALERT',
}
export interface RuletypesBasicRuleThresholdDTO {
/**
* @type array
* @nullable true
*/
channels?: string[] | null;
matchType: RuletypesMatchTypeDTO;
/**
* @type string
*/
name: string;
op: RuletypesCompareOperatorDTO;
/**
* @type number
* @nullable true
*/
recoveryTarget?: number | null;
/**
* @type number
* @nullable true
*/
target: number | null;
/**
* @type string
*/
targetUnit?: string;
}
/**
* @nullable
*/
export type RuletypesBasicRuleThresholdsDTO =
| RuletypesBasicRuleThresholdDTO[]
| null;
export enum RuletypesCompareOperatorDTO {
above = 'above',
below = 'below',
equal = 'equal',
not_equal = 'not_equal',
outside_bounds = 'outside_bounds',
}
export interface RuletypesCumulativeScheduleDTO {
/**
* @type integer
* @nullable true
*/
day?: number | null;
/**
* @type integer
* @nullable true
*/
hour?: number | null;
/**
* @type integer
* @nullable true
*/
minute?: number | null;
type: RuletypesScheduleTypeDTO;
/**
* @type integer
* @nullable true
*/
weekday?: number | null;
}
export interface RuletypesCumulativeWindowDTO {
/**
* @type string
*/
frequency: string;
schedule: RuletypesCumulativeScheduleDTO;
/**
* @type string
*/
timezone: string;
}
export interface RuletypesEvaluationCumulativeDTO {
kind?: RuletypesEvaluationKindDTO;
spec?: RuletypesCumulativeWindowDTO;
}
export type RuletypesEvaluationEnvelopeDTO =
| (RuletypesEvaluationRollingDTO & {
kind: RuletypesEvaluationKindDTO;
spec: unknown;
})
| (RuletypesEvaluationCumulativeDTO & {
kind: RuletypesEvaluationKindDTO;
spec: unknown;
});
export enum RuletypesEvaluationKindDTO {
rolling = 'rolling',
cumulative = 'cumulative',
}
export interface RuletypesEvaluationRollingDTO {
kind?: RuletypesEvaluationKindDTO;
spec?: RuletypesRollingWindowDTO;
}
export interface RuletypesGettableTestRuleDTO {
/**
* @type integer
*/
alertCount?: number;
/**
* @type string
*/
message?: string;
}
export enum RuletypesMaintenanceKindDTO {
fixed = 'fixed',
recurring = 'recurring',
}
export enum RuletypesMaintenanceStatusDTO {
active = 'active',
upcoming = 'upcoming',
expired = 'expired',
}
export enum RuletypesMatchTypeDTO {
at_least_once = 'at_least_once',
all_the_times = 'all_the_times',
on_average = 'on_average',
in_total = 'in_total',
last = 'last',
}
export interface RuletypesNotificationSettingsDTO {
/**
* @type array
*/
groupBy?: string[];
/**
* @type string
*/
newGroupEvalDelay?: string;
renotify?: RuletypesRenotifyDTO;
/**
* @type boolean
*/
usePolicy?: boolean;
}
export enum RuletypesPanelTypeDTO {
value = 'value',
table = 'table',
graph = 'graph',
}
export interface RuletypesPlannedMaintenanceDTO {
/**
* @type array
* @nullable true
*/
alertIds?: string[] | null;
/**
* @type string
* @format date-time
*/
createdAt?: Date;
/**
* @type string
*/
createdBy?: string;
/**
* @type string
*/
description?: string;
/**
* @type string
*/
id: string;
kind: RuletypesMaintenanceKindDTO;
/**
* @type string
*/
name: string;
schedule: RuletypesScheduleDTO;
status: RuletypesMaintenanceStatusDTO;
/**
* @type string
* @format date-time
*/
updatedAt?: Date;
/**
* @type string
*/
updatedBy?: string;
}
export interface RuletypesPostablePlannedMaintenanceDTO {
/**
* @type array
* @nullable true
*/
alertIds?: string[] | null;
/**
* @type string
*/
description?: string;
/**
* @type string
*/
name: string;
schedule: RuletypesScheduleDTO;
}
export type RuletypesPostableRuleDTOAnnotations = { [key: string]: string };
export type RuletypesPostableRuleDTOLabels = { [key: string]: string };
export interface RuletypesPostableRuleDTO {
/**
* @type string
*/
alert: string;
alertType?: RuletypesAlertTypeDTO;
/**
* @type object
*/
annotations?: RuletypesPostableRuleDTOAnnotations;
condition: RuletypesRuleConditionDTO;
/**
* @type string
*/
description?: string;
/**
* @type boolean
*/
disabled?: boolean;
/**
* @type string
*/
evalWindow?: string;
evaluation?: RuletypesEvaluationEnvelopeDTO;
/**
* @type string
*/
frequency?: string;
/**
* @type object
*/
labels?: RuletypesPostableRuleDTOLabels;
notificationSettings?: RuletypesNotificationSettingsDTO;
/**
* @type array
*/
preferredChannels?: string[];
ruleType: RuletypesRuleTypeDTO;
/**
* @type string
*/
schemaVersion?: string;
/**
* @type string
*/
source?: string;
/**
* @type string
*/
version?: string;
}
export enum RuletypesQueryTypeDTO {
builder = 'builder',
clickhouse_sql = 'clickhouse_sql',
promql = 'promql',
}
export interface RuletypesRecurrenceDTO {
/**
* @type string
*/
duration: string;
/**
* @type string
* @format date-time
* @nullable true
*/
endTime?: Date | null;
/**
* @type array
* @nullable true
*/
repeatOn?: RuletypesRepeatOnDTO[] | null;
repeatType: RuletypesRepeatTypeDTO;
/**
* @type string
* @format date-time
*/
startTime: Date;
}
export interface RuletypesRenotifyDTO {
/**
* @type array
*/
alertStates?: RuletypesAlertStateDTO[];
/**
* @type boolean
*/
enabled?: boolean;
/**
* @type string
*/
interval?: string;
}
export enum RuletypesRepeatOnDTO {
sunday = 'sunday',
monday = 'monday',
tuesday = 'tuesday',
wednesday = 'wednesday',
thursday = 'thursday',
friday = 'friday',
saturday = 'saturday',
}
export enum RuletypesRepeatTypeDTO {
daily = 'daily',
weekly = 'weekly',
monthly = 'monthly',
}
export interface RuletypesRollingWindowDTO {
/**
* @type string
*/
evalWindow: string;
/**
* @type string
*/
frequency: string;
}
export type RuletypesRuleDTOAnnotations = { [key: string]: string };
export type RuletypesRuleDTOLabels = { [key: string]: string };
export interface RuletypesRuleDTO {
/**
* @type string
*/
alert: string;
alertType?: RuletypesAlertTypeDTO;
/**
* @type object
*/
annotations?: RuletypesRuleDTOAnnotations;
condition: RuletypesRuleConditionDTO;
/**
* @type string
* @format date-time
*/
createdAt?: Date;
/**
* @type string
*/
createdBy?: string;
/**
* @type string
*/
description?: string;
/**
* @type boolean
*/
disabled?: boolean;
/**
* @type string
*/
evalWindow?: string;
evaluation?: RuletypesEvaluationEnvelopeDTO;
/**
* @type string
*/
frequency?: string;
/**
* @type string
*/
id: string;
/**
* @type object
*/
labels?: RuletypesRuleDTOLabels;
notificationSettings?: RuletypesNotificationSettingsDTO;
/**
* @type array
*/
preferredChannels?: string[];
ruleType: RuletypesRuleTypeDTO;
/**
* @type string
*/
schemaVersion?: string;
/**
* @type string
*/
source?: string;
state: RuletypesAlertStateDTO;
/**
* @type string
* @format date-time
*/
updatedAt?: Date;
/**
* @type string
*/
updatedBy?: string;
/**
* @type string
*/
version?: string;
}
export interface RuletypesRuleConditionDTO {
/**
* @type integer
* @minimum 0
*/
absentFor?: number;
/**
* @type boolean
*/
alertOnAbsent?: boolean;
/**
* @type string
*/
algorithm?: string;
compositeQuery: RuletypesAlertCompositeQueryDTO;
matchType: RuletypesMatchTypeDTO;
op: RuletypesCompareOperatorDTO;
/**
* @type boolean
*/
requireMinPoints?: boolean;
/**
* @type integer
*/
requiredNumPoints?: number;
seasonality?: RuletypesSeasonalityDTO;
/**
* @type string
*/
selectedQueryName?: string;
/**
* @type number
* @nullable true
*/
target?: number | null;
/**
* @type string
*/
targetUnit?: string;
thresholds?: RuletypesRuleThresholdDataDTO;
}
export type RuletypesRuleThresholdDataDTO = RuletypesThresholdBasicDTO & {
kind: RuletypesThresholdKindDTO;
spec: unknown;
};
export enum RuletypesRuleTypeDTO {
threshold_rule = 'threshold_rule',
promql_rule = 'promql_rule',
anomaly_rule = 'anomaly_rule',
}
export interface RuletypesScheduleDTO {
/**
* @type string
* @format date-time
*/
endTime?: Date;
recurrence?: RuletypesRecurrenceDTO;
/**
* @type string
* @format date-time
*/
startTime?: Date;
/**
* @type string
*/
timezone: string;
}
export enum RuletypesScheduleTypeDTO {
hourly = 'hourly',
daily = 'daily',
weekly = 'weekly',
monthly = 'monthly',
}
export enum RuletypesSeasonalityDTO {
hourly = 'hourly',
daily = 'daily',
weekly = 'weekly',
}
export interface RuletypesThresholdBasicDTO {
kind?: RuletypesThresholdKindDTO;
spec?: RuletypesBasicRuleThresholdsDTO;
}
export enum RuletypesThresholdKindDTO {
basic = 'basic',
}
export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
/**
* @type string
@@ -5456,6 +5977,57 @@ export type DeleteAuthDomainPathParameters = {
export type UpdateAuthDomainPathParameters = {
id: string;
};
export type ListDowntimeSchedulesParams = {
/**
* @type boolean
* @nullable true
* @description undefined
*/
active?: boolean | null;
/**
* @type boolean
* @nullable true
* @description undefined
*/
recurring?: boolean | null;
};
export type ListDowntimeSchedules200 = {
/**
* @type array
*/
data: RuletypesPlannedMaintenanceDTO[];
/**
* @type string
*/
status: string;
};
export type CreateDowntimeSchedule201 = {
data: RuletypesPlannedMaintenanceDTO;
/**
* @type string
*/
status: string;
};
export type DeleteDowntimeScheduleByIDPathParameters = {
id: string;
};
export type GetDowntimeScheduleByIDPathParameters = {
id: string;
};
export type GetDowntimeScheduleByID200 = {
data: RuletypesPlannedMaintenanceDTO;
/**
* @type string
*/
status: string;
};
export type UpdateDowntimeScheduleByIDPathParameters = {
id: string;
};
export type HandleExportRawDataPOSTParams = {
/**
* @enum csv,jsonl
@@ -6253,6 +6825,53 @@ export type GetUsersByRoleID200 = {
status: string;
};
export type ListRules200 = {
/**
* @type array
*/
data: RuletypesRuleDTO[];
/**
* @type string
*/
status: string;
};
export type CreateRule201 = {
data: RuletypesRuleDTO;
/**
* @type string
*/
status: string;
};
export type DeleteRuleByIDPathParameters = {
id: string;
};
export type GetRuleByIDPathParameters = {
id: string;
};
export type GetRuleByID200 = {
data: RuletypesRuleDTO;
/**
* @type string
*/
status: string;
};
export type PatchRuleByIDPathParameters = {
id: string;
};
export type PatchRuleByID200 = {
data: RuletypesRuleDTO;
/**
* @type string
*/
status: string;
};
export type UpdateRuleByIDPathParameters = {
id: string;
};
export type GetRuleHistoryFilterKeysPathParameters = {
id: string;
};
@@ -6523,6 +7142,14 @@ export type GetRuleHistoryTopContributors200 = {
status: string;
};
export type TestRule200 = {
data: RuletypesGettableTestRuleDTO;
/**
* @type string
*/
status: string;
};
export type GetSessionContext200 = {
data: AuthtypesSessionContextDTO;
/**

View File

@@ -1,88 +0,0 @@
import axios from 'api';
import {
CloudAccount,
Service,
ServiceData,
UpdateServiceConfigPayload,
UpdateServiceConfigResponse,
} from 'container/CloudIntegrationPage/ServicesSection/types';
import {
AccountConfigPayload,
AccountConfigResponse,
ConnectionParams,
ConnectionUrlResponse,
} from 'types/api/integrations/aws';
export const getAwsAccounts = async (): Promise<CloudAccount[]> => {
const response = await axios.get('/cloud-integrations/aws/accounts');
return response.data.data.accounts;
};
export const getAwsServices = async (
cloudAccountId?: string,
): Promise<Service[]> => {
const params = cloudAccountId
? { cloud_account_id: cloudAccountId }
: undefined;
const response = await axios.get('/cloud-integrations/aws/services', {
params,
});
return response.data.data.services;
};
export const getServiceDetails = async (
serviceId: string,
cloudAccountId?: string,
): Promise<ServiceData> => {
const params = cloudAccountId
? { cloud_account_id: cloudAccountId }
: undefined;
const response = await axios.get(
`/cloud-integrations/aws/services/${serviceId}`,
{ params },
);
return response.data.data;
};
export const generateConnectionUrl = async (params: {
agent_config: { region: string };
account_config: { regions: string[] };
account_id?: string;
}): Promise<ConnectionUrlResponse> => {
const response = await axios.post(
'/cloud-integrations/aws/accounts/generate-connection-url',
params,
);
return response.data.data;
};
export const updateAccountConfig = async (
accountId: string,
payload: AccountConfigPayload,
): Promise<AccountConfigResponse> => {
const response = await axios.post<AccountConfigResponse>(
`/cloud-integrations/aws/accounts/${accountId}/config`,
payload,
);
return response.data;
};
export const updateServiceConfig = async (
serviceId: string,
payload: UpdateServiceConfigPayload,
): Promise<UpdateServiceConfigResponse> => {
const response = await axios.post<UpdateServiceConfigResponse>(
`/cloud-integrations/aws/services/${serviceId}/config`,
payload,
);
return response.data;
};
export const getConnectionParams = async (): Promise<ConnectionParams> => {
const response = await axios.get(
'/cloud-integrations/aws/accounts/generate-connection-params',
);
return response.data.data;
};

View File

@@ -0,0 +1,9 @@
<svg width="929" height="8" viewBox="0 0 929 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id="dotted-double-line-pattern" x="0" y="0" width="6" height="8" patternUnits="userSpaceOnUse">
<rect width="2" height="2" rx="1" fill="#242834" />
<rect y="6" width="2" height="2" rx="1" fill="#242834" />
</pattern>
</defs>
<rect width="929" height="8" fill="url(#dotted-double-line-pattern)" />
</svg>

After

Width:  |  Height:  |  Size: 442 B

View File

@@ -24,6 +24,7 @@ import '@signozhq/input';
import '@signozhq/popover';
import '@signozhq/radio-group';
import '@signozhq/resizable';
import '@signozhq/tabs';
import '@signozhq/table';
import '@signozhq/toggle-group';
import '@signozhq/ui';

View File

@@ -42,6 +42,7 @@
height: 32px;
padding: 10px 16px;
background: var(--l2-background);
color: var(--l2-foreground);
border: none;
border-radius: 2px;
cursor: pointer;
@@ -65,10 +66,3 @@
opacity: 0.8;
}
}
.lightMode {
.auth-header-help-button {
background: var(--l2-background);
border: 1px solid var(--l1-border);
}
}

View File

@@ -0,0 +1,34 @@
.cloud-service-data-collected {
display: flex;
flex-direction: column;
gap: 16px;
.cloud-service-data-collected-table {
display: flex;
flex-direction: column;
gap: 8px;
.cloud-service-data-collected-table-heading {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
color: var(--l2-foreground);
/* Bifrost (Ancient)/Content/sm */
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.cloud-service-data-collected-table-logs {
border-radius: 6px;
border: 1px solid var(--l3-background);
background: var(--l1-background);
}
}
}

View File

@@ -1,13 +1,18 @@
import { Table } from 'antd';
import {
CloudintegrationtypesCollectedLogAttributeDTO,
CloudintegrationtypesCollectedMetricDTO,
} from 'api/generated/services/sigNoz.schemas';
import { BarChart2, ScrollText } from 'lucide-react';
import { ServiceData } from './types';
import './CloudServiceDataCollected.styles.scss';
function CloudServiceDataCollected({
logsData,
metricsData,
}: {
logsData: ServiceData['data_collected']['logs'];
metricsData: ServiceData['data_collected']['metrics'];
logsData: CloudintegrationtypesCollectedLogAttributeDTO[] | null | undefined;
metricsData: CloudintegrationtypesCollectedMetricDTO[] | null | undefined;
}): JSX.Element {
const logsColumns = [
{
@@ -61,24 +66,30 @@ function CloudServiceDataCollected({
return (
<div className="cloud-service-data-collected">
{logsData && logsData.length > 0 && (
<div className="cloud-service-data-collected__table">
<div className="cloud-service-data-collected__table-heading">Logs</div>
<div className="cloud-service-data-collected-table">
<div className="cloud-service-data-collected-table-heading">
<ScrollText size={14} />
Logs
</div>
<Table
columns={logsColumns}
dataSource={logsData}
{...tableProps}
className="cloud-service-data-collected__table-logs"
className="cloud-service-data-collected-table-logs"
/>
</div>
)}
{metricsData && metricsData.length > 0 && (
<div className="cloud-service-data-collected__table">
<div className="cloud-service-data-collected__table-heading">Metrics</div>
<div className="cloud-service-data-collected-table">
<div className="cloud-service-data-collected-table-heading">
<BarChart2 size={14} />
Metrics
</div>
<Table
columns={metricsColumns}
dataSource={metricsData}
{...tableProps}
className="cloud-service-data-collected__table-metrics"
className="cloud-service-data-collected-table-metrics"
/>
</div>
)}

View File

@@ -3,8 +3,8 @@ import { useLocation } from 'react-router-dom';
import { toast } from '@signozhq/ui';
import { Button, Input, Radio, RadioChangeEvent, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import { handleContactSupport } from 'container/Integrations/utils';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { handleContactSupport } from 'pages/Integrations/utils';
function FeedbackModal({ onClose }: { onClose: () => void }): JSX.Element {
const [activeTab, setActiveTab] = useState('feedback');

View File

@@ -1,11 +1,7 @@
import { useCallback, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { Button as PeriscopeButton } from '@signozhq/button';
import { Tooltip as PeriscopeTooltip } from '@signozhq/tooltip';
import { Button, Popover } from 'antd';
import logEvent from 'api/common/logEvent';
import AIAssistantIcon from 'container/AIAssistant/components/AIAssistantIcon';
import { openAIAssistant, useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { Globe, Inbox, SquarePen } from 'lucide-react';
@@ -71,23 +67,9 @@ function HeaderRightSection({
};
const isLicenseEnabled = isEnterpriseSelfHostedUser || isCloudUser;
const isDrawerOpen = useAIAssistantStore((s) => s.isDrawerOpen);
return (
<div className="header-right-section-container">
{!isDrawerOpen && (
<PeriscopeTooltip title="AI Assistant">
<PeriscopeButton
variant="ghost"
size="xs"
onClick={openAIAssistant}
aria-label="Open AI Assistant"
>
<AIAssistantIcon size={18} />
</PeriscopeButton>
</PeriscopeTooltip>
)}
{enableFeedback && isLicenseEnabled && (
<Popover
rootClassName="header-section-popover-root"

View File

@@ -4,8 +4,8 @@ import { toast } from '@signozhq/ui';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import logEvent from 'api/common/logEvent';
import { handleContactSupport } from 'container/Integrations/utils';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { handleContactSupport } from 'pages/Integrations/utils';
import FeedbackModal from '../FeedbackModal';
@@ -31,7 +31,7 @@ jest.mock('hooks/useGetTenantLicense', () => ({
useGetTenantLicense: jest.fn(),
}));
jest.mock('pages/Integrations/utils', () => ({
jest.mock('container/Integrations/utils', () => ({
handleContactSupport: jest.fn(),
}));

View File

@@ -100,16 +100,19 @@ function MarkdownRenderer({
variables,
trackCopyAction,
elementDetails,
className,
}: {
markdownContent: any;
variables: any;
trackCopyAction?: boolean;
elementDetails?: Record<string, unknown>;
className?: string;
}): JSX.Element {
const interpolatedMarkdown = interpolateMarkdown(markdownContent, variables);
return (
<ReactMarkdown
className={className}
rehypePlugins={[rehypeRaw as any]}
components={{
// @ts-ignore

View File

@@ -46,8 +46,8 @@
}
&__button {
background: var(--card);
color: var(--accent-primary);
background: var(--secondary-background);
color: var(--secondary-foreground);
border: none;
padding: 6px 12px;
border-radius: 4px;

View File

@@ -7,6 +7,14 @@
[data-slot='dialog-content'] {
position: fixed;
z-index: 60;
background: var(--l1-background);
color: var(--l1-foreground);
/* Override the background and color of the dialog content from the theme */
> div {
background: var(--l1-background);
color: var(--l1-foreground);
}
}
.cmdk-section-heading [cmdk-group-heading] {
@@ -43,6 +51,22 @@
.cmdk-item {
cursor: pointer;
color: var(--l1-foreground);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px;
&:hover {
background: var(--l1-background-hover);
}
&[data-selected='true'] {
background: var(--l3-background);
color: var(--l1-foreground);
}
}
[cmdk-item] svg {

View File

@@ -65,6 +65,7 @@ const ROUTES = {
WORKSPACE_SUSPENDED: '/workspace-suspended',
SHORTCUTS: '/settings/shortcuts',
INTEGRATIONS: '/integrations',
INTEGRATIONS_DETAIL: '/integrations/:integrationId',
MESSAGING_QUEUES_BASE: '/messaging-queues',
MESSAGING_QUEUES_KAFKA: '/messaging-queues/kafka',
MESSAGING_QUEUES_KAFKA_DETAIL: '/messaging-queues/kafka/detail',
@@ -86,8 +87,6 @@ const ROUTES = {
HOME_PAGE: '/',
PUBLIC_DASHBOARD: '/public/dashboard/:dashboardId',
SERVICE_ACCOUNTS_SETTINGS: '/settings/service-accounts',
AI_ASSISTANT: '/ai-assistant/:conversationId',
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
} as const;
export default ROUTES;

File diff suppressed because it is too large Load Diff

View File

@@ -1,97 +0,0 @@
import { useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import { Drawer, Tooltip } from 'antd';
import ROUTES from 'constants/routes';
import { Maximize2, MessageSquare, Plus, X } from 'lucide-react';
import ConversationView from './ConversationView';
import { useAIAssistantStore } from './store/useAIAssistantStore';
import './AIAssistant.styles.scss';
export default function AIAssistantDrawer(): JSX.Element {
const history = useHistory();
const isDrawerOpen = useAIAssistantStore((s) => s.isDrawerOpen);
const activeConversationId = useAIAssistantStore(
(s) => s.activeConversationId,
);
const closeDrawer = useAIAssistantStore((s) => s.closeDrawer);
const startNewConversation = useAIAssistantStore(
(s) => s.startNewConversation,
);
const handleExpand = useCallback(() => {
if (!activeConversationId) {
return;
}
closeDrawer();
history.push(
ROUTES.AI_ASSISTANT.replace(':conversationId', activeConversationId),
);
}, [activeConversationId, closeDrawer, history]);
const handleNewConversation = useCallback(() => {
startNewConversation();
}, [startNewConversation]);
return (
<Drawer
open={isDrawerOpen}
onClose={closeDrawer}
placement="right"
width={420}
className="ai-assistant-drawer"
// Suppress default close button — we render our own header
closeIcon={null}
title={
<div className="ai-assistant-drawer__header">
<div className="ai-assistant-drawer__title">
<MessageSquare size={16} />
<span>AI Assistant</span>
</div>
<div className="ai-assistant-drawer__actions">
<Tooltip title="New conversation">
<button
type="button"
className="ai-assistant-drawer__action-btn"
onClick={handleNewConversation}
aria-label="New conversation"
>
<Plus size={16} />
</button>
</Tooltip>
<Tooltip title="Open full screen">
<button
type="button"
className="ai-assistant-drawer__action-btn"
onClick={handleExpand}
disabled={!activeConversationId}
aria-label="Open full screen"
>
<Maximize2 size={16} />
</button>
</Tooltip>
<Tooltip title="Close">
<button
type="button"
className="ai-assistant-drawer__action-btn"
onClick={closeDrawer}
aria-label="Close drawer"
>
<X size={16} />
</button>
</Tooltip>
</div>
</div>
}
>
{activeConversationId ? (
<ConversationView conversationId={activeConversationId} />
) : null}
</Drawer>
);
}

View File

@@ -1,223 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { useHistory } from 'react-router-dom';
import { Button } from '@signozhq/button';
import { Tooltip } from '@signozhq/tooltip';
import ROUTES from 'constants/routes';
import { Eraser, History, Maximize2, Minus, Plus, X } from 'lucide-react';
import AIAssistantIcon from './components/AIAssistantIcon';
import HistorySidebar from './components/HistorySidebar';
import ConversationView from './ConversationView';
import { useAIAssistantStore } from './store/useAIAssistantStore';
import './AIAssistant.styles.scss';
/**
* Global floating modal for the AI Assistant.
*
* - Triggered by Cmd+P (Mac) / Ctrl+P (Windows/Linux)
* - Escape or the × button fully closes it
* - The (minimize) button collapses to the side panel
* - Mounted once in AppLayout; always in the DOM, conditionally visible
*/
// eslint-disable-next-line sonarjs/cognitive-complexity
export default function AIAssistantModal(): JSX.Element | null {
const history = useHistory();
const [showHistory, setShowHistory] = useState(false);
const isOpen = useAIAssistantStore((s) => s.isModalOpen);
const activeConversationId = useAIAssistantStore(
(s) => s.activeConversationId,
);
const openModal = useAIAssistantStore((s) => s.openModal);
const closeModal = useAIAssistantStore((s) => s.closeModal);
const minimizeModal = useAIAssistantStore((s) => s.minimizeModal);
const startNewConversation = useAIAssistantStore(
(s) => s.startNewConversation,
);
const clearConversation = useAIAssistantStore((s) => s.clearConversation);
// ── Keyboard shortcuts ──────────────────────────────────────────────────────
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent): void => {
// Cmd+P (Mac) / Ctrl+P (Win/Linux) — toggle modal
if ((e.metaKey || e.ctrlKey) && e.key === 'p') {
// Don't intercept Cmd+P inside input/textarea — those are for the user
const tag = (e.target as HTMLElement).tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') {
return;
}
e.preventDefault(); // stop browser print dialog
if (isOpen) {
closeModal();
} else {
openModal();
}
return;
}
// Escape — close modal
if (e.key === 'Escape' && isOpen) {
closeModal();
}
};
window.addEventListener('keydown', handleKeyDown);
return (): void => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, openModal, closeModal]);
// ── Handlers ────────────────────────────────────────────────────────────────
const handleExpand = useCallback(() => {
if (!activeConversationId) {
return;
}
closeModal();
history.push(
ROUTES.AI_ASSISTANT.replace(':conversationId', activeConversationId),
);
}, [activeConversationId, closeModal, history]);
const handleNew = useCallback(() => {
startNewConversation();
setShowHistory(false);
}, [startNewConversation]);
const handleClear = useCallback(() => {
if (activeConversationId) {
clearConversation(activeConversationId);
}
}, [activeConversationId, clearConversation]);
const handleHistorySelect = useCallback(() => {
setShowHistory(false);
}, []);
const handleMinimize = useCallback(() => {
minimizeModal();
setShowHistory(false);
}, [minimizeModal]);
const handleBackdropClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
// Only close when clicking the backdrop itself, not the modal card
if (e.target === e.currentTarget) {
closeModal();
}
},
[closeModal],
);
// ── Render ──────────────────────────────────────────────────────────────────
if (!isOpen) {
return null;
}
return createPortal(
<div
className="ai-modal-backdrop"
role="dialog"
aria-modal="true"
aria-label="AI Assistant"
onClick={handleBackdropClick}
>
<div className="ai-modal">
{/* Header */}
<div className="ai-modal__header">
<div className="ai-modal__title">
<AIAssistantIcon size={16} />
<span>AI Assistant</span>
<kbd className="ai-modal__shortcut">P</kbd>
</div>
<div className="ai-modal__actions">
<Tooltip title={showHistory ? 'Back to chat' : 'Chat history'}>
<Button
variant="ghost"
size="xs"
onClick={(): void => setShowHistory((v) => !v)}
aria-label="Toggle history"
className={showHistory ? 'ai-panel-btn--active' : ''}
>
<History size={14} />
</Button>
</Tooltip>
<Tooltip title="Clear chat">
<Button
variant="ghost"
size="xs"
onClick={handleClear}
disabled={!activeConversationId || showHistory}
aria-label="Clear chat"
>
<Eraser size={14} />
</Button>
</Tooltip>
<Tooltip title="New conversation">
<Button
variant="ghost"
size="xs"
onClick={handleNew}
aria-label="New conversation"
>
<Plus size={14} />
</Button>
</Tooltip>
<Tooltip title="Open full screen">
<Button
variant="ghost"
size="xs"
onClick={handleExpand}
disabled={!activeConversationId}
aria-label="Open full screen"
>
<Maximize2 size={14} />
</Button>
</Tooltip>
<Tooltip title="Minimize to side panel">
<Button
variant="ghost"
size="xs"
onClick={handleMinimize}
aria-label="Minimize to side panel"
>
<Minus size={14} />
</Button>
</Tooltip>
<Tooltip title="Close">
<Button
variant="ghost"
size="xs"
onClick={closeModal}
aria-label="Close"
>
<X size={14} />
</Button>
</Tooltip>
</div>
</div>
{/* Body */}
<div className="ai-modal__body">
{showHistory ? (
<HistorySidebar onSelect={handleHistorySelect} />
) : (
activeConversationId && (
<ConversationView conversationId={activeConversationId} />
)
)}
</div>
</div>
</div>,
document.body,
);
}

View File

@@ -1,180 +0,0 @@
import { useCallback, useRef, useState } from 'react';
import { matchPath, useHistory, useLocation } from 'react-router-dom';
import { Button } from '@signozhq/button';
import { Tooltip } from '@signozhq/tooltip';
import ROUTES from 'constants/routes';
import { Eraser, History, Maximize2, Plus, X } from 'lucide-react';
import AIAssistantIcon from './components/AIAssistantIcon';
import HistorySidebar from './components/HistorySidebar';
import ConversationView from './ConversationView';
import { useAIAssistantStore } from './store/useAIAssistantStore';
import './AIAssistant.styles.scss';
export default function AIAssistantPanel(): JSX.Element | null {
const history = useHistory();
const { pathname } = useLocation();
const [showHistory, setShowHistory] = useState(false);
const isOpen = useAIAssistantStore((s) => s.isDrawerOpen);
const isFullScreenPage = !!matchPath(pathname, {
path: ROUTES.AI_ASSISTANT,
exact: true,
});
const activeConversationId = useAIAssistantStore(
(s) => s.activeConversationId,
);
const closeDrawer = useAIAssistantStore((s) => s.closeDrawer);
const startNewConversation = useAIAssistantStore(
(s) => s.startNewConversation,
);
const clearConversation = useAIAssistantStore((s) => s.clearConversation);
const handleExpand = useCallback(() => {
if (!activeConversationId) {
return;
}
closeDrawer();
history.push(
ROUTES.AI_ASSISTANT.replace(':conversationId', activeConversationId),
);
}, [activeConversationId, closeDrawer, history]);
const handleNew = useCallback(() => {
startNewConversation();
setShowHistory(false);
}, [startNewConversation]);
const handleClear = useCallback(() => {
if (activeConversationId) {
clearConversation(activeConversationId);
}
}, [activeConversationId, clearConversation]);
// When user picks a conversation from history, close the sidebar
const handleHistorySelect = useCallback(() => {
setShowHistory(false);
}, []);
// ── Resize logic ──────────────────────────────────────────────────────────
const [panelWidth, setPanelWidth] = useState(380);
const dragStartX = useRef(0);
const dragStartWidth = useRef(0);
const handleResizeMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
dragStartX.current = e.clientX;
dragStartWidth.current = panelWidth;
const onMouseMove = (ev: MouseEvent): void => {
// Panel is on the right; dragging left (lower clientX) increases width
const delta = dragStartX.current - ev.clientX;
const next = Math.min(Math.max(dragStartWidth.current + delta, 380), 800);
setPanelWidth(next);
};
const onMouseUp = (): void => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
},
[panelWidth],
);
if (!isOpen || isFullScreenPage) {
return null;
}
return (
<div className="ai-assistant-panel" style={{ width: panelWidth }}>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className="ai-assistant-panel__resize-handle"
onMouseDown={handleResizeMouseDown}
/>
<div className="ai-assistant-panel__header">
<div className="ai-assistant-panel__title">
<AIAssistantIcon size={18} />
<span>AI Assistant</span>
</div>
<div className="ai-assistant-panel__actions">
<Tooltip title={showHistory ? 'Back to chat' : 'Chat history'}>
<Button
variant="ghost"
size="xs"
onClick={(): void => setShowHistory((v) => !v)}
aria-label="Toggle history"
className={showHistory ? 'ai-panel-btn--active' : ''}
>
<History size={14} />
</Button>
</Tooltip>
<Tooltip title="Clear chat">
<Button
variant="ghost"
size="xs"
onClick={handleClear}
disabled={!activeConversationId || showHistory}
aria-label="Clear chat"
>
<Eraser size={14} />
</Button>
</Tooltip>
<Tooltip title="New conversation">
<Button
variant="ghost"
size="xs"
onClick={handleNew}
aria-label="New conversation"
>
<Plus size={14} />
</Button>
</Tooltip>
<Tooltip title="Open full screen">
<Button
variant="ghost"
size="xs"
onClick={handleExpand}
disabled={!activeConversationId}
aria-label="Open full screen"
>
<Maximize2 size={14} />
</Button>
</Tooltip>
<Tooltip title="Close">
<Button
variant="ghost"
size="xs"
onClick={closeDrawer}
aria-label="Close panel"
>
<X size={14} />
</Button>
</Tooltip>
</div>
</div>
{showHistory ? (
<HistorySidebar onSelect={handleHistorySelect} />
) : (
activeConversationId && (
<ConversationView conversationId={activeConversationId} />
)
)}
</div>
);
}

View File

@@ -1,43 +0,0 @@
import { matchPath, useLocation } from 'react-router-dom';
import { Tooltip } from '@signozhq/tooltip';
import ROUTES from 'constants/routes';
import { Bot } from 'lucide-react';
import {
openAIAssistant,
useAIAssistantStore,
} from './store/useAIAssistantStore';
import './AIAssistant.styles.scss';
/**
* Floating action button anchored to the bottom-right of the content area.
* Hidden when the panel is already open or when on the full-screen AI Assistant page.
*/
export default function AIAssistantTrigger(): JSX.Element | null {
const { pathname } = useLocation();
const isDrawerOpen = useAIAssistantStore((s) => s.isDrawerOpen);
const isModalOpen = useAIAssistantStore((s) => s.isModalOpen);
const isFullScreenPage = !!matchPath(pathname, {
path: ROUTES.AI_ASSISTANT,
exact: true,
});
if (isDrawerOpen || isModalOpen || isFullScreenPage) {
return null;
}
return (
<Tooltip title="AI Assistant">
<button
type="button"
className="ai-trigger"
onClick={openAIAssistant}
aria-label="Open AI Assistant"
>
<Bot size={20} />
</button>
</Tooltip>
);
}

View File

@@ -1,81 +0,0 @@
import { useCallback } from 'react';
import { Loader2 } from 'lucide-react';
import ChatInput from './components/ChatInput';
import VirtualizedMessages from './components/VirtualizedMessages';
import { useAIAssistantStore } from './store/useAIAssistantStore';
import { MessageAttachment } from './types';
interface ConversationViewProps {
conversationId: string;
}
export default function ConversationView({
conversationId,
}: ConversationViewProps): JSX.Element {
const conversation = useAIAssistantStore(
(s) => s.conversations[conversationId],
);
const isStreamingHere = useAIAssistantStore(
(s) => s.streams[conversationId]?.isStreaming ?? false,
);
const isLoadingThread = useAIAssistantStore((s) => s.isLoadingThread);
const pendingApprovalHere = useAIAssistantStore(
(s) => s.streams[conversationId]?.pendingApproval ?? null,
);
const pendingClarificationHere = useAIAssistantStore(
(s) => s.streams[conversationId]?.pendingClarification ?? null,
);
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
const cancelStream = useAIAssistantStore((s) => s.cancelStream);
const handleSend = useCallback(
(text: string, attachments?: MessageAttachment[]) => {
sendMessage(text, attachments);
},
[sendMessage],
);
const handleCancel = useCallback(() => {
cancelStream(conversationId);
}, [cancelStream, conversationId]);
const messages = conversation?.messages ?? [];
const inputDisabled =
isStreamingHere ||
isLoadingThread ||
Boolean(pendingApprovalHere) ||
Boolean(pendingClarificationHere);
if (isLoadingThread && messages.length === 0) {
return (
<div className="ai-conversation">
<div className="ai-conversation__loading">
<Loader2 size={20} className="ai-history__spinner" />
Loading conversation
</div>
<div className="ai-conversation__input-wrapper">
<ChatInput onSend={handleSend} disabled />
</div>
</div>
);
}
return (
<div className="ai-conversation">
<VirtualizedMessages
conversationId={conversationId}
messages={messages}
isStreaming={isStreamingHere}
/>
<div className="ai-conversation__input-wrapper">
<ChatInput
onSend={handleSend}
onCancel={handleCancel}
disabled={inputDisabled}
isStreaming={isStreamingHere}
/>
</div>
</div>
);
}

View File

@@ -1,649 +0,0 @@
# Page-Aware AI Action System — Technical Design
**Status:** Draft
**Author:** AI Assistant
**Created:** 2026-03-31
**Scope:** Frontend — AI Assistant integration with page-specific actions
---
## 1. Overview
The Page-Aware AI Action System extends the AI Assistant so that it can understand what page the user is currently on, read the page's live state (active filters, time range, selected entities, etc.), and execute actions available on that page — all through a natural-language conversation.
### Goals
- Let users query, filter, and navigate each SigNoz page by talking to the AI
- Let users create and modify entities (dashboards, alerts, saved views) via the AI
- Keep page-specific wiring isolated and co-located with each page — not inside the AI core
- Zero-friction adoption: adding AI support to a new page is a single `usePageActions(...)` call
- Prevent the AI from silently mutating state — every action requires explicit user confirmation
### Non-Goals
- Backend AI model training or fine-tuning
- Real-time data streaming inside the AI chat (charts already handle that via existing blocks)
- Cross-session memory of user preferences (deferred to a future persistent-context system)
---
## 2. Architecture Diagram
```
┌──────────────────────────────────────────────────────────────────────┐
│ Browser │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Active Page (e.g. LogsExplorer) │ │
│ │ │ │
│ │ usePageActions('logs-explorer', [...actions]) │ │
│ │ │ registers on mount │ │
│ │ │ unregisters on unmount │ │
│ │ ▼ │ │
│ │ ┌──────────────────┐ │ │
│ │ │ PageActionRegistry│ ◄── singleton │ │
│ │ │ Map<id, Action> │ │ │
│ │ └────────┬─────────┘ │ │
│ └───────────┼─────────────────────────────────────┘ │
│ │ getAll() + context snapshot │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ AI Assistant (Drawer / Full-page) │ │
│ │ │ │
│ │ sendMessage() │ │
│ │ ├── builds [PAGE_CONTEXT] block from registry │ │
│ │ ├── appends user text │ │
│ │ └── sends to API ──────────────────────────────────► │ │
│ │ AI Backend / Mock │ │
│ │ ◄── streaming response │ │
│ │ │ │
│ │ MessageBubble │ │
│ │ └── RichCodeBlock detects ```ai-action │ │
│ │ └── ActionBlock │ │
│ │ ├── renders description + param preview │ │
│ │ ├── Accept → PageActionRegistry.execute() │ │
│ │ └── Reject → no-op │ │
│ └───────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
```
---
## 3. Data Model
### 3.1 `PageAction<TParams>`
The descriptor for a single action a page exposes to the AI.
```typescript
interface PageAction<TParams = Record<string, unknown>> {
/**
* Stable identifier, dot-namespaced by page.
* e.g. "logs.runQuery", "dashboard.createPanel", "alert.save"
*/
id: string;
/**
* Natural-language description sent verbatim in the page context block.
* The AI uses this to decide which action to invoke.
*/
description: string;
/**
* JSON Schema (draft-07) describing the parameters this action accepts.
* Sent to the AI so it can generate structurally valid calls.
*/
parameters: JSONSchemaObject;
/**
* Performs the action. Resolves with a result the AI can narrate back to
* the user. Rejects if the action cannot be completed.
*/
execute: (params: TParams) => Promise<ActionResult>;
/**
* Optional snapshot of the current page state.
* Called at message-send time so the AI has fresh context.
* Return value is JSON-serialised into the [PAGE_CONTEXT] block.
*/
getContext?: () => unknown;
}
interface ActionResult {
/** Short human-readable outcome: "Query updated with 2 new filters." */
summary: string;
/** Optional structured data the AI block can display (e.g. a new URL) */
data?: Record<string, unknown>;
}
type JSONSchemaObject = {
type: 'object';
properties: Record<string, JSONSchemaProperty>;
required?: string[];
};
```
### 3.2 `PageActionDescriptor`
A lightweight, serialisable version of `PageAction` — safe to include in the API payload (no function references).
```typescript
interface PageActionDescriptor {
id: string;
description: string;
parameters: JSONSchemaObject;
context?: unknown; // snapshot from getContext()
}
```
### 3.3 `AIActionBlock`
The JSON payload the AI emits inside an ` ```ai-action ``` ` fenced block when it wants to invoke an action.
```typescript
interface AIActionBlock {
/** Must match a registered PageAction.id */
actionId: string;
/**
* One-sentence explanation of what the action will do.
* Displayed in the confirmation card.
*/
description: string;
/**
* Parameters the AI chose for this action.
* Validated against the action's JSON Schema before execute() is called.
*/
parameters: Record<string, unknown>;
}
```
---
## 4. PageActionRegistry
A module-level singleton (like `BlockRegistry`). Keeps a flat `Map<id, PageAction>` so look-up is O(1). Supports batch register/unregister keyed by a `pageId` so a page can remove all its actions at once on unmount.
```
src/container/AIAssistant/pageActions/PageActionRegistry.ts
```
### Interface
```typescript
const PageActionRegistry = {
/** Register a set of actions under a page scope key. */
register(pageId: string, actions: PageAction[]): void;
/** Remove all actions registered under a page scope key. */
unregister(pageId: string): void;
/** Look up a single action by its dot-namespaced id. */
get(actionId: string): PageAction | undefined;
/**
* Return serialisable descriptors for all currently registered actions,
* with context snapshots already collected.
*/
snapshot(): PageActionDescriptor[];
};
```
### Internal structure
```typescript
// pageId → action[] (for clean unregister)
const _byPage = new Map<string, PageAction[]>();
// actionId → action (for O(1) lookup at execute time)
const _byId = new Map<string, PageAction>();
```
---
## 5. `usePageActions` Hook
Pages call this hook to register their actions declaratively. React lifecycle handles cleanup.
```
src/container/AIAssistant/pageActions/usePageActions.ts
```
```typescript
function usePageActions(pageId: string, actions: PageAction[]): void {
useEffect(() => {
PageActionRegistry.register(pageId, actions);
return () => PageActionRegistry.unregister(pageId);
// Re-register if action definitions change (e.g. new callbacks after query update)
}, [pageId, actions]);
}
```
**Important:** action factories (see §8) memoize with `useMemo` so that the `actions` array reference is stable — preventing unnecessary re-registrations.
---
## 6. Context Injection in `sendMessage`
Before every outgoing message, the AI store reads the registry and prepends a machine-readable context block to the API payload content. This block is **never stored in the conversation** (not visible in the message list) — it exists only in the network payload.
```
[PAGE_CONTEXT]
page: logs-explorer
actions:
- id: logs.runQuery
description: "Run the current log query with updated filters or time range"
params: { filters: TagFilter[], timeRange?: string }
- id: logs.saveView
description: "Save the current query as a named view"
params: { name: string }
state:
filters: [{ key: "level", op: "=", value: "error" }]
timeRange: "Last 15 minutes"
panelType: "list"
[/PAGE_CONTEXT]
{user's message}
```
### Implementation in `useAIAssistantStore.sendMessage`
```typescript
// Build context prefix from registry
function buildContextPrefix(): string {
const descriptors = PageActionRegistry.snapshot();
if (descriptors.length === 0) return '';
const actionLines = descriptors.map(a =>
` - id: ${a.id}\n description: "${a.description}"\n params: ${JSON.stringify(a.parameters.properties)}`
).join('\n');
const contextLines = descriptors
.filter(a => a.context !== undefined)
.map(a => ` ${a.id}: ${JSON.stringify(a.context)}`)
.join('\n');
return [
'[PAGE_CONTEXT]',
'actions:',
actionLines,
contextLines ? 'state:' : '',
contextLines,
'[/PAGE_CONTEXT]',
'',
].filter(Boolean).join('\n');
}
// In sendMessage, when building the API payload:
const payload = {
conversationId: activeConversationId,
messages: history.map((m, i) => {
const content = (i === history.length - 1 && m.role === 'user')
? buildContextPrefix() + m.content
: m.content;
return { role: m.role, content };
}),
};
```
The displayed message in the UI always shows only `m.content` (the user's raw text). The context prefix only exists in the wire payload.
---
## 7. `ActionBlock` Component
Registered as `BlockRegistry.register('action', ActionBlock)`.
```
src/container/AIAssistant/components/blocks/ActionBlock.tsx
```
### Render states
```
┌─────────────────────────────────────────────────────┐
│ ⚡ Suggested Action │
│ │
│ "Filter logs for ERROR level from payment-service" │
│ │
│ Parameters: │
│ • level = ERROR │
│ • service.name = payment-service │
│ │
│ [ Apply ] [ Dismiss ] │
└─────────────────────────────────────────────────────┘
── After Apply ──
┌─────────────────────────────────────────────────────┐
│ ✓ Applied: "Filter logs for ERROR level from │
│ payment-service" │
└─────────────────────────────────────────────────────┘
── After error ──
┌─────────────────────────────────────────────────────┐
│ ✗ Failed: "Action 'logs.runQuery' is not │
│ available on the current page." │
└─────────────────────────────────────────────────────┘
```
### Execution flow
1. Parse `AIActionBlock` JSON from the fenced block content
2. Validate `parameters` against the action's JSON Schema (fail fast with a clear error)
3. Look up `PageActionRegistry.get(actionId)` — if missing, show "not available" state
4. On Accept: call `action.execute(parameters)`, show loading spinner
5. On success: show summary from `ActionResult.summary`, call `markBlockAnswered(messageId, 'applied')`
6. On failure: show error, allow retry
7. On Dismiss: call `markBlockAnswered(messageId, 'dismissed')`
Like `ConfirmBlock` and `InteractiveQuestion`, `ActionBlock` uses `MessageContext` to get `messageId` and `answeredBlocks` from the store to persist its answered state across remounts.
---
## 8. Page Action Factories
Each page co-locates its action definitions in an `aiActions.ts` file. Factories are functions that close over the page's live state and handlers, so the `execute` callbacks always operate on current data.
### Example: `src/pages/LogsExplorer/aiActions.ts`
```typescript
export function logsRunQueryAction(deps: {
handleRunQuery: () => void;
updateQueryFilters: (filters: TagFilterItem[]) => void;
currentQuery: Query;
globalTime: GlobalReducer;
}): PageAction {
return {
id: 'logs.runQuery',
description: 'Update the active log filters and run the query',
parameters: {
type: 'object',
properties: {
filters: {
type: 'array',
description: 'Replacement filter list. Each item has key, op, value.',
items: {
type: 'object',
properties: {
key: { type: 'string' },
op: { type: 'string', enum: ['=', '!=', 'IN', 'NOT_IN', 'CONTAINS', 'NOT_CONTAINS'] },
value: { type: 'string' },
},
required: ['key', 'op', 'value'],
},
},
},
},
execute: async ({ filters }) => {
deps.updateQueryFilters(filters as TagFilterItem[]);
deps.handleRunQuery();
return { summary: `Query updated with ${filters.length} filter(s) and re-run.` };
},
getContext: () => ({
filters: deps.currentQuery.builder.queryData[0]?.filters?.items ?? [],
timeRange: deps.globalTime.selectedTime,
panelType: 'list',
}),
};
}
export function logsSaveViewAction(deps: {
saveView: (name: string) => Promise<void>;
}): PageAction {
return {
id: 'logs.saveView',
description: 'Save the current log query as a named view',
parameters: {
type: 'object',
properties: { name: { type: 'string', description: 'View name' } },
required: ['name'],
},
execute: async ({ name }) => {
await deps.saveView(name as string);
return { summary: `View "${name}" saved.` };
},
};
}
```
### Usage in the page component
```typescript
// src/pages/LogsExplorer/index.tsx
const { handleRunQuery, updateQueryFilters, currentQuery } = useQueryBuilder();
const globalTime = useSelector((s) => s.globalTime);
const actions = useMemo(
() => [
logsRunQueryAction({ handleRunQuery, updateQueryFilters, currentQuery, globalTime }),
logsSaveViewAction({ saveView }),
logsExportToDashboardAction({ exportToDashboard }),
],
[handleRunQuery, updateQueryFilters, currentQuery, globalTime, saveView, exportToDashboard],
);
usePageActions('logs-explorer', actions);
```
---
## 9. Action Catalogue (Initial Scope)
### 9.1 Logs Explorer
| Action ID | Description | Key Parameters |
|-----------|-------------|----------------|
| `logs.runQuery` | Update filters and run the log query | `filters: TagFilterItem[]` |
| `logs.addFilter` | Append a single filter to the existing set | `key, op, value` |
| `logs.changeView` | Switch between list / timeseries / table | `view: 'list' \| 'timeseries' \| 'table'` |
| `logs.saveView` | Save current query as a named view | `name: string` |
| `logs.exportToDashboard` | Add current query as a panel to a dashboard | `dashboardId?: string, panelTitle?: string` |
### 9.2 Traces Explorer
| Action ID | Description | Key Parameters |
|-----------|-------------|----------------|
| `traces.runQuery` | Update filters and run the trace query | `filters: TagFilterItem[]` |
| `traces.addFilter` | Append a single filter | `key, op, value` |
| `traces.changeView` | Switch between list / trace / timeseries / table | `view: string` |
| `traces.exportToDashboard` | Add to a dashboard | `dashboardId?: string, panelTitle?: string` |
### 9.3 Dashboards List
| Action ID | Description | Key Parameters |
|-----------|-------------|----------------|
| `dashboards.create` | Create a new blank dashboard | `title: string, description?: string` |
| `dashboards.search` | Filter the dashboard list | `query: string` |
| `dashboards.duplicate` | Duplicate an existing dashboard | `dashboardId: string, newTitle?: string` |
| `dashboards.delete` | Delete a dashboard (requires confirmation) | `dashboardId: string` |
### 9.4 Dashboard Detail
| Action ID | Description | Key Parameters |
|-----------|-------------|----------------|
| `dashboard.createPanel` | Add a new panel to the current dashboard | `title: string, queryType: 'logs'\|'metrics'\|'traces'` |
| `dashboard.rename` | Rename the current dashboard | `title: string` |
| `dashboard.deletePanel` | Remove a panel | `panelId: string` |
| `dashboard.addVariable` | Add a dashboard-level variable | `name: string, type: string, defaultValue?: string` |
### 9.5 Alerts
| Action ID | Description | Key Parameters |
|-----------|-------------|----------------|
| `alerts.navigateCreate` | Navigate to the Create Alert page | `alertType?: 'metrics'\|'logs'\|'traces'` |
| `alerts.disable` | Disable an existing alert rule | `alertId: string` |
| `alerts.enable` | Enable an existing alert rule | `alertId: string` |
| `alerts.delete` | Delete an alert rule | `alertId: string` |
### 9.6 Create Alert
| Action ID | Description | Key Parameters |
|-----------|-------------|----------------|
| `alert.setCondition` | Set the alert threshold condition | `op: string, threshold: number` |
| `alert.test` | Test the alert rule against live data | — |
| `alert.save` | Save the alert rule | `name: string, severity?: string` |
### 9.7 Metrics Explorer
| Action ID | Description | Key Parameters |
|-----------|-------------|----------------|
| `metrics.runQuery` | Run a metric query | `metric: string, aggregation?: string` |
| `metrics.saveView` | Save current query as a view | `name: string` |
| `metrics.exportToDashboard` | Add to a dashboard | `dashboardId?: string, panelTitle?: string` |
---
## 10. Context Block Schema (Wire Format)
The `[PAGE_CONTEXT]` block is a freeform text section prepended to the API message content. For the real backend, this should migrate to a structured `system` role message or a dedicated field in the request body. For the initial implementation, embedding it in the user message content is sufficient and works with any LLM API.
```
[PAGE_CONTEXT]
page: <pageId>
actions:
- id: <actionId>
description: "<description>"
params: <JSON Schema properties summary>
...
state:
<actionId>: <JSON context snapshot>
...
[/PAGE_CONTEXT]
```
---
## 11. Parameter Validation
Before `execute()` is called, parameters are validated client-side using the JSON Schema stored on the `PageAction`. This catches cases where the AI generates structurally wrong parameters.
```typescript
function validateParams(schema: JSONSchemaObject, params: unknown): string | null {
// Minimal validation: check required fields are present and types match
// Full implementation can use ajv or a lightweight equivalent
for (const key of schema.required ?? []) {
if (!(params as Record<string, unknown>)[key]) {
return `Missing required parameter: "${key}"`;
}
}
return null; // valid
}
```
If validation fails, `ActionBlock` shows the error inline and does not call `execute`.
---
## 12. Answered State Persistence
`ActionBlock` follows the same pattern as `ConfirmBlock` and `InteractiveQuestion`:
- Uses `MessageContext` to read `messageId`
- Reads `answeredBlocks[messageId]` from the Zustand store
- On Accept: calls `markBlockAnswered(messageId, 'applied')`
- On Dismiss: calls `markBlockAnswered(messageId, 'dismissed')`
- On Error: calls `markBlockAnswered(messageId, 'error:<message>')`
This ensures re-renders and re-mounts do not reset the block to its initial state.
---
## 13. Error Handling
| Failure scenario | Behaviour |
|-----------------|-----------|
| `actionId` not in registry (page navigated away) | Block shows: "This action is no longer available — navigate back to \<page\> and try again." |
| Parameter validation fails | Block shows the validation error inline; does not call `execute` |
| `execute()` throws | Block shows the error message; offers a Retry button |
| AI emits malformed JSON in the block | `RichCodeBlock` falls back to rendering the raw fenced block as a code block |
| User navigates away mid-execution | `execute()` promise resolves/rejects normally; result is stored in `answeredBlocks` |
---
## 14. Permissions
Many page actions map to protected operations (e.g., creating a dashboard, deleting an alert). Each action factory should check the relevant permission before registering — if the user doesn't have permission, the action is simply not registered and will not appear in the context block.
```typescript
const canCreateDashboard = useComponentPermission(['create_new_dashboards']);
const actions = useMemo(() => [
...(canCreateDashboard ? [dashboardCreateAction({ ... })] : []),
// ...
], [canCreateDashboard, ...]);
usePageActions('dashboard-list', actions);
```
This way the AI never suggests actions the user cannot perform.
---
## 15. Implementation Plan
### Phase 1 — Infrastructure (no page integrations yet)
1. `src/container/AIAssistant/pageActions/types.ts`
2. `src/container/AIAssistant/pageActions/PageActionRegistry.ts`
3. `src/container/AIAssistant/pageActions/usePageActions.ts`
4. `ActionBlock.tsx` + `ActionBlock.scss`
5. Register `'action'` in `blocks/index.ts`
6. Context injection in `useAIAssistantStore.sendMessage`
7. Mock API support for `[PAGE_CONTEXT]` → responds with `ai-action` block
### Phase 2 — Logs Explorer integration
8. `src/pages/LogsExplorer/aiActions.ts` (factories for `logs.*` actions)
9. Wire `usePageActions` into `LogsExplorer/index.tsx`
### Phase 3 — Traces, Dashboards, Alerts
10. `src/pages/TracesExplorer/aiActions.ts`
11. `src/pages/DashboardsListPage/aiActions.ts`
12. `src/pages/DashboardPage/aiActions.ts`
13. `src/pages/AlertList/aiActions.ts`
14. `src/pages/CreateAlert/aiActions.ts`
### Phase 4 — Backend handoff
15. Move `[PAGE_CONTEXT]` from content-embedded text to a dedicated `pageContext` field in the API request body
16. Replace mock responses with real AI backend calls
---
## 16. Open Questions
| # | Question | Impact |
|---|----------|--------|
| 1 | Should `ActionBlock` require a single user confirmation, or show a diff-style preview of what will change? | UX complexity |
| 2 | How should multi-step actions work? (e.g. "create dashboard then add three panels") — queue them or chain them? | Architecture |
| 3 | Should the registry support a global `getContext()` for page-agnostic state (user, org, time range)? | Context completeness |
| 4 | What is the max context block size before it degrades AI quality? | Prompt engineering |
| 5 | Should failed actions add a retry message back into the conversation, or stay silent? | UX |
| 6 | Can two pages be active simultaneously (e.g. drawer open over dashboard)? How do we prioritise which actions are "active"? | Edge case |
---
## 17. Relation to Existing AI Architecture
```
BlockRegistry PageActionRegistry
│ │
│ render blocks │ register/unregister actions
│ (ai-chart, ai- │ (logs.runQuery, dashboard.create...)
│ question, ai- │
│ confirm, ...) │
└──────────┬────────────┘
MessageBubble / StreamingMessage
RichCodeBlock (routes to BlockRegistry)
ActionBlock ←── new: reads PageActionRegistry to execute
```
The `PageActionRegistry` is a parallel singleton to `BlockRegistry`. `BlockRegistry` maps `fenced-block-type → render component`. `PageActionRegistry` maps `action-id → execute function`. `ActionBlock` bridges the two: it is a registered *block* (render side) that calls into the *action* registry (execution side).

View File

@@ -1,911 +0,0 @@
# AI Assistant — Technical Design Document
**Status:** In Progress
**Last Updated:** 2026-04-01
**Scope:** Frontend AI Assistant subsystem — UI, state management, API integration, page action system
---
## Table of Contents
1. [Overview](#1-overview)
2. [Architecture Diagram](#2-architecture-diagram)
3. [User Flows](#3-user-flows)
4. [UI Modes and Transitions](#4-ui-modes-and-transitions)
5. [Control Flow: UI → API → UI](#5-control-flow-ui--api--ui)
6. [SSE Response Handling](#6-sse-response-handling)
7. [Page Action System](#7-page-action-system)
8. [Block Rendering System](#8-block-rendering-system)
9. [State Management](#9-state-management)
10. [Page-Specific Actions](#10-page-specific-actions)
11. [Voice Input](#11-voice-input)
12. [File Attachments](#12-file-attachments)
13. [Development Mode (Mock)](#13-development-mode-mock)
14. [Adding a New Page's Actions](#14-adding-a-new-pages-actions)
15. [Adding a New Block Type](#15-adding-a-new-block-type)
16. [Data Contracts](#16-data-contracts)
17. [Key Design Decisions](#17-key-design-decisions)
---
## 1. Overview
The AI Assistant is an embedded chat interface inside SigNoz that understands the current page context and can execute actions on behalf of the user (e.g., filter logs, update queries, navigate views). It communicates with a backend AI service via Server-Sent Events (SSE) and renders structured responses as rich interactive blocks alongside plain markdown.
**Key goals:**
- **Context-aware:** the AI always knows what page the user is on and what actions are available
- **Streaming:** responses appear token-by-token, no waiting for a full response
- **Actionable:** the AI can trigger page mutations (filter logs, switch views) without copy-paste
- **Extensible:** new pages can register actions; new block types can be added independently
---
## 2. Architecture Diagram
```
┌─────────────────────────────────────────────────────────────────────┐
│ User │
└────────────────────────────┬────────────────────────────────────────┘
│ types text / voice / file
┌─────────────────────────────────────────────────────────────────────┐
│ UI Layer │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌───────────────────────────┐ │
│ │ Panel │ │ Modal │ │ Full-Screen Page │ │
│ │ (drawer) │ │ (Cmd+P) │ │ /ai-assistant/:id │ │
│ └──────┬──────┘ └──────┬──────┘ └─────────────┬─────────────┘ │
│ └────────────────┴─────────────────────────┘ │
│ │ all modes share │
│ ┌──────▼──────┐ │
│ │ConversationView│ │
│ │ + ChatInput │ │
│ └──────┬──────┘ │
└───────────────────────────┼─────────────────────────────────────────┘
│ sendMessage()
┌─────────────────────────────────────────────────────────────────────┐
│ Zustand Store (useAIAssistantStore) │
│ │
│ conversations{} isStreaming │
│ activeConversationId streamingContent │
│ isDrawerOpen answeredBlocks{} │
│ isModalOpen │
│ │
│ sendMessage() │
│ 1. push user message │
│ 2. buildContextPrefix() ──► PageActionRegistry.snapshot() │
│ 3. call streamChat(payload) [or mockStreamChat in dev] │
│ 4. accumulate chunks into streamingContent │
│ 5. on done: push assistant message with actions[] │
└──────────────────────────┬──────────────────────────────────────────┘
│ POST /api/v1/assistant/threads
│ (SSE response)
┌─────────────────────────────────────────────────────────────────────┐
│ API Layer (src/api/ai/chat.ts) │
│ │
│ streamChat(payload) → AsyncGenerator<SSEEvent> │
│ Parses data: {...}\n\n SSE frames │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ Page Action System │
│ │
│ PageActionRegistry ◄──── usePageActions() hook │
│ (module singleton) (called by each page on mount) │
│ │
│ Registry is read by buildContextPrefix() before every API call. │
│ │
│ AI response → ai-action block → ActionBlock component │
│ → PageActionRegistry.get(actionId).execute(params) │
└─────────────────────────────────────────────────────────────────────┘
```
---
## 3. User Flows
### 3.1 Basic Chat
```
User opens panel (header icon / Cmd+P / trigger button)
→ Conversation created (or resumed from store)
→ ChatInput focused automatically
User types message → presses Enter
→ User message appended to conversation
→ StreamingMessage (typing indicator) appears
→ SSE stream opens: tokens arrive word-by-word
→ StreamingMessage renders live content
→ Stream ends: StreamingMessage replaced by MessageBubble
→ Follow-up actions (if any) shown as chips on the message
```
### 3.2 AI Applies a Page Action (autoApply)
```
User: "Filter logs for errors from payment-svc"
→ PAGE_CONTEXT injected into wire payload
(includes registered action schemas + current query state)
→ AI responds with plain text + ai-action block
→ ActionBlock mounts with autoApply=true
→ execute() fires immediately on mount — no user confirmation
→ Logs Explorer query updated via redirectWithQueryBuilderData()
→ URL reflects new filters, QueryBuilderV2 UI updates
→ ActionBlock shows "Applied ✓" state (persisted in answeredBlocks)
```
### 3.3 AI Asks a Clarifying Question
```
AI responds with ai-question block
→ InteractiveQuestion renders (radio or checkbox)
→ User selects answer → sendMessage() called automatically
→ Answer persisted in answeredBlocks (survives re-renders / mode switches)
→ Block shows answered state on re-mount
```
### 3.4 AI Requests Confirmation
```
AI responds with ai-confirm block
→ ConfirmBlock renders Accept / Reject buttons
→ Accept → sendMessage(acceptText)
→ Reject → sendMessage(rejectText)
→ Block shows answered state, buttons disabled
```
### 3.5 Modal → Panel Minimize
```
User opens modal (Cmd+P), interacts with AI
User clicks minimize button ()
→ minimizeModal(): isModalOpen=false, isDrawerOpen=true (atomic)
→ Same conversation continues in the side panel
→ No data loss, streaming state preserved
```
### 3.6 Panel → Full Screen Expand
```
User clicks Maximize in panel header
→ closeDrawer() called
→ navigate to /ai-assistant/:conversationId
→ Full-screen page renders same conversation
→ TopNav (timepicker header) hidden on this route
→ SideNav remains visible
```
### 3.7 Voice Input
```
User clicks mic button in ChatInput
→ SpeechRecognition.start()
→ isListening=true (mic turns red, CSS pulse animation)
→ Interim results: textarea updates live as user speaks
→ Recognition ends (auto pause detection or manual click)
→ Final transcript committed to committedTextRef
→ User reviews / edits text, then sends normally
```
### 3.8 Resize Panel
```
User hovers over left edge of panel
→ Resize handle highlights (purple line)
User drags left/right
→ panel width updates live (min 380px, max 800px)
→ document.body.cursor = 'col-resize' during drag
→ text selection disabled during drag
```
---
## 4. UI Modes and Transitions
```
┌──────────────────┐
│ All Closed │
│ (trigger shown) │
└────────┬─────────┘
┌──────────────┼──────────────┐
│ │ │
Click trigger Cmd+P navigate to
│ │ /ai-assistant/:id
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌─────────────┐
│ Panel │ │ Modal │ │ Full-Screen │
│ (drawer) │ │ (portal) │ │ Page │
└────┬─────┘ └────┬─────┘ └──────┬──────┘
│ │ │
┌────▼──────────────▼───────────────▼────┐
│ ConversationView (shared component) │
└─────────────────────────────────────────┘
Transitions:
Panel → Full-Screen : Maximize → closeDrawer() + history.push()
Modal → Panel : Minimize → minimizeModal()
Modal → Full-Screen : Maximize → closeModal() + history.push()
Any → Closed : X button or Escape key
Visibility rules:
Header AI icon : hidden when isDrawerOpen=true
Trigger button : hidden when isDrawerOpen || isModalOpen || isFullScreenPage
TopNav (timepicker): hidden when pathname.startsWith('/ai-assistant/')
```
---
## 5. Control Flow: UI → API → UI
### 5.1 Message Send
```
ChatInput.handleSend()
├── setText('') // clear textarea
├── committedTextRef.current = '' // clear voice accumulator
└── store.sendMessage(text, attachments)
├── Push userMessage to conversations[id].messages
├── set isStreaming=true, streamingContent=''
├── buildContextPrefix()
│ └── PageActionRegistry.snapshot()
│ → returns PageActionDescriptor[] (ids, schemas, current context)
│ → serialized as [PAGE_CONTEXT]...[/PAGE_CONTEXT] string
├── Build wire payload:
│ {
│ conversationId,
│ messages: history.map((m, i) => ({
│ role: m.role,
│ content: i === last && role==='user'
│ ? contextPrefix + m.content // wire only, never stored
│ : m.content
│ }))
│ }
├── for await (event of streamChat(payload)):
│ ├── streamingContent += event.content // triggers StreamingMessage re-render
│ └── if event.done: finalActions = event.actions; break
├── Push assistantMessage { id: serverMessageId, content, actions }
└── set isStreaming=false, streamingContent=''
```
### 5.2 Streaming Render Pipeline
```
streamingContent (Zustand state)
→ StreamingMessage component (rendered while isStreaming=true)
→ react-markdown
→ RichCodeBlock (custom code renderer)
→ BlockRegistry.get(lang) → renders chart / table / action / etc.
On stream end:
streamingContent → assistantMessage.content (frozen in store)
StreamingMessage removed, MessageBubble added with same content
MessageBubble renders through identical markdown pipeline
```
### 5.3 PAGE_CONTEXT Wire Format
The context prefix is prepended to the last user message in the API payload **only**. It is never stored in the conversation or shown in the UI.
```
[PAGE_CONTEXT]
actions:
- id: logs.runQuery
description: "Replace all log filters and re-run the query"
params: {"filters": {"type": "array", "items": {...}}}
- id: logs.addFilter
description: "Append a single key/op/value filter"
params: {"key": {...}, "op": {...}, "value": {...}}
state:
logs.runQuery: {"currentFilters": [...], "currentView": "list"}
[/PAGE_CONTEXT]
User's actual message text here
```
---
## 6. SSE Response Handling
### 6.1 Wire Format
**Request:**
```
POST /api/v1/assistant/threads
Content-Type: application/json
{
"conversationId": "uuid",
"messages": [
{ "role": "user", "content": "[PAGE_CONTEXT]...[/PAGE_CONTEXT]\nUser text" },
{ "role": "assistant", "content": "Previous assistant turn" },
...
]
}
```
**Response (SSE stream):**
```
data: {"type":"message","messageId":"srv-123","role":"assistant","content":"I'll ","done":false,"actions":[]}\n\n
data: {"type":"message","messageId":"srv-123","role":"assistant","content":"update ","done":false,"actions":[]}\n\n
data: {"type":"message","messageId":"srv-123","role":"assistant","content":"the query.","done":true,"actions":[
{"id":"act-1","label":"Add another filter","kind":"follow_up","payload":{},"expiresAt":null}
]}\n\n
```
### 6.2 SSE Parsing (src/api/ai/chat.ts)
```
fetch() → ReadableStream → TextDecoder → string chunks
→ lineBuffer accumulates across chunks (handles partial lines)
→ split on '\n\n' (SSE event boundary)
→ for each complete part:
find line starting with 'data: '
strip prefix → parse JSON → yield SSEEvent
→ '[DONE]' sentinel → stop iteration
→ malformed JSON → skip silently
→ finally: reader.releaseLock()
```
### 6.3 Structured Content in the Stream
The AI embeds block payloads as markdown fenced code blocks with `ai-*` language tags inside the `content` stream. These are parsed live as tokens arrive:
````markdown
Here are the results:
```ai-graph
{
"title": "p99 Latency",
"datasets": [...]
}
```
The spike started at 14:45.
````
React-markdown renders the code block → `RichCodeBlock` detects the `ai-` prefix → looks up `BlockRegistry` → renders the chart/table/action component.
### 6.4 actions[] Array
Actions arrive on the **final** SSE event (`done: true`). They are stored on the `Message` object. Each action's `kind` determines UI behavior:
| Kind | Behavior |
|---|---|
| `follow_up` | Rendered as suggestion chip; click sends as new message |
| `open_resource` | Opens a SigNoz resource (trace, log, dashboard) |
| `navigate` | Navigates to a SigNoz route |
| `apply_filter` | Directly triggers a registered page action |
| `open_docs` | Opens a documentation URL |
| `undo` | Reverts the last page mutation |
| `revert` | Reverts to a specified previous state |
---
## 7. Page Action System
### 7.1 Concepts
| Concept | Description |
|---|---|
| `PageAction<TParams>` | An action a page exposes to the AI: id, description, JSON Schema params, `execute()`, optional `getContext()`, optional `autoApply` |
| `PageActionRegistry` | Module-level singleton (`Map<pageId, actions[]>` + `Map<actionId, action>`) |
| `usePageActions(pageId, actions)` | React hook — registers on mount, unregisters on unmount |
| `PageActionDescriptor` | Serializable version of `PageAction` (no functions) — sent to AI via PAGE_CONTEXT |
| `AIActionBlock` | Shape the AI emits when invoking an action: `{ actionId, description, parameters }` |
### 7.2 Lifecycle
```
Page component mounts
└── usePageActions('logs-explorer', actions)
└── PageActionRegistry.register('logs-explorer', actions)
→ added to _byPage map (for bulk unregister)
→ added to _byId map (for O(1) lookup by actionId)
User sends any message
└── buildContextPrefix()
└── PageActionRegistry.snapshot()
→ returns PageActionDescriptor[] with current context values
AI response contains ```ai-action block
└── ActionBlock component mounts
├── PageActionRegistry.get(actionId) → PageAction with execute()
└── if autoApply: execute(params) on mount
else: render confirmation card, execute on user click
Page component unmounts
└── usePageActions cleanup
└── PageActionRegistry.unregister('logs-explorer')
→ all actions for this page removed from both maps
```
### 7.3 ActionBlock State Machine
**autoApply: true** (fires immediately on mount):
```
mount
→ hasFired ref guard (prevents double-fire in React StrictMode)
→ PageActionRegistry.get(actionId).execute(params)
→ render: loading spinner
→ success: "Applied ✓" state, markBlockAnswered(messageId, 'applied')
→ error: error state with message, markBlockAnswered(messageId, 'error:...')
```
**autoApply: false** (user must confirm):
```
mount
→ render: description + parameter summary + Apply / Dismiss buttons
→ Apply clicked:
→ execute(params) → loading → applied state
→ markBlockAnswered(messageId, 'applied')
→ Dismiss clicked:
→ markBlockAnswered(messageId, 'dismissed')
```
**Re-mount (scroll / mode switch):**
```
mount
→ answeredBlocks[messageId] exists
→ render answered state directly (skip pending UI)
```
---
## 8. Block Rendering System
### 8.1 Registration
`src/container/AIAssistant/components/blocks/index.ts` registers all built-in types at import time (side-effect import):
```typescript
BlockRegistry.register('action', ActionBlock);
BlockRegistry.register('question', InteractiveQuestion);
BlockRegistry.register('confirm', ConfirmBlock);
BlockRegistry.register('timeseries', TimeseriesBlock);
BlockRegistry.register('barchart', BarChartBlock);
BlockRegistry.register('piechart', PieChartBlock);
BlockRegistry.register('linechart', LineChartBlock);
BlockRegistry.register('graph', LineChartBlock); // alias
```
### 8.2 Render Pipeline
```
MessageBubble (assistant message)
└── react-markdown
└── components={{ code: RichCodeBlock }}
└── RichCodeBlock
├── lang.startsWith('ai-') ?
│ yes → BlockRegistry.get(lang.slice(3))
│ → parse JSON content
│ → render block component
└── no → render plain <code> element
```
### 8.3 Block Component Interface
All block components receive:
```typescript
interface BlockProps {
content: string; // raw JSON string from the fenced code block body
}
```
Blocks access shared context via:
```typescript
const { messageId } = useContext(MessageContext); // for answeredBlocks key
const markBlockAnswered = useAIAssistantStore(s => s.markBlockAnswered);
const sendMessage = useAIAssistantStore(s => s.sendMessage); // for interactive blocks
```
### 8.4 Block Types Reference
| Tag | Component | Purpose |
|---|---|---|
| `ai-action` | `ActionBlock` | Invokes a registered page action |
| `ai-question` | `InteractiveQuestion` | Radio or checkbox user selection |
| `ai-confirm` | `ConfirmBlock` | Binary accept / reject prompt |
| `ai-timeseries` | `TimeseriesBlock` | Tabular data with rows and columns |
| `ai-barchart` | `BarChartBlock` | Horizontal / vertical bar chart |
| `ai-piechart` | `PieChartBlock` | Doughnut / pie chart |
| `ai-linechart` | `LineChartBlock` | Multi-series line chart |
| `ai-graph` | `LineChartBlock` | Alias for `ai-linechart` |
---
## 9. State Management
### 9.1 Store Shape (Zustand + Immer)
```typescript
interface AIAssistantStore {
// UI
isDrawerOpen: boolean;
isModalOpen: boolean;
activeConversationId: string | null;
// Data
conversations: Record<string, Conversation>;
// Streaming
streamingContent: string; // accumulates token-by-token during SSE stream
isStreaming: boolean;
// Block answer persistence
answeredBlocks: Record<string, string>; // messageId → answer string
}
```
### 9.2 Conversation Structure
```typescript
interface Conversation {
id: string;
messages: Message[];
createdAt: number;
updatedAt?: number;
title?: string; // auto-derived from first user message (60 char max)
}
interface Message {
id: string; // server messageId for assistant turns, uuidv4 for user
role: 'user' | 'assistant';
content: string;
attachments?: MessageAttachment[];
actions?: AssistantAction[]; // follow-up actions, present on final assistant message only
createdAt: number;
}
```
### 9.3 Streaming State Machine
```
idle
→ sendMessage() called
→ isStreaming=true, streamingContent=''
streaming
→ each SSE chunk: streamingContent += event.content (triggers StreamingMessage re-render)
→ done event: isStreaming=false, streamingContent=''
→ assistant message pushed to conversation
idle (settled)
→ MessageBubble renders final frozen content
→ ChatInput re-enabled (disabled={isStreaming})
```
### 9.4 Answered Block Persistence
Interactive blocks call `markBlockAnswered(messageId, answer)` on completion. On re-mount, blocks check `answeredBlocks[messageId]` and render the answered state directly. This ensures:
- Scrolling away and back does not reset blocks
- Switching UI modes (panel → full-screen) does not reset blocks
- Blocks cannot be answered twice
---
## 10. Page-Specific Actions
### 10.1 Logs Explorer
**File:** `src/pages/LogsExplorer/aiActions.ts`
**Registered in:** `src/pages/LogsExplorer/index.tsx` via `usePageActions('logs-explorer', aiActions)`
| Action ID | autoApply | Description |
|---|---|---|
| `logs.runQuery` | `true` | Replace all filters in the query builder and re-run |
| `logs.addFilter` | `true` | Append a single `key / op / value` filter |
| `logs.changeView` | `true` | Switch between list / timeseries / table views |
| `logs.saveView` | `false` | Save current query as a named view (requires confirmation) |
**Critical implementation detail:** All query mutations use `redirectWithQueryBuilderData()`, not `handleSetQueryData`. The Logs Explorer's `QueryBuilderV2` is URL-driven — `compositeQuery` in the URL is the source of truth for displayed filters. `handleSetQueryData` updates React state only; `redirectWithQueryBuilderData` syncs the URL, making changes visible in the UI.
**Context shape provided to AI:**
```typescript
getContext: () => ({
currentFilters: currentQuery.builder.queryData[0].filters.items,
currentView: currentView, // 'list' | 'timeseries' | 'table'
})
```
---
## 11. Voice Input
### 11.1 Hook: useSpeechRecognition
**File:** `src/container/AIAssistant/hooks/useSpeechRecognition.ts`
```typescript
const { isListening, isSupported, start, stop, transcript, isFinal } =
useSpeechRecognition({ lang: 'en-US', onError });
```
Exposes `transcript` and `isFinal` as React state (not callbacks) so `ChatInput` reacts via `useEffect([transcript, isFinal])`, eliminating stale closure issues.
### 11.2 Interim vs Final Handling
```
onresult (isFinal=false) → pendingInterim = text → setTranscript(text), setIsFinal(false)
onresult (isFinal=true) → pendingInterim = '' → setTranscript(text), setIsFinal(true)
onend (pendingInterim) → setTranscript(pendingInterim), setIsFinal(true)
↑ fallback: Chrome often skips the final onresult when stop() is called manually
```
### 11.3 Text Accumulation in ChatInput
```
committedTextRef.current = '' // tracks finalized text (typed + confirmed speech)
isFinal=false (interim):
setText(committedTextRef.current + ' ' + transcript)
// textarea shows live speech; committedTextRef unchanged
isFinal=true (final):
committedTextRef.current += ' ' + transcript
setText(committedTextRef.current)
// both textarea and ref updated — text is now "committed"
User types manually:
setText(e.target.value)
committedTextRef.current = e.target.value
// keeps both in sync so next speech session appends correctly
```
---
## 12. File Attachments
`ChatInput` uses Ant Design `Upload` with `beforeUpload` returning `false` (prevents auto-upload). Files accumulate in `pendingFiles: UploadFile[]` state. On send, files are converted to data URIs (`fileToDataUrl`) and stored on the `Message` as `attachments[]`.
**Accepted types:** `image/*`, `.pdf`, `.txt`, `.log`, `.csv`, `.json`
**Rendered in MessageBubble:**
- Images → inline `<img>` preview
- Other files → file badge chip (name + type)
---
## 13. Development Mode (Mock)
Set `VITE_AI_MOCK=true` in `.env.local` to use the mock API instead of the real SSE endpoint.
```typescript
// store/useAIAssistantStore.ts
const USE_MOCK_AI = import.meta.env.VITE_AI_MOCK === 'true';
const chat = USE_MOCK_AI ? mockStreamChat : streamChat;
```
`mockStreamChat` implements the same `AsyncGenerator<SSEEvent>` interface as `streamChat`. It selects canned responses from keyword matching on the last user message and simulates word-by-word streaming with 1545ms random delays.
**Trigger keywords:**
| Keyword(s) | Response type |
|---|---|
| `filter logs`, `payment` + `error` | `ai-action`: logs.runQuery |
| `add filter` | `ai-action`: logs.addFilter |
| `change view` / `timeseries view` | `ai-action`: logs.changeView |
| `save view` | `ai-action`: logs.saveView |
| `error` / `exception` | Error rates table + trace snippet |
| `latency` / `p99` / `graph` | Line chart (p99 latency) |
| `bar` / `top service` | Bar chart (error count) |
| `pie` / `distribution` | Pie / doughnut chart |
| `timeseries` / `table` | Timeseries data table |
| `log` | Top log errors summary |
| `confirm` / `alert` / `anomal` | `ai-confirm` block |
| `environment` / `question` | `ai-question` (radio) |
| `level` / `select` / `filter` | `ai-question` (checkbox) |
---
## 14. Adding a New Page's Actions
### Step 1 — Create an aiActions file
```typescript
// src/pages/TracesExplorer/aiActions.ts
import { PageAction } from 'container/AIAssistant/pageActions/types';
interface FilterTracesParams {
service: string;
minDurationMs?: number;
}
export function tracesFilterAction(deps: {
currentQuery: Query;
redirectWithQueryBuilderData: (q: Query) => void;
}): PageAction<FilterTracesParams> {
return {
id: 'traces.filter', // globally unique: pageName.actionName
description: 'Filter traces by service name and minimum duration',
autoApply: true,
parameters: {
type: 'object',
properties: {
service: { type: 'string', description: 'Service name to filter by' },
minDurationMs: { type: 'number', description: 'Minimum span duration in ms' },
},
required: ['service'],
},
execute: async ({ service, minDurationMs }) => {
// Build updated query and redirect
deps.redirectWithQueryBuilderData(buildUpdatedQuery(service, minDurationMs));
return { summary: `Filtered traces for ${service}` };
},
getContext: () => ({
currentFilters: deps.currentQuery.builder.queryData[0].filters.items,
}),
};
}
```
### Step 2 — Register in the page component
```typescript
// src/pages/TracesExplorer/index.tsx
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
import { tracesFilterAction } from './aiActions';
function TracesExplorer() {
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const aiActions = useMemo(() => [
tracesFilterAction({ currentQuery, redirectWithQueryBuilderData }),
], [currentQuery, redirectWithQueryBuilderData]);
usePageActions('traces-explorer', aiActions);
// ... rest of component
}
```
**Rules:**
- `pageId` must be unique across pages (kebab-case convention)
- `actionId` must be globally unique — use `pageName.actionName` convention
- `actions` array **must be memoized** (`useMemo`) — identity change triggers re-registration
- For URL-driven state (QueryBuilder), always use the URL-sync API; never use `handleSetQueryData` alone
- `getContext()` should return only what the AI needs to make decisions — keep it minimal
---
## 15. Adding a New Block Type
### Step 1 — Create the component
```typescript
// src/container/AIAssistant/components/blocks/MyBlock.tsx
import { useContext } from 'react';
import MessageContext from '../MessageContext';
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
interface MyBlockPayload {
title: string;
items: string[];
}
export default function MyBlock({ content }: { content: string }): JSX.Element {
const payload = JSON.parse(content) as MyBlockPayload;
const { messageId } = useContext(MessageContext);
const markBlockAnswered = useAIAssistantStore(s => s.markBlockAnswered);
const answered = useAIAssistantStore(s => s.answeredBlocks[messageId]);
if (answered) return <div className="ai-block--answered">Done</div>;
return (
<div>
<h4>{payload.title}</h4>
{/* ... */}
</div>
);
}
```
### Step 2 — Register in index.ts
```typescript
// src/container/AIAssistant/components/blocks/index.ts
import MyBlock from './MyBlock';
BlockRegistry.register('myblock', MyBlock);
```
### Step 3 — AI emits the block tag
````markdown
```ai-myblock
{
"title": "Something",
"items": ["a", "b"]
}
```
````
---
## 16. Data Contracts
### 16.1 API Request
```typescript
POST /api/v1/assistant/threads
{
conversationId: string,
messages: Array<{
role: 'user' | 'assistant',
content: string // last user message includes [PAGE_CONTEXT]...[/PAGE_CONTEXT] prefix
}>
}
```
### 16.2 SSE Event Schema
```typescript
interface SSEEvent {
type: 'message';
messageId: string; // server-assigned; consistent across all chunks of one turn
role: 'assistant';
content: string; // incremental chunk — NOT cumulative
done: boolean; // true on the last event of a turn
actions: Array<{
id: string;
label: string;
kind: 'follow_up' | 'open_resource' | 'navigate' | 'apply_filter' | 'open_docs' | 'undo' | 'revert';
payload: Record<string, unknown>;
expiresAt: string | null; // ISO-8601 or null
}>;
}
```
### 16.3 ai-action Block Payload (embedded in content stream)
```typescript
{
actionId: string, // must match a registered PageAction.id
description: string, // shown in the confirmation card (autoApply=false)
parameters: Record<string, unknown> // must conform to the action's JSON Schema
}
```
### 16.4 PageAction Interface
```typescript
interface PageAction<TParams = Record<string, any>> {
id: string;
description: string;
parameters: JSONSchemaObject;
execute: (params: TParams) => Promise<{ summary: string; data?: unknown }>;
getContext?: () => unknown; // called on every sendMessage() to populate PAGE_CONTEXT
autoApply?: boolean; // default false
}
```
---
## 17. Key Design Decisions
### Context injection is wire-only
PAGE_CONTEXT is injected into the wire payload but never stored or shown in the UI. This keeps conversations readable, avoids polluting history with system context, and ensures the AI always gets fresh page state on every message rather than stale state from when the conversation started.
### URL-driven query builders require URL-sync APIs
Pages that use URL-driven state (e.g., `QueryBuilderV2` with `compositeQuery` URL param) **must** use the URL-sync API (`redirectWithQueryBuilderData`) when actions mutate query state. Using React `setState` alone does not update the URL, so the displayed filters do not change. This was the root cause of the first major bug in the Logs Explorer integration.
### autoApply co-located with action definition
The `autoApply` flag lives on the `PageAction` definition, not in the UI layer. The page that owns the action knows whether it is safe to apply without confirmation. Additive / reversible actions use `autoApply: true`. Actions that create persistent artifacts (saved views, alert rules) use `autoApply: false`.
### Transcript-as-state for voice input
`useSpeechRecognition` exposes `transcript` and `isFinal` as React state rather than using an `onTranscript` callback. The callback approach had a race condition: recognition events could fire before the `useEffect` that wired up the callback had run, leaving `onTranscriptRef.current` unset. State-based approach uses normal React reactivity with no timing dependency.
### Block answer persistence across re-mounts
Interactive blocks persist their answered state to `answeredBlocks[messageId]` in the Zustand store. Without this, switching UI modes or scrolling away and back would reset blocks to their unanswered state, allowing the user to re-submit answers and send duplicate messages to the AI.
### Panel resize is not persisted
Panel width resets to 380px on close/reopen. If persistence is needed, save `panelWidth` to `localStorage` in the drag `onMouseUp` handler and initialize `useState` from it.
### Mock API shares the same interface
`mockStreamChat` implements the same `AsyncGenerator<SSEEvent>` interface as `streamChat`. The store switches between them via `VITE_AI_MOCK=true`. This means the mock exercises the exact same store code path as production — no separate code branch to maintain.

View File

@@ -1,54 +0,0 @@
/**
* AIAssistantIcon — SigNoz AI Assistant icon (V2 — Minimal Line).
*
* Single-weight stroke outline of a bear face. Inherits `currentColor` so it
* adapts to any dark/light context automatically. The only hard-coded color is
* the SigNoz red (#E8432D) eye bar — one bold accent, nothing else.
*/
interface AIAssistantIconProps {
size?: number;
className?: string;
}
export default function AIAssistantIcon({
size = 24,
className,
}: AIAssistantIconProps): JSX.Element {
return (
<svg
width={size}
height={size}
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
aria-label="AI Assistant"
role="img"
>
{/* Ears */}
<path
d="M8 13.5 C8 8 5 6 5 11 C5 14 7 15.5 9.5 15.5"
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"
/>
<path
d="M24 13.5 C24 8 27 6 27 11 C27 14 25 15.5 22.5 15.5"
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"
/>
{/* Head */}
<rect x="7" y="12" width="18" height="15" rx="7"
stroke="currentColor" strokeWidth="1.5" fill="none" />
{/* Eye bar — SigNoz red, the only accent */}
<line x1="10" y1="18" x2="22" y2="18"
stroke="#E8432D" strokeWidth="2.5" strokeLinecap="round" />
<circle cx="12" cy="18" r="1" fill="#E8432D" />
<circle cx="20" cy="18" r="1" fill="#E8432D" />
{/* Nose */}
<ellipse cx="16" cy="23.5" rx="1.6" ry="1"
stroke="currentColor" strokeWidth="1.2" fill="none" />
</svg>
);
}

View File

@@ -1,120 +0,0 @@
import { useState } from 'react';
import { Button } from '@signozhq/button';
import { Check, Shield, X } from 'lucide-react';
import { useAIAssistantStore } from '../store/useAIAssistantStore';
import { PendingApproval } from '../types';
interface ApprovalCardProps {
conversationId: string;
approval: PendingApproval;
}
/**
* Rendered when the agent emits an `approval` SSE event.
* The agent has paused execution; the user must approve or reject
* before the stream resumes on a new execution.
*/
export default function ApprovalCard({
conversationId,
approval,
}: ApprovalCardProps): JSX.Element {
const approveAction = useAIAssistantStore((s) => s.approveAction);
const rejectAction = useAIAssistantStore((s) => s.rejectAction);
const isStreaming = useAIAssistantStore(
(s) => s.streams[conversationId]?.isStreaming ?? false,
);
const [decided, setDecided] = useState<'approved' | 'rejected' | null>(null);
const handleApprove = async (): Promise<void> => {
setDecided('approved');
await approveAction(conversationId, approval.approvalId);
};
const handleReject = async (): Promise<void> => {
setDecided('rejected');
await rejectAction(conversationId, approval.approvalId);
};
// After decision the card shows a compact confirmation row
if (decided === 'approved') {
return (
<div className="ai-approval ai-approval--decided">
<Check
size={13}
className="ai-approval__status-icon ai-approval__status-icon--ok"
/>
<span className="ai-approval__status-text">Approved resuming</span>
</div>
);
}
if (decided === 'rejected') {
return (
<div className="ai-approval ai-approval--decided">
<X
size={13}
className="ai-approval__status-icon ai-approval__status-icon--no"
/>
<span className="ai-approval__status-text">Rejected.</span>
</div>
);
}
return (
<div className="ai-approval">
<div className="ai-approval__header">
<Shield size={13} className="ai-approval__shield-icon" />
<span className="ai-approval__header-label">Action requires approval</span>
<span className="ai-approval__resource-badge">
{approval.actionType} · {approval.resourceType}
</span>
</div>
<p className="ai-approval__summary">{approval.summary}</p>
{approval.diff && (
<div className="ai-approval__diff">
{approval.diff.before !== undefined && (
<div className="ai-approval__diff-block ai-approval__diff-block--before">
<span className="ai-approval__diff-label">Before</span>
<pre className="ai-approval__diff-json">
{JSON.stringify(approval.diff.before, null, 2)}
</pre>
</div>
)}
{approval.diff.after !== undefined && (
<div className="ai-approval__diff-block ai-approval__diff-block--after">
<span className="ai-approval__diff-label">After</span>
<pre className="ai-approval__diff-json">
{JSON.stringify(approval.diff.after, null, 2)}
</pre>
</div>
)}
</div>
)}
<div className="ai-approval__actions">
<Button
variant="solid"
size="xs"
onClick={handleApprove}
disabled={isStreaming}
>
<Check size={12} />
Approve
</Button>
<Button
variant="outlined"
size="xs"
onClick={handleReject}
disabled={isStreaming}
>
<X size={12} />
Reject
</Button>
</div>
</div>
);
}

View File

@@ -1,257 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Button } from '@signozhq/button';
import { Tooltip } from '@signozhq/tooltip';
import type { UploadFile } from 'antd';
import { Upload } from 'antd';
import { Mic, Paperclip, Send, Square, X } from 'lucide-react';
import { useSpeechRecognition } from '../hooks/useSpeechRecognition';
import { MessageAttachment } from '../types';
interface ChatInputProps {
onSend: (text: string, attachments?: MessageAttachment[]) => void;
onCancel?: () => void;
disabled?: boolean;
isStreaming?: boolean;
}
function fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (): void => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
export default function ChatInput({
onSend,
onCancel,
disabled,
isStreaming = false,
}: ChatInputProps): JSX.Element {
const [text, setText] = useState('');
const [pendingFiles, setPendingFiles] = useState<UploadFile[]>([]);
// Stores the already-committed final text so interim results don't overwrite it
const committedTextRef = useRef('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Focus the textarea when this component mounts (panel/modal open)
useEffect(() => {
textareaRef.current?.focus();
}, []);
const handleSend = useCallback(async () => {
const trimmed = text.trim();
if (!trimmed && pendingFiles.length === 0) {
return;
}
const attachments: MessageAttachment[] = await Promise.all(
pendingFiles.map(async (f) => {
const dataUrl = f.originFileObj ? await fileToDataUrl(f.originFileObj) : '';
return {
name: f.name,
type: f.type ?? 'application/octet-stream',
dataUrl,
};
}),
);
onSend(trimmed, attachments.length > 0 ? attachments : undefined);
setText('');
committedTextRef.current = '';
setPendingFiles([]);
textareaRef.current?.focus();
}, [text, pendingFiles, onSend]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
},
[handleSend],
);
const removeFile = useCallback((uid: string) => {
setPendingFiles((prev) => prev.filter((f) => f.uid !== uid));
}, []);
// ── Voice input ────────────────────────────────────────────────────────────
const { isListening, isSupported, start, discard } = useSpeechRecognition({
onTranscript: (transcriptText, isFinal) => {
if (isFinal) {
// Commit: append to whatever the user has already typed
const separator = committedTextRef.current ? ' ' : '';
const next = committedTextRef.current + separator + transcriptText;
committedTextRef.current = next;
setText(next);
} else {
// Interim: live preview appended to committed text, not yet persisted
const separator = committedTextRef.current ? ' ' : '';
setText(committedTextRef.current + separator + transcriptText);
}
},
});
// Stop recording and immediately send whatever is in the textarea.
const handleStopAndSend = useCallback(async () => {
// Promote the displayed text (interim included) to committed so handleSend sees it.
committedTextRef.current = text;
// Stop recognition without triggering onTranscript again (would double-append).
discard();
await handleSend();
}, [text, discard, handleSend]);
// Stop recording and revert the textarea to what it was before voice started.
const handleDiscard = useCallback(() => {
discard();
setText(committedTextRef.current);
textareaRef.current?.focus();
}, [discard]);
return (
<div className="ai-assistant-input">
{pendingFiles.length > 0 && (
<div className="ai-assistant-input__attachments">
{pendingFiles.map((f) => (
<div key={f.uid} className="ai-assistant-input__attachment-chip">
<span className="ai-assistant-input__attachment-name">{f.name}</span>
<Button
variant="ghost"
size="xs"
className="ai-assistant-input__attachment-remove"
onClick={(): void => removeFile(f.uid)}
aria-label={`Remove ${f.name}`}
>
<X size={11} />
</Button>
</div>
))}
</div>
)}
<div className="ai-assistant-input__row">
<Upload
multiple
accept="image/*,.pdf,.txt,.log,.csv,.json"
showUploadList={false}
beforeUpload={(file): boolean => {
setPendingFiles((prev) => [
...prev,
{
uid: file.uid,
name: file.name,
type: file.type,
originFileObj: file,
},
]);
return false;
}}
>
<Button
variant="ghost"
size="xs"
disabled={disabled}
aria-label="Attach file"
>
<Paperclip size={14} />
</Button>
</Upload>
<textarea
ref={textareaRef}
className="ai-assistant-input__textarea"
placeholder="Ask anything… (Shift+Enter for new line)"
value={text}
onChange={(e): void => {
setText(e.target.value);
// Keep committed text in sync when the user edits manually
committedTextRef.current = e.target.value;
}}
onKeyDown={handleKeyDown}
disabled={disabled}
rows={1}
/>
{isListening ? (
<div className="ai-mic-recording">
<button
type="button"
className="ai-mic-recording__discard"
onClick={handleDiscard}
aria-label="Discard recording"
>
<X size={12} />
</button>
<span className="ai-mic-recording__waves" aria-hidden="true">
<span />
<span />
<span />
<span />
<span />
<span />
<span />
<span />
</span>
<button
type="button"
className="ai-mic-recording__stop"
onClick={handleStopAndSend}
aria-label="Stop and send"
>
<Square size={9} fill="currentColor" strokeWidth={0} />
</button>
</div>
) : (
<Tooltip
title={
!isSupported
? 'Voice input not supported in this browser'
: 'Voice input'
}
>
<Button
variant="ghost"
size="xs"
onClick={start}
disabled={disabled || !isSupported}
aria-label="Start voice input"
className="ai-mic-btn"
>
<Mic size={14} />
</Button>
</Tooltip>
)}
{isStreaming && onCancel ? (
<Tooltip title="Stop generating">
<Button
variant="solid"
size="xs"
className="ai-assistant-input__send-btn ai-assistant-input__send-btn--stop"
onClick={onCancel}
aria-label="Stop generating"
>
<Square size={10} fill="currentColor" strokeWidth={0} />
</Button>
</Tooltip>
) : (
<Button
variant="solid"
size="xs"
className="ai-assistant-input__send-btn"
onClick={isListening ? handleStopAndSend : handleSend}
disabled={disabled || (!text.trim() && pendingFiles.length === 0)}
aria-label="Send message"
>
<Send size={14} />
</Button>
)}
</div>
</div>
);
}

View File

@@ -1,208 +0,0 @@
import { useState } from 'react';
import { Button } from '@signozhq/button';
import { HelpCircle, Send } from 'lucide-react';
import { useAIAssistantStore } from '../store/useAIAssistantStore';
import { ClarificationField, PendingClarification } from '../types';
interface ClarificationFormProps {
conversationId: string;
clarification: PendingClarification;
}
/**
* Rendered when the agent emits a `clarification` SSE event.
* Dynamically renders form fields based on the `fields` array and
* submits answers to resume the agent on a new execution.
*/
export default function ClarificationForm({
conversationId,
clarification,
}: ClarificationFormProps): JSX.Element {
const submitClarification = useAIAssistantStore((s) => s.submitClarification);
const isStreaming = useAIAssistantStore(
(s) => s.streams[conversationId]?.isStreaming ?? false,
);
const initialAnswers = Object.fromEntries(
clarification.fields.map((f) => [f.id, f.default ?? '']),
);
const [answers, setAnswers] = useState<Record<string, unknown>>(
initialAnswers,
);
const [submitted, setSubmitted] = useState(false);
const setField = (id: string, value: unknown): void => {
setAnswers((prev) => ({ ...prev, [id]: value }));
};
const handleSubmit = async (): Promise<void> => {
setSubmitted(true);
await submitClarification(
conversationId,
clarification.clarificationId,
answers,
);
};
if (submitted) {
return (
<div className="ai-clarification ai-clarification--submitted">
<Send size={13} className="ai-clarification__icon" />
<span className="ai-clarification__status-text">
Answers submitted resuming
</span>
</div>
);
}
return (
<div className="ai-clarification">
<div className="ai-clarification__header">
<HelpCircle size={13} className="ai-clarification__header-icon" />
<span className="ai-clarification__header-label">A few details needed</span>
</div>
<p className="ai-clarification__message">{clarification.message}</p>
<div className="ai-clarification__fields">
{clarification.fields.map((field) => (
<FieldInput
key={field.id}
field={field}
value={answers[field.id]}
onChange={(val): void => setField(field.id, val)}
/>
))}
</div>
<div className="ai-clarification__actions">
<Button
variant="solid"
size="xs"
onClick={handleSubmit}
disabled={isStreaming}
>
<Send size={12} />
Submit
</Button>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Field renderer — handles text, number, select, radio, checkbox
// ---------------------------------------------------------------------------
interface FieldInputProps {
field: ClarificationField;
value: unknown;
onChange: (value: unknown) => void;
}
function FieldInput({ field, value, onChange }: FieldInputProps): JSX.Element {
const { id, type, label, required, options } = field;
if (type === 'select' && options) {
return (
<div className="ai-clarification__field">
<label className="ai-clarification__label" htmlFor={id}>
{label}
{required && <span className="ai-clarification__required">*</span>}
</label>
<select
id={id}
className="ai-clarification__select"
value={String(value ?? '')}
onChange={(e): void => onChange(e.target.value)}
>
<option value="">Select</option>
{options.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</div>
);
}
if (type === 'radio' && options) {
return (
<div className="ai-clarification__field">
<span className="ai-clarification__label">
{label}
{required && <span className="ai-clarification__required">*</span>}
</span>
<div className="ai-clarification__radio-group">
{options.map((opt) => (
<label key={opt} className="ai-clarification__radio-label">
<input
type="radio"
name={id}
value={opt}
checked={value === opt}
onChange={(): void => onChange(opt)}
className="ai-clarification__radio"
/>
{opt}
</label>
))}
</div>
</div>
);
}
if (type === 'checkbox' && options) {
const selected = Array.isArray(value) ? (value as string[]) : [];
const toggle = (opt: string): void => {
onChange(
selected.includes(opt)
? selected.filter((v) => v !== opt)
: [...selected, opt],
);
};
return (
<div className="ai-clarification__field">
<span className="ai-clarification__label">
{label}
{required && <span className="ai-clarification__required">*</span>}
</span>
<div className="ai-clarification__checkbox-group">
{options.map((opt) => (
<label key={opt} className="ai-clarification__checkbox-label">
<input
type="checkbox"
checked={selected.includes(opt)}
onChange={(): void => toggle(opt)}
className="ai-clarification__checkbox"
/>
{opt}
</label>
))}
</div>
</div>
);
}
// text / number (default)
return (
<div className="ai-clarification__field">
<label className="ai-clarification__label" htmlFor={id}>
{label}
{required && <span className="ai-clarification__required">*</span>}
</label>
<input
id={id}
type={type === 'number' ? 'number' : 'text'}
className="ai-clarification__input"
value={String(value ?? '')}
onChange={(e): void =>
onChange(type === 'number' ? Number(e.target.value) : e.target.value)
}
placeholder={label}
/>
</div>
);
}

View File

@@ -1,158 +0,0 @@
import { KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import { Button } from '@signozhq/button';
import { Tooltip } from '@signozhq/tooltip';
import { MessageSquare, Pencil, Trash2 } from 'lucide-react';
import { Conversation } from '../types';
interface ConversationItemProps {
conversation: Conversation;
isActive: boolean;
onSelect: (id: string) => void;
onRename: (id: string, title: string) => void;
onDelete: (id: string) => void;
}
function formatRelativeTime(ts: number): string {
const diff = Date.now() - ts;
const mins = Math.floor(diff / 60_000);
if (mins < 1) {
return 'just now';
}
if (mins < 60) {
return `${mins}m ago`;
}
const hrs = Math.floor(mins / 60);
if (hrs < 24) {
return `${hrs}h ago`;
}
const days = Math.floor(hrs / 24);
if (days < 7) {
return `${days}d ago`;
}
return new Date(ts).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
});
}
export default function ConversationItem({
conversation,
isActive,
onSelect,
onRename,
onDelete,
}: ConversationItemProps): JSX.Element {
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const displayTitle = conversation.title ?? 'New conversation';
const ts = conversation.updatedAt ?? conversation.createdAt;
const startEditing = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
setEditValue(conversation.title ?? '');
setIsEditing(true);
},
[conversation.title],
);
useEffect(() => {
if (isEditing) {
inputRef.current?.focus();
inputRef.current?.select();
}
}, [isEditing]);
const commitEdit = useCallback(() => {
onRename(conversation.id, editValue);
setIsEditing(false);
}, [conversation.id, editValue, onRename]);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
commitEdit();
}
if (e.key === 'Escape') {
setIsEditing(false);
}
},
[commitEdit],
);
const handleDelete = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onDelete(conversation.id);
},
[conversation.id, onDelete],
);
return (
<div
className={`ai-history__item${isActive ? ' ai-history__item--active' : ''}`}
onClick={(): void => onSelect(conversation.id)}
role="button"
tabIndex={0}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === ' ') {
onSelect(conversation.id);
}
}}
>
<MessageSquare size={12} className="ai-history__item-icon" />
<div className="ai-history__item-body">
{isEditing ? (
<input
ref={inputRef}
className="ai-history__item-input"
value={editValue}
onChange={(e): void => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={commitEdit}
onClick={(e): void => e.stopPropagation()}
maxLength={80}
/>
) : (
<>
<span className="ai-history__item-title" title={displayTitle}>
{displayTitle}
</span>
<span className="ai-history__item-time">{formatRelativeTime(ts)}</span>
</>
)}
</div>
{!isEditing && (
<div className="ai-history__item-actions">
<Tooltip title="Rename">
<Button
variant="ghost"
size="xs"
className="ai-history__item-btn"
onClick={startEditing}
aria-label="Rename conversation"
>
<Pencil size={11} />
</Button>
</Tooltip>
<Tooltip title="Delete">
<Button
variant="ghost"
size="xs"
className="ai-history__item-btn ai-history__item-btn--danger"
onClick={handleDelete}
aria-label="Delete conversation"
>
<Trash2 size={11} />
</Button>
</Tooltip>
</div>
)}
</div>
);
}

View File

@@ -1,146 +0,0 @@
import { useEffect, useMemo } from 'react';
import { Button } from '@signozhq/button';
import { Loader2, Plus } from 'lucide-react';
import { useAIAssistantStore } from '../store/useAIAssistantStore';
import { Conversation } from '../types';
import ConversationItem from './ConversationItem';
interface HistorySidebarProps {
/** Called when a conversation is selected — lets the parent navigate if needed */
onSelect?: (id: string) => void;
}
function groupByDate(
conversations: Conversation[],
): { label: string; items: Conversation[] }[] {
const now = Date.now();
const DAY = 86_400_000;
const groups: Record<string, Conversation[]> = {
Today: [],
Yesterday: [],
'Last 7 days': [],
'Last 30 days': [],
Older: [],
};
for (const conv of conversations) {
const age = now - (conv.updatedAt ?? conv.createdAt);
if (age < DAY) {
groups.Today.push(conv);
} else if (age < 2 * DAY) {
groups.Yesterday.push(conv);
} else if (age < 7 * DAY) {
groups['Last 7 days'].push(conv);
} else if (age < 30 * DAY) {
groups['Last 30 days'].push(conv);
} else {
groups.Older.push(conv);
}
}
return Object.entries(groups)
.filter(([, items]) => items.length > 0)
.map(([label, items]) => ({ label, items }));
}
export default function HistorySidebar({
onSelect,
}: HistorySidebarProps): JSX.Element {
const conversations = useAIAssistantStore((s) => s.conversations);
const activeConversationId = useAIAssistantStore(
(s) => s.activeConversationId,
);
const isLoadingThreads = useAIAssistantStore((s) => s.isLoadingThreads);
const startNewConversation = useAIAssistantStore(
(s) => s.startNewConversation,
);
const setActiveConversation = useAIAssistantStore(
(s) => s.setActiveConversation,
);
const loadThread = useAIAssistantStore((s) => s.loadThread);
const fetchThreads = useAIAssistantStore((s) => s.fetchThreads);
const deleteConversation = useAIAssistantStore((s) => s.deleteConversation);
const renameConversation = useAIAssistantStore((s) => s.renameConversation);
// Fetch thread history from backend on mount
useEffect(() => {
fetchThreads();
}, [fetchThreads]);
const sorted = useMemo(
() =>
Object.values(conversations).sort(
(a, b) => (b.updatedAt ?? b.createdAt) - (a.updatedAt ?? a.createdAt),
),
[conversations],
);
const groups = useMemo(() => groupByDate(sorted), [sorted]);
const handleSelect = (id: string): void => {
const conv = conversations[id];
if (conv?.threadId) {
// Always load from backend — refreshes messages and reconnects
// to active execution if the thread is still busy.
loadThread(conv.threadId);
} else {
// Local-only conversation (no backend thread yet)
setActiveConversation(id);
}
onSelect?.(id);
};
const handleNew = (): void => {
const id = startNewConversation();
onSelect?.(id);
};
return (
<div className="ai-history">
<div className="ai-history__header">
<span className="ai-history__heading">History</span>
<Button
variant="ghost"
size="xs"
onClick={handleNew}
aria-label="New conversation"
className="ai-history__new-btn"
>
<Plus size={13} />
New
</Button>
</div>
<div className="ai-history__list">
{isLoadingThreads && groups.length === 0 && (
<div className="ai-history__loading">
<Loader2 size={16} className="ai-history__spinner" />
Loading conversations
</div>
)}
{!isLoadingThreads && groups.length === 0 && (
<p className="ai-history__empty">No conversations yet.</p>
)}
{groups.map(({ label, items }) => (
<div key={label} className="ai-history__group">
<span className="ai-history__group-label">{label}</span>
{items.map((conv) => (
<ConversationItem
key={conv.id}
conversation={conv}
isActive={conv.id === activeConversationId}
onSelect={handleSelect}
onRename={renameConversation}
onDelete={deleteConversation}
/>
))}
</div>
))}
</div>
</div>
);
}

View File

@@ -1,141 +0,0 @@
import React from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
// Side-effect: registers all built-in block types into the BlockRegistry
import './blocks';
import { Message, MessageBlock } from '../types';
import { RichCodeBlock } from './blocks';
import { MessageContext } from './MessageContext';
import MessageFeedback from './MessageFeedback';
import ThinkingStep from './ThinkingStep';
import ToolCallStep from './ToolCallStep';
interface MessageBubbleProps {
message: Message;
onRegenerate?: () => void;
isLastAssistant?: boolean;
}
/**
* react-markdown renders fenced code blocks as <pre><code>...</code></pre>.
* When RichCodeBlock replaces <code> with a custom AI block component, the
* block ends up wrapped in <pre> which forces monospace font and white-space:pre.
* This renderer detects that case and unwraps the <pre>.
*/
function SmartPre({ children }: { children?: React.ReactNode }): JSX.Element {
const childArr = React.Children.toArray(children);
if (childArr.length === 1) {
const child = childArr[0];
// If the code component returned something other than a <code> element
// (i.e. a custom AI block), render without the <pre> wrapper.
if (React.isValidElement(child) && child.type !== 'code') {
return <>{child}</>;
}
}
return <pre>{children}</pre>;
}
const MD_PLUGINS = [remarkGfm];
const MD_COMPONENTS = { code: RichCodeBlock, pre: SmartPre };
/** Renders a single MessageBlock by type. */
function renderBlock(block: MessageBlock, index: number): JSX.Element {
switch (block.type) {
case 'thinking':
return <ThinkingStep key={index} content={block.content} />;
case 'tool_call':
// Blocks in a persisted message are always complete — done is always true.
return (
<ToolCallStep
key={index}
toolCall={{
toolName: block.toolName,
input: block.toolInput,
result: block.result,
done: true,
}}
/>
);
case 'text':
default:
return (
<ReactMarkdown
key={index}
className="ai-message__markdown"
remarkPlugins={MD_PLUGINS}
components={MD_COMPONENTS}
>
{block.content}
</ReactMarkdown>
);
}
}
export default function MessageBubble({
message,
onRegenerate,
isLastAssistant = false,
}: MessageBubbleProps): JSX.Element {
const isUser = message.role === 'user';
const hasBlocks = !isUser && message.blocks && message.blocks.length > 0;
return (
<div
className={`ai-message ai-message--${isUser ? 'user' : 'assistant'}`}
data-testid={`ai-message-${message.id}`}
>
<div className="ai-message__body">
<div className="ai-message__bubble">
{message.attachments && message.attachments.length > 0 && (
<div className="ai-message__attachments">
{message.attachments.map((att) => {
const isImage = att.type.startsWith('image/');
return isImage ? (
<img
key={att.name}
src={att.dataUrl}
alt={att.name}
className="ai-message__attachment-image"
/>
) : (
<div key={att.name} className="ai-message__attachment-file">
{att.name}
</div>
);
})}
</div>
)}
{isUser ? (
<p className="ai-message__text">{message.content}</p>
) : hasBlocks ? (
<MessageContext.Provider value={{ messageId: message.id }}>
{/* eslint-disable-next-line react/no-array-index-key */}
{message.blocks!.map((block, i) => renderBlock(block, i))}
</MessageContext.Provider>
) : (
<MessageContext.Provider value={{ messageId: message.id }}>
<ReactMarkdown
className="ai-message__markdown"
remarkPlugins={MD_PLUGINS}
components={MD_COMPONENTS}
>
{message.content}
</ReactMarkdown>
</MessageContext.Provider>
)}
</div>
{!isUser && (
<MessageFeedback
message={message}
onRegenerate={onRegenerate}
isLastAssistant={isLastAssistant}
/>
)}
</div>
</div>
);
}

View File

@@ -1,12 +0,0 @@
import { createContext, useContext } from 'react';
interface MessageContextValue {
messageId: string;
}
export const MessageContext = createContext<MessageContextValue>({
messageId: '',
});
export const useMessageContext = (): MessageContextValue =>
useContext(MessageContext);

View File

@@ -1,165 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import { Button } from '@signozhq/button';
import { Tooltip } from '@signozhq/tooltip';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { Check, Copy, RefreshCw, ThumbsDown, ThumbsUp } from 'lucide-react';
import { useTimezone } from 'providers/Timezone';
import { useAIAssistantStore } from '../store/useAIAssistantStore';
import { FeedbackRating, Message } from '../types';
interface MessageFeedbackProps {
message: Message;
onRegenerate?: () => void;
isLastAssistant?: boolean;
}
function formatRelativeTime(timestamp: number): string {
const diffMs = Date.now() - timestamp;
const diffSec = Math.floor(diffMs / 1000);
if (diffSec < 10) {
return 'just now';
}
if (diffSec < 60) {
return `${diffSec}s ago`;
}
const diffMin = Math.floor(diffSec / 60);
if (diffMin < 60) {
return `${diffMin} min${diffMin === 1 ? '' : 's'} ago`;
}
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) {
return `${diffHr} hr${diffHr === 1 ? '' : 's'} ago`;
}
const diffDay = Math.floor(diffHr / 24);
return `${diffDay} day${diffDay === 1 ? '' : 's'} ago`;
}
export default function MessageFeedback({
message,
onRegenerate,
isLastAssistant = false,
}: MessageFeedbackProps): JSX.Element {
const [copied, setCopied] = useState(false);
const [, copyToClipboard] = useCopyToClipboard();
const submitMessageFeedback = useAIAssistantStore(
(s) => s.submitMessageFeedback,
);
const { formatTimezoneAdjustedTimestamp } = useTimezone();
// Local vote state — initialised from persisted feedbackRating, updated
// immediately on click so the UI responds without waiting for the API.
const [vote, setVote] = useState<FeedbackRating | null>(
message.feedbackRating ?? null,
);
const [relativeTime, setRelativeTime] = useState(() =>
formatRelativeTime(message.createdAt),
);
const absoluteTime = useMemo(
() =>
formatTimezoneAdjustedTimestamp(
message.createdAt,
DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS,
),
[message.createdAt, formatTimezoneAdjustedTimestamp],
);
// Tick relative time every 30 s
useEffect(() => {
const id = setInterval(() => {
setRelativeTime(formatRelativeTime(message.createdAt));
}, 30_000);
return (): void => clearInterval(id);
}, [message.createdAt]);
const handleCopy = useCallback((): void => {
copyToClipboard(message.content);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}, [copyToClipboard, message.content]);
const handleVote = useCallback(
(rating: FeedbackRating): void => {
if (vote === rating) {
return;
}
setVote(rating);
submitMessageFeedback(message.id, rating);
},
[vote, message.id, submitMessageFeedback],
);
const feedbackClass = `ai-message-feedback${
isLastAssistant ? ' ai-message-feedback--visible' : ''
}`;
return (
<div className={feedbackClass}>
<div className="ai-message-feedback__actions">
<Tooltip title={copied ? 'Copied!' : 'Copy'}>
<Button
className={`ai-message-feedback__btn${
copied ? ' ai-message-feedback__btn--active' : ''
}`}
size="xs"
variant="ghost"
onClick={handleCopy}
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</Button>
</Tooltip>
<Tooltip title="Good response">
<Button
className={`ai-message-feedback__btn${
vote === 'positive' ? ' ai-message-feedback__btn--voted-up' : ''
}`}
size="xs"
variant="ghost"
onClick={(): void => handleVote('positive')}
>
<ThumbsUp size={12} />
</Button>
</Tooltip>
<Tooltip title="Bad response">
<Button
className={`ai-message-feedback__btn${
vote === 'negative' ? ' ai-message-feedback__btn--voted-down' : ''
}`}
size="xs"
variant="ghost"
onClick={(): void => handleVote('negative')}
>
<ThumbsDown size={12} />
</Button>
</Tooltip>
{onRegenerate && (
<Tooltip title="Regenerate">
<Button
className="ai-message-feedback__btn"
size="xs"
variant="ghost"
onClick={onRegenerate}
>
<RefreshCw size={12} />
</Button>
</Tooltip>
)}
</div>
<span className="ai-message-feedback__time">
{relativeTime} · {absoluteTime}
</span>
</div>
);
}

View File

@@ -1,109 +0,0 @@
import React from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import {
PendingApproval,
PendingClarification,
StreamingEventItem,
} from '../types';
import ApprovalCard from './ApprovalCard';
import { RichCodeBlock } from './blocks';
import ClarificationForm from './ClarificationForm';
import ThinkingStep from './ThinkingStep';
import ToolCallStep from './ToolCallStep';
function SmartPre({ children }: { children?: React.ReactNode }): JSX.Element {
const childArr = React.Children.toArray(children);
if (childArr.length === 1) {
const child = childArr[0];
if (React.isValidElement(child) && child.type !== 'code') {
return <>{child}</>;
}
}
return <pre>{children}</pre>;
}
const MD_PLUGINS = [remarkGfm];
const MD_COMPONENTS = { code: RichCodeBlock, pre: SmartPre };
/** Human-readable labels for execution status codes shown before any events arrive. */
const STATUS_LABEL: Record<string, string> = {
queued: 'Queued…',
running: 'Thinking…',
awaiting_approval: 'Waiting for your approval…',
awaiting_clarification: 'Waiting for your input…',
resumed: 'Resumed…',
};
interface StreamingMessageProps {
conversationId: string;
/** Ordered timeline of text and tool-call events in arrival order. */
events: StreamingEventItem[];
status?: string;
pendingApproval?: PendingApproval | null;
pendingClarification?: PendingClarification | null;
}
export default function StreamingMessage({
conversationId,
events,
status = '',
pendingApproval = null,
pendingClarification = null,
}: StreamingMessageProps): JSX.Element {
const statusLabel = STATUS_LABEL[status] ?? '';
const isEmpty =
events.length === 0 && !pendingApproval && !pendingClarification;
return (
<div className="ai-message ai-message--assistant ai-message--streaming">
<div className="ai-message__bubble">
{/* Status pill or typing indicator — only before any events arrive */}
{isEmpty && statusLabel && (
<span className="ai-streaming-status">{statusLabel}</span>
)}
{isEmpty && !statusLabel && (
<span className="ai-message__typing-indicator">
<span />
<span />
<span />
</span>
)}
{/* eslint-disable react/no-array-index-key */}
{/* Events rendered in arrival order: text, thinking, and tool calls interleaved */}
{events.map((event, i) => {
if (event.kind === 'tool') {
return <ToolCallStep key={i} toolCall={event.toolCall} />;
}
if (event.kind === 'thinking') {
return <ThinkingStep key={i} content={event.content} />;
}
return (
<ReactMarkdown
key={i}
className="ai-message__markdown"
remarkPlugins={MD_PLUGINS}
components={MD_COMPONENTS}
>
{event.content}
</ReactMarkdown>
);
})}
{/* eslint-enable react/no-array-index-key */}
{/* Approval / clarification cards appended after any streamed text */}
{pendingApproval && (
<ApprovalCard conversationId={conversationId} approval={pendingApproval} />
)}
{pendingClarification && (
<ClarificationForm
conversationId={conversationId}
clarification={pendingClarification}
/>
)}
</div>
</div>
);
}

View File

@@ -1,38 +0,0 @@
import { useState } from 'react';
import { Brain, ChevronDown, ChevronRight } from 'lucide-react';
interface ThinkingStepProps {
content: string;
}
/** Displays a collapsible thinking/reasoning block. */
export default function ThinkingStep({
content,
}: ThinkingStepProps): JSX.Element {
const [expanded, setExpanded] = useState(false);
return (
<div className="ai-thinking-step">
<button
type="button"
className="ai-thinking-step__header"
onClick={(): void => setExpanded((v) => !v)}
aria-expanded={expanded}
>
<Brain size={12} className="ai-thinking-step__icon" />
<span className="ai-thinking-step__label">Thinking</span>
{expanded ? (
<ChevronDown size={11} className="ai-thinking-step__chevron" />
) : (
<ChevronRight size={11} className="ai-thinking-step__chevron" />
)}
</button>
{expanded && (
<div className="ai-thinking-step__body">
<p className="ai-thinking-step__content">{content}</p>
</div>
)}
</div>
);
}

View File

@@ -1,73 +0,0 @@
import { useState } from 'react';
import { ChevronDown, ChevronRight, Loader2, Wrench } from 'lucide-react';
import { StreamingToolCall } from '../types';
interface ToolCallStepProps {
toolCall: StreamingToolCall;
}
/** Displays a single tool invocation, collapsible, with in/out detail. */
export default function ToolCallStep({
toolCall,
}: ToolCallStepProps): JSX.Element {
const [expanded, setExpanded] = useState(false);
const { toolName, input, result, done } = toolCall;
// Format tool name: "signoz_get_dashboard" → "Get Dashboard"
const label = toolName
.replace(/^[a-z]+_/, '') // strip prefix like "signoz_"
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
return (
<div
className={`ai-tool-step ${
done ? 'ai-tool-step--done' : 'ai-tool-step--running'
}`}
>
<button
type="button"
className="ai-tool-step__header"
onClick={(): void => setExpanded((v) => !v)}
aria-expanded={expanded}
>
{done ? (
<Wrench
size={12}
className="ai-tool-step__icon ai-tool-step__icon--done"
/>
) : (
<Loader2
size={12}
className="ai-tool-step__icon ai-tool-step__icon--spin"
/>
)}
<span className="ai-tool-step__label">{label}</span>
<span className="ai-tool-step__tool-name">{toolName}</span>
{expanded ? (
<ChevronDown size={11} className="ai-tool-step__chevron" />
) : (
<ChevronRight size={11} className="ai-tool-step__chevron" />
)}
</button>
{expanded && (
<div className="ai-tool-step__body">
<div className="ai-tool-step__section">
<span className="ai-tool-step__section-label">Input</span>
<pre className="ai-tool-step__json">{JSON.stringify(input, null, 2)}</pre>
</div>
{done && result !== undefined && (
<div className="ai-tool-step__section">
<span className="ai-tool-step__section-label">Output</span>
<pre className="ai-tool-step__json">
{typeof result === 'string' ? result : JSON.stringify(result, null, 2)}
</pre>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -1,159 +0,0 @@
import { useCallback, useEffect, useRef } from 'react';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { Activity, AlertTriangle, BarChart3, Search, Zap } from 'lucide-react';
import { useAIAssistantStore } from '../store/useAIAssistantStore';
import { Message, StreamingEventItem } from '../types';
import AIAssistantIcon from './AIAssistantIcon';
import MessageBubble from './MessageBubble';
import StreamingMessage from './StreamingMessage';
const SUGGESTIONS = [
{
icon: AlertTriangle,
text: 'Show me the top errors in the last hour',
},
{
icon: Activity,
text: 'What services have the highest latency?',
},
{
icon: BarChart3,
text: 'Give me an overview of system health',
},
{
icon: Search,
text: 'Find slow database queries',
},
{
icon: Zap,
text: 'Which endpoints have the most 5xx errors?',
},
];
const EMPTY_EVENTS: StreamingEventItem[] = [];
interface VirtualizedMessagesProps {
conversationId: string;
messages: Message[];
isStreaming: boolean;
}
export default function VirtualizedMessages({
conversationId,
messages,
isStreaming,
}: VirtualizedMessagesProps): JSX.Element {
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
const streamingStatus = useAIAssistantStore(
(s) => s.streams[conversationId]?.streamingStatus ?? '',
);
const streamingEvents = useAIAssistantStore(
(s) => s.streams[conversationId]?.streamingEvents ?? EMPTY_EVENTS,
);
const pendingApproval = useAIAssistantStore(
(s) => s.streams[conversationId]?.pendingApproval ?? null,
);
const pendingClarification = useAIAssistantStore(
(s) => s.streams[conversationId]?.pendingClarification ?? null,
);
const virtuosoRef = useRef<VirtuosoHandle>(null);
const lastUserMessage = [...messages].reverse().find((m) => m.role === 'user');
const handleRegenerate = useCallback((): void => {
if (lastUserMessage && !isStreaming) {
sendMessage(lastUserMessage.content, lastUserMessage.attachments);
}
}, [lastUserMessage, isStreaming, sendMessage]);
// Scroll to bottom on new messages, streaming progress, or interactive cards
useEffect(() => {
virtuosoRef.current?.scrollToIndex({
index: 'LAST',
behavior: 'smooth',
});
}, [
messages.length,
streamingEvents.length,
isStreaming,
pendingApproval,
pendingClarification,
]);
const followOutput = useCallback(
(atBottom: boolean): false | 'smooth' =>
atBottom || isStreaming ? 'smooth' : false,
[isStreaming],
);
const showStreamingSlot =
isStreaming || Boolean(pendingApproval) || Boolean(pendingClarification);
if (messages.length === 0 && !showStreamingSlot) {
return (
<div className="ai-messages__empty">
<div className="ai-empty__icon">
<AIAssistantIcon size={40} />
</div>
<h3 className="ai-empty__title">SigNoz AI Assistant</h3>
<p className="ai-empty__subtitle">
Ask questions about your traces, logs, metrics, and infrastructure.
</p>
<div className="ai-empty__suggestions">
{SUGGESTIONS.map((s) => (
<button
key={s.text}
type="button"
className="ai-empty__chip"
onClick={(): void => {
sendMessage(s.text);
}}
>
<s.icon size={14} />
{s.text}
</button>
))}
</div>
</div>
);
}
const totalCount = messages.length + (showStreamingSlot ? 1 : 0);
return (
<Virtuoso
ref={virtuosoRef}
className="ai-messages"
totalCount={totalCount}
followOutput={followOutput}
initialTopMostItemIndex={Math.max(0, totalCount - 1)}
itemContent={(index): JSX.Element => {
if (index < messages.length) {
const msg = messages[index];
const isLastAssistant =
msg.role === 'assistant' &&
messages.slice(index + 1).every((m) => m.role !== 'assistant');
return (
<MessageBubble
message={msg}
onRegenerate={
isLastAssistant && !showStreamingSlot ? handleRegenerate : undefined
}
isLastAssistant={isLastAssistant}
/>
);
}
return (
<StreamingMessage
conversationId={conversationId}
events={streamingEvents}
status={streamingStatus}
pendingApproval={pendingApproval}
pendingClarification={pendingClarification}
/>
);
}}
/>
);
}

View File

@@ -1,210 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { Button } from '@signozhq/button';
import { AlertCircle, Check, Loader2, X, Zap } from 'lucide-react';
import { PageActionRegistry } from '../../pageActions/PageActionRegistry';
import { AIActionBlock } from '../../pageActions/types';
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
import { useMessageContext } from '../MessageContext';
type BlockState = 'pending' | 'loading' | 'applied' | 'dismissed' | 'error';
/**
* Renders an AI-suggested page action.
*
* Two modes based on the registered PageAction.autoApply flag:
*
* autoApply = false (default)
* Shows a confirmation card with Accept / Dismiss. The user must
* explicitly approve before execute() is called. Use for destructive or
* hard-to-reverse actions (create dashboard, delete alert, etc.).
*
* autoApply = true
* Executes immediately on mount — no card shown, just the result summary.
* Use for low-risk, reversible actions where the user explicitly asked for
* the change (e.g. "filter logs for errors"). Avoids unnecessary friction.
*
* Persists answered state via answeredBlocks so re-mounts don't reset UI.
*/
export default function ActionBlock({
data,
}: {
data: AIActionBlock;
}): JSX.Element {
const { messageId } = useMessageContext();
const answeredBlocks = useAIAssistantStore((s) => s.answeredBlocks);
const markBlockAnswered = useAIAssistantStore((s) => s.markBlockAnswered);
const [localState, setLocalState] = useState<BlockState>(() => {
if (!messageId) {
return 'pending';
}
const saved = answeredBlocks[messageId];
if (!saved) {
return 'pending';
}
if (saved === 'dismissed') {
return 'dismissed';
}
if (saved.startsWith('error:')) {
return 'error';
}
return 'applied';
});
const [resultSummary, setResultSummary] = useState<string>('');
const [errorMessage, setErrorMessage] = useState<string>('');
const { actionId, description, parameters } = data;
// ── Shared execute logic ─────────────────────────────────────────────────────
const execute = async (): Promise<void> => {
const action = PageActionRegistry.get(actionId);
if (!action) {
const msg = `Action "${actionId}" is not available on the current page.`;
setErrorMessage(msg);
setLocalState('error');
if (messageId) {
markBlockAnswered(messageId, `error:${msg}`);
}
return;
}
setLocalState('loading');
try {
const result = await action.execute(parameters as never);
setResultSummary(result.summary);
setLocalState('applied');
if (messageId) {
markBlockAnswered(messageId, `applied:${result.summary}`);
}
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
setErrorMessage(msg);
setLocalState('error');
if (messageId) {
markBlockAnswered(messageId, `error:${msg}`);
}
}
};
// ── Auto-apply: fire immediately on mount if the action opts in ──────────────
const autoApplyFired = useRef(false);
useEffect(() => {
// Only auto-apply once, and only when the block hasn't been answered yet
// (i.e. this is a fresh render, not a remount of an already-answered block).
if (autoApplyFired.current || localState !== 'pending') {
return;
}
const action = PageActionRegistry.get(actionId);
if (!action?.autoApply) {
return;
}
autoApplyFired.current = true;
execute();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleDismiss = (): void => {
setLocalState('dismissed');
if (messageId) {
markBlockAnswered(messageId, 'dismissed');
}
};
// ── Terminal states ──────────────────────────────────────────────────────────
if (localState === 'applied') {
return (
<div className="ai-block ai-action ai-action--applied">
<Check
size={13}
className="ai-action__status-icon ai-action__status-icon--ok"
/>
<span className="ai-action__status-text">
{resultSummary || 'Applied.'}
</span>
</div>
);
}
if (localState === 'dismissed') {
return (
<div className="ai-block ai-action ai-action--dismissed">
<X
size={13}
className="ai-action__status-icon ai-action__status-icon--no"
/>
<span className="ai-action__status-text">Dismissed.</span>
</div>
);
}
if (localState === 'error') {
return (
<div className="ai-block ai-action ai-action--error">
<AlertCircle
size={13}
className="ai-action__status-icon ai-action__status-icon--err"
/>
<span className="ai-action__status-text">{errorMessage}</span>
</div>
);
}
// ── Loading (autoApply in progress) ─────────────────────────────────────────
if (localState === 'loading') {
return (
<div className="ai-block ai-action ai-action--loading">
<Loader2 size={13} className="ai-action__spinner ai-action__status-icon" />
<span className="ai-action__status-text">{description}</span>
</div>
);
}
// ── Pending: manual confirmation card ────────────────────────────────────────
const paramEntries = Object.entries(parameters ?? {});
return (
<div className="ai-block ai-action">
<div className="ai-action__header">
<Zap size={13} className="ai-action__zap-icon" />
<span className="ai-action__header-label">Suggested Action</span>
</div>
<p className="ai-action__description">{description}</p>
{paramEntries.length > 0 && (
<ul className="ai-action__params">
{paramEntries.map(([key, val]) => (
<li key={key} className="ai-action__param">
<span className="ai-action__param-key">{key}</span>
<span className="ai-action__param-val">
{typeof val === 'object' ? JSON.stringify(val) : String(val)}
</span>
</li>
))}
</ul>
)}
<div className="ai-action__actions">
<Button variant="solid" size="xs" onClick={execute}>
<Check size={12} />
Apply
</Button>
<Button variant="outlined" size="xs" onClick={handleDismiss}>
<X size={12} />
Dismiss
</Button>
</div>
</div>
);
}

View File

@@ -1,124 +0,0 @@
import { Bar } from 'react-chartjs-2';
import { CHART_PALETTE, getChartTheme } from './chartSetup';
export interface BarDataset {
label?: string;
data: number[];
color?: string;
}
export interface BarChartData {
title?: string;
unit?: string;
/**
* Category labels (x-axis for vertical, y-axis for horizontal).
* Shorthand: omit `datasets` and use `bars` for single-series data.
*/
labels?: string[];
datasets?: BarDataset[];
/** Single-series shorthand: [{ label, value }] */
bars?: { label: string; value: number; color?: string }[];
/** 'vertical' (default) | 'horizontal' */
direction?: 'vertical' | 'horizontal';
}
export default function BarChartBlock({
data,
}: {
data: BarChartData;
}): JSX.Element {
const { title, unit, direction = 'horizontal' } = data;
const theme = getChartTheme();
// Normalise shorthand `bars` → labels + datasets
let labels: string[];
let datasets: BarDataset[];
if (data.bars) {
labels = data.bars.map((b) => b.label);
datasets = [
{
label: title ?? 'Value',
data: data.bars.map((b) => b.value),
color: undefined, // use palette below
},
];
} else {
labels = data.labels ?? [];
datasets = data.datasets ?? [];
}
const chartData = {
labels,
datasets: datasets.map((ds, i) => {
const baseColor = ds.color ?? CHART_PALETTE[i % CHART_PALETTE.length];
return {
label: ds.label ?? `Series ${i + 1}`,
data: ds.data,
backgroundColor: data.bars
? data.bars.map((_, j) => CHART_PALETTE[j % CHART_PALETTE.length])
: baseColor,
borderColor: data.bars
? data.bars.map((_, j) => CHART_PALETTE[j % CHART_PALETTE.length])
: baseColor,
borderWidth: 1,
borderRadius: 3,
};
}),
};
const barHeight = Math.max(160, labels.length * 28 + 48);
return (
<div className="ai-block ai-chart">
{title && <p className="ai-block__title">{title}</p>}
<div
className="ai-chart__canvas-wrap"
style={{ height: direction === 'horizontal' ? barHeight : 200 }}
>
<Bar
data={chartData}
options={{
indexAxis: direction === 'horizontal' ? 'y' : 'x',
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: datasets.length > 1,
labels: { color: theme.legendColor, boxWidth: 12, font: { size: 11 } },
},
tooltip: {
backgroundColor: theme.tooltipBg,
titleColor: theme.tooltipText,
bodyColor: theme.tooltipText,
borderColor: theme.gridColor,
borderWidth: 1,
callbacks: unit
? { label: (ctx): string => `${ctx.formattedValue} ${unit}` }
: {},
},
},
scales: {
x: {
grid: { color: theme.gridColor },
ticks: {
color: theme.tickColor,
font: { size: 11 },
callback:
unit && direction !== 'horizontal'
? (v): string => `${v} ${unit}`
: undefined,
},
},
y: {
grid: { color: theme.gridColor },
ticks: { color: theme.tickColor, font: { size: 11 } },
},
},
}}
/>
</div>
</div>
);
}

View File

@@ -1,36 +0,0 @@
import React from 'react';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type BlockComponent<T = any> = React.ComponentType<{ data: T }>;
/**
* Global registry for AI response block renderers.
*
* Any part of the application can register a custom block type:
*
* import { BlockRegistry } from 'container/AIAssistant/components/blocks';
* BlockRegistry.register('my-panel', MyPanelComponent);
*
* The AI can then emit fenced code blocks with the prefix `ai-<type>` and a
* JSON payload, and the registered component will be rendered in-place:
*
* ```ai-my-panel
* { "foo": "bar" }
* ```
*/
const _registry = new Map<string, BlockComponent>();
export const BlockRegistry = {
register<T>(type: string, component: BlockComponent<T>): void {
_registry.set(type, component as BlockComponent);
},
get(type: string): BlockComponent | undefined {
return _registry.get(type);
},
/** Returns all registered type names (useful for debugging). */
types(): string[] {
return Array.from(_registry.keys());
},
};

View File

@@ -1,85 +0,0 @@
import { Button } from '@signozhq/button';
import { Check, X } from 'lucide-react';
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
import { useMessageContext } from '../MessageContext';
export interface ConfirmData {
message?: string;
/** Text sent back when accepted. Defaults to "Yes, proceed." */
acceptText?: string;
/** Text sent back when rejected. Defaults to "No, cancel." */
rejectText?: string;
/** Label shown on Accept button. Defaults to "Accept" */
acceptLabel?: string;
/** Label shown on Reject button. Defaults to "Reject" */
rejectLabel?: string;
}
export default function ConfirmBlock({
data,
}: {
data: ConfirmData;
}): JSX.Element {
const {
message,
acceptText = 'Yes, proceed.',
rejectText = 'No, cancel.',
acceptLabel = 'Accept',
rejectLabel = 'Reject',
} = data;
const { messageId } = useMessageContext();
const answeredBlocks = useAIAssistantStore((s) => s.answeredBlocks);
const markBlockAnswered = useAIAssistantStore((s) => s.markBlockAnswered);
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
// Durable answered state — survives re-renders/remounts
const answeredChoice = messageId ? answeredBlocks[messageId] : undefined;
const isAnswered = answeredChoice !== undefined;
const handle = (choice: 'accepted' | 'rejected'): void => {
const responseText = choice === 'accepted' ? acceptText : rejectText;
if (messageId) {
markBlockAnswered(messageId, choice);
}
sendMessage(responseText);
};
if (isAnswered) {
const wasAccepted = answeredChoice === 'accepted';
const icon = wasAccepted ? (
<Check size={13} className="ai-confirm__icon ai-confirm__icon--ok" />
) : (
<X size={13} className="ai-confirm__icon ai-confirm__icon--no" />
);
return (
<div className="ai-block ai-confirm ai-confirm--answered">
{icon}
<span className="ai-confirm__answer-text">
{wasAccepted ? acceptText : rejectText}
</span>
</div>
);
}
return (
<div className="ai-block ai-confirm">
{message && <p className="ai-confirm__message">{message}</p>}
<div className="ai-confirm__actions">
<Button variant="solid" size="xs" onClick={(): void => handle('accepted')}>
<Check size={12} />
{acceptLabel}
</Button>
<Button
variant="outlined"
size="xs"
onClick={(): void => handle('rejected')}
>
<X size={12} />
{rejectLabel}
</Button>
</div>
</div>
);
}

View File

@@ -1,110 +0,0 @@
import { useState } from 'react';
import { Button } from '@signozhq/button';
import { Checkbox, Radio } from 'antd';
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
import { useMessageContext } from '../MessageContext';
interface Option {
value: string;
label: string;
}
export interface QuestionData {
question?: string;
type?: 'radio' | 'checkbox';
options: (string | Option)[];
}
function normalizeOption(opt: string | Option): Option {
return typeof opt === 'string' ? { value: opt, label: opt } : opt;
}
export default function InteractiveQuestion({
data,
}: {
data: QuestionData;
}): JSX.Element {
const { question, type = 'radio', options } = data;
const normalized = options.map(normalizeOption);
const { messageId } = useMessageContext();
const answeredBlocks = useAIAssistantStore((s) => s.answeredBlocks);
const markBlockAnswered = useAIAssistantStore((s) => s.markBlockAnswered);
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
// Persist selected state locally only for the pending (not-yet-submitted) case
const [selected, setSelected] = useState<string[]>([]);
// Durable answered state from the store — survives re-renders/remounts
const answeredText = messageId ? answeredBlocks[messageId] : undefined;
const isAnswered = answeredText !== undefined;
const handleSubmit = (values: string[]): void => {
if (values.length === 0) {
return;
}
const answer = values.join(', ');
if (messageId) {
markBlockAnswered(messageId, answer);
}
sendMessage(answer);
};
if (isAnswered) {
return (
<div className="ai-block ai-question ai-question--answered">
<span className="ai-question__check"></span>
<span className="ai-question__answer-text">{answeredText}</span>
</div>
);
}
return (
<div className="ai-block ai-question">
{question && <p className="ai-block__title">{question}</p>}
{type === 'radio' ? (
<Radio.Group
className="ai-question__options"
onChange={(e): void => {
setSelected([e.target.value]);
handleSubmit([e.target.value]);
}}
>
{normalized.map((opt) => (
<Radio key={opt.value} value={opt.value} className="ai-question__option">
{opt.label}
</Radio>
))}
</Radio.Group>
) : (
<>
<Checkbox.Group
className="ai-question__options ai-question__options--checkbox"
onChange={(vals): void => setSelected(vals as string[])}
>
{normalized.map((opt) => (
<Checkbox
key={opt.value}
value={opt.value}
className="ai-question__option"
>
{opt.label}
</Checkbox>
))}
</Checkbox.Group>
<Button
variant="solid"
size="xs"
className="ai-question__submit"
disabled={selected.length === 0}
onClick={(): void => handleSubmit(selected)}
>
Confirm
</Button>
</>
)}
</div>
);
}

View File

@@ -1,103 +0,0 @@
import { Line } from 'react-chartjs-2';
import {
CHART_PALETTE,
CHART_PALETTE_ALPHA,
getChartTheme,
} from './chartSetup';
export interface LineDataset {
label?: string;
data: number[];
color?: string;
/** Fill area under line. Defaults to false. */
fill?: boolean;
}
export interface LineChartData {
title?: string;
unit?: string;
/** X-axis labels (time strings, numbers, etc.) */
labels: string[];
datasets: LineDataset[];
}
export default function LineChartBlock({
data,
}: {
data: LineChartData;
}): JSX.Element {
const { title, unit, labels, datasets } = data;
const theme = getChartTheme();
const chartData = {
labels,
datasets: datasets.map((ds, i) => {
const color = ds.color ?? CHART_PALETTE[i % CHART_PALETTE.length];
const fillColor = CHART_PALETTE_ALPHA[i % CHART_PALETTE_ALPHA.length];
return {
label: ds.label ?? `Series ${i + 1}`,
data: ds.data,
borderColor: color,
backgroundColor: ds.fill ? fillColor : 'transparent',
pointBackgroundColor: color,
pointRadius: labels.length > 30 ? 0 : 3,
pointHoverRadius: 5,
borderWidth: 2,
fill: ds.fill ?? false,
tension: 0.35,
};
}),
};
return (
<div className="ai-block ai-chart">
{title && <p className="ai-block__title">{title}</p>}
<div className="ai-chart__canvas-wrap">
<Line
data={chartData}
options={{
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: {
display: datasets.length > 1,
labels: { color: theme.legendColor, boxWidth: 12, font: { size: 11 } },
},
tooltip: {
backgroundColor: theme.tooltipBg,
titleColor: theme.tooltipText,
bodyColor: theme.tooltipText,
borderColor: theme.gridColor,
borderWidth: 1,
callbacks: unit
? { label: (ctx): string => ` ${ctx.formattedValue} ${unit}` }
: {},
},
},
scales: {
x: {
grid: { color: theme.gridColor },
ticks: {
color: theme.tickColor,
font: { size: 11 },
maxRotation: 0,
maxTicksLimit: 8,
},
},
y: {
grid: { color: theme.gridColor },
ticks: {
color: theme.tickColor,
font: { size: 11 },
callback: unit ? (v): string => `${v} ${unit}` : undefined,
},
},
},
}}
/>
</div>
</div>
);
}

View File

@@ -1,82 +0,0 @@
import { Doughnut } from 'react-chartjs-2';
import { CHART_PALETTE, getChartTheme } from './chartSetup';
export interface SliceData {
label: string;
value: number;
color?: string;
}
export interface PieChartData {
title?: string;
slices: SliceData[];
}
export default function PieChartBlock({
data,
}: {
data: PieChartData;
}): JSX.Element {
const { title, slices } = data;
const theme = getChartTheme();
const chartData = {
labels: slices.map((s) => s.label),
datasets: [
{
data: slices.map((s) => s.value),
backgroundColor: slices.map(
(s, i) => s.color ?? CHART_PALETTE[i % CHART_PALETTE.length],
),
borderColor: theme.tooltipBg,
borderWidth: 2,
hoverOffset: 6,
},
],
};
return (
<div className="ai-block ai-chart">
{title && <p className="ai-block__title">{title}</p>}
<div className="ai-chart__canvas-wrap ai-chart__canvas-wrap--pie">
<Doughnut
data={chartData}
options={{
responsive: true,
maintainAspectRatio: false,
cutout: '58%',
plugins: {
legend: {
position: 'right',
labels: {
color: theme.legendColor,
boxWidth: 10,
padding: 10,
font: { size: 11 },
},
},
tooltip: {
backgroundColor: theme.tooltipBg,
titleColor: theme.tooltipText,
bodyColor: theme.tooltipText,
borderColor: theme.gridColor,
borderWidth: 1,
callbacks: {
label: (ctx): string => {
const total = (ctx.dataset.data as number[]).reduce(
(a, b) => a + b,
0,
);
const pct = ((ctx.parsed / total) * 100).toFixed(1);
return ` ${ctx.formattedValue} (${pct}%)`;
},
},
},
},
}}
/>
</div>
</div>
);
}

View File

@@ -1,45 +0,0 @@
import React from 'react';
import { BlockRegistry } from './BlockRegistry';
interface CodeProps {
className?: string;
children?: React.ReactNode;
// react-markdown passes `node` — accept and ignore it
// eslint-disable-next-line @typescript-eslint/no-explicit-any
node?: any;
}
/**
* Drop-in replacement for react-markdown's `code` renderer.
*
* When the language tag begins with `ai-` the remaining part is looked up in
* the BlockRegistry and, if a component is found, the JSON payload is parsed
* and the component is rendered.
*
* Falls back to a regular <code> element for all other blocks (including plain
* inline code and unknown `ai-*` types).
*/
export default function RichCodeBlock({
className,
children,
}: CodeProps): JSX.Element {
const lang = /language-(\S+)/.exec(className ?? '')?.[1];
if (lang?.startsWith('ai-')) {
const blockType = lang.slice(3); // strip the 'ai-' prefix
const BlockComp = BlockRegistry.get(blockType);
if (BlockComp) {
const raw = String(children ?? '').trim();
try {
const parsedData = JSON.parse(raw);
return <BlockComp data={parsedData} />;
} catch {
// Invalid JSON — fall through and render as a code block
}
}
}
return <code className={className}>{children}</code>;
}

View File

@@ -1,54 +0,0 @@
export interface TimeseriesData {
title?: string;
unit?: string;
/** Column header labels. Defaults to ["Time", "Value"]. */
columns?: string[];
/** Each row is an array of cell values (strings or numbers). */
rows: (string | number)[][];
}
export default function TimeseriesBlock({
data,
}: {
data: TimeseriesData;
}): JSX.Element {
const { title, unit, columns, rows } = data;
const cols = columns ?? ['Time', 'Value'];
return (
<div className="ai-block ai-timeseries">
{(title || unit) && (
<p className="ai-block__title">
{title}
{unit ? <span className="ai-block__unit"> ({unit})</span> : null}
</p>
)}
<div className="ai-timeseries__scroll">
<table className="ai-timeseries__table">
<thead>
<tr>
{cols.map((col) => (
<th key={col}>{col}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
// Row index is the stable key here since rows have no IDs
// eslint-disable-next-line react/no-array-index-key
<tr key={i}>
{row.map((cell, j) => (
// eslint-disable-next-line react/no-array-index-key
<td key={j}>{cell}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{rows.length === 0 && <p className="ai-block__empty">No data available.</p>}
</div>
);
}

View File

@@ -1,70 +0,0 @@
/**
* Registers all Chart.js components that the AI assistant blocks need.
* Import this module once (via blocks/index.ts) — safe to import multiple times.
*/
import {
ArcElement,
BarElement,
CategoryScale,
Chart as ChartJS,
Filler,
Legend,
LinearScale,
LineElement,
PointElement,
TimeScale,
Title,
Tooltip,
} from 'chart.js';
ChartJS.register(
CategoryScale,
LinearScale,
TimeScale,
BarElement,
PointElement,
LineElement,
ArcElement,
Filler,
Title,
Tooltip,
Legend,
);
// ─── Colour palette (SigNoz brand colours as explicit hex) ───────────────────
export const CHART_PALETTE = [
'#4E74F8', // robin (blue primary)
'#2DB699', // aquamarine
'#F5A623', // amber
'#F05944', // cherry (red)
'#06B6D4', // aqua (cyan)
'#F97316', // sienna (orange)
'#8B5CF6', // violet
'#EC4899', // sakura (pink)
];
export const CHART_PALETTE_ALPHA = CHART_PALETTE.map((c) => `${c}33`); // 20% opacity fills
// ─── Theme helpers ────────────────────────────────────────────────────────────
function isDark(): boolean {
return document.body.classList.contains('dark');
}
export function getChartTheme(): {
gridColor: string;
tickColor: string;
legendColor: string;
tooltipBg: string;
tooltipText: string;
} {
const dark = isDark();
return {
gridColor: dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)',
tickColor: dark ? '#8c9bb5' : '#6b7280',
legendColor: dark ? '#c0cbe0' : '#374151',
tooltipBg: dark ? '#1a1f2e' : '#ffffff',
tooltipText: dark ? '#e2e8f0' : '#111827',
};
}

View File

@@ -1,40 +0,0 @@
/**
* AI response block system.
*
* Import this module once (e.g. in MessageBubble / StreamingMessage) to
* register all built-in block types. External modules can extend the registry
* at any time:
*
* import { BlockRegistry } from 'container/AIAssistant/components/blocks';
* BlockRegistry.register('my-panel', MyPanelComponent);
*/
// Side-effect: ensure Chart.js components are registered before any chart renders
import './chartSetup';
import ActionBlock from './ActionBlock';
import BarChartBlock from './BarChartBlock';
import { BlockRegistry } from './BlockRegistry';
import ConfirmBlock from './ConfirmBlock';
import InteractiveQuestion from './InteractiveQuestion';
import LineChartBlock from './LineChartBlock';
import PieChartBlock from './PieChartBlock';
import TimeseriesBlock from './TimeseriesBlock';
// ─── Register built-in block types ───────────────────────────────────────────
BlockRegistry.register('question', InteractiveQuestion);
BlockRegistry.register('confirm', ConfirmBlock);
BlockRegistry.register('timeseries', TimeseriesBlock);
BlockRegistry.register('barchart', BarChartBlock);
BlockRegistry.register('piechart', PieChartBlock);
// ai-linechart and ai-graph are aliases for the same component
BlockRegistry.register('linechart', LineChartBlock);
BlockRegistry.register('graph', LineChartBlock);
// Page-aware action block
BlockRegistry.register('action', ActionBlock);
// ─── Public exports ───────────────────────────────────────────────────────────
export { BlockRegistry } from './BlockRegistry';
export { default as RichCodeBlock } from './RichCodeBlock';

View File

@@ -1,193 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
// ── Web Speech API types (not yet in lib.dom.d.ts) ────────────────────────────
interface SpeechRecognitionResult {
readonly length: number;
readonly isFinal: boolean;
[index: number]: { transcript: string; confidence: number };
}
interface SpeechRecognitionResultList {
readonly length: number;
[index: number]: SpeechRecognitionResult;
}
interface SpeechRecognitionEvent extends Event {
readonly resultIndex: number;
readonly results: SpeechRecognitionResultList;
}
interface SpeechRecognitionErrorEvent extends Event {
readonly error: string;
readonly message: string;
}
interface ISpeechRecognition extends EventTarget {
lang: string;
continuous: boolean;
interimResults: boolean;
onstart: (() => void) | null;
onend: (() => void) | null;
onresult: ((event: SpeechRecognitionEvent) => void) | null;
onerror: ((event: SpeechRecognitionErrorEvent) => void) | null;
start(): void;
stop(): void;
abort(): void;
}
type SpeechRecognitionConstructor = new () => ISpeechRecognition;
// ── Vendor-prefix shim for Safari / older browsers ────────────────────────────
const SpeechRecognitionAPI: SpeechRecognitionConstructor | null =
typeof window !== 'undefined'
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).SpeechRecognition ??
(window as any).webkitSpeechRecognition ??
null
: null;
export type SpeechRecognitionError =
| 'not-supported'
| 'not-allowed'
| 'no-speech'
| 'network'
| 'unknown';
interface UseSpeechRecognitionOptions {
onError?: (error: SpeechRecognitionError) => void;
/**
* Called directly from browser recognition events — no React state intermediary.
* `isFinal=false` → interim (live preview), `isFinal=true` → committed text.
*/
onTranscript?: (text: string, isFinal: boolean) => void;
lang?: string;
}
interface UseSpeechRecognitionReturn {
isListening: boolean;
isSupported: boolean;
start: () => void;
stop: () => void;
/** Stop recognition and discard any pending interim text (no onTranscript call). */
discard: () => void;
}
export function useSpeechRecognition({
onError,
onTranscript,
lang = 'en-US',
}: UseSpeechRecognitionOptions = {}): UseSpeechRecognitionReturn {
const [isListening, setIsListening] = useState(false);
const recognitionRef = useRef<ISpeechRecognition | null>(null);
const isDiscardingRef = useRef(false);
const isSupported = SpeechRecognitionAPI !== null;
// Always-current refs — updated synchronously on every render so closures
// inside recognition event handlers always call the latest version.
const onErrorRef = useRef(onError);
onErrorRef.current = onError;
const onTranscriptRef = useRef(onTranscript);
onTranscriptRef.current = onTranscript;
const stop = useCallback(() => {
recognitionRef.current?.stop();
}, []);
const discard = useCallback(() => {
isDiscardingRef.current = true;
recognitionRef.current?.stop();
}, []);
// eslint-disable-next-line sonarjs/cognitive-complexity
const start = useCallback(() => {
if (!isSupported) {
onErrorRef.current?.('not-supported');
return;
}
// If already listening, stop
if (recognitionRef.current) {
recognitionRef.current.stop();
return;
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const recognition = new SpeechRecognitionAPI!();
recognition.lang = lang;
recognition.continuous = true; // keep listening until user clicks stop
recognition.interimResults = true; // live updates while speaking
// Track the last interim text so we can commit it as final in onend —
// Chrome often skips the isFinal result when stop() is called manually.
let pendingInterim = '';
recognition.onstart = (): void => {
setIsListening(true);
};
recognition.onresult = (event): void => {
let interim = '';
let finalText = '';
for (let i = event.resultIndex; i < event.results.length; i++) {
const text = event.results[i][0].transcript;
if (event.results[i].isFinal) {
finalText += text;
} else {
interim += text;
}
}
if (finalText) {
pendingInterim = '';
onTranscriptRef.current?.(finalText, true);
} else if (interim) {
pendingInterim = interim;
onTranscriptRef.current?.(interim, false);
}
};
recognition.onerror = (event): void => {
pendingInterim = '';
let mapped: SpeechRecognitionError = 'unknown';
if (event.error === 'not-allowed' || event.error === 'service-not-allowed') {
mapped = 'not-allowed';
} else if (event.error === 'no-speech') {
mapped = 'no-speech';
} else if (event.error === 'network') {
mapped = 'network';
}
onErrorRef.current?.(mapped);
};
recognition.onend = (): void => {
// Commit any interim text that never received a final result,
// unless the session was explicitly discarded.
if (!isDiscardingRef.current && pendingInterim) {
const committed = pendingInterim;
pendingInterim = '';
onTranscriptRef.current?.(committed, true);
}
isDiscardingRef.current = false;
pendingInterim = '';
setIsListening(false);
recognitionRef.current = null;
};
recognitionRef.current = recognition;
recognition.start();
}, [isSupported, lang]);
// Clean up on unmount
useEffect(
() => (): void => {
recognitionRef.current?.abort();
},
[],
);
return { isListening, isSupported, start, stop, discard };
}

View File

@@ -1,391 +0,0 @@
/**
* Dummy streaming API — mimics a real fetch() response with a ReadableStream body.
* Swap `mockAIStream` for `fetch(...)` in the store when the real backend is ready.
*/
const CANNED_RESPONSES: Record<string, string> = {
default: `I'm the SigNoz AI Assistant. I can help you explore your observability data — traces, logs, metrics, and more.
Here are a few things you can ask me:
- **"Show me error rates"** — table view of errors per service
- **"Show me a latency graph"** — line chart of p99 latency over time
- **"Show me a bar chart of top services"** — horizontal bar chart
- **"Show me a pie chart of errors"** — doughnut chart by service
- **"Any anomalies?"** — confirmation flow example
What would you like to investigate?`,
error: `I found several issues in your traces over the last 15 minutes:
\`\`\`ai-timeseries
{
"title": "Error Rates by Service",
"columns": ["Service", "Error Rate", "Change"],
"rows": [
["payment-svc", "4.2%", "↑ +3.1%"],
["auth-svc", "0.8%", "→ stable"],
["cart-svc", "12.1%", "↑ +11.4%"]
]
}
\`\`\`
The \`cart-svc\` spike started around **14:32 UTC** — correlates with a deploy event.
\`\`\`
TraceID: 7f3a9c2b1e4d6f80
Span: cart-svc → inventory-svc
Error: connection timeout after 5000ms
\`\`\``,
latency: `Here's the p99 latency over the last hour for your top services:
\`\`\`ai-graph
{
"title": "p99 Latency (ms)",
"unit": "ms",
"labels": ["13:00","13:10","13:20","13:30","13:40","13:45","13:50","14:00"],
"datasets": [
{
"label": "checkout-svc",
"data": [310, 318, 325, 340, 480, 842, 790, 650],
"fill": true
},
{
"label": "payment-svc",
"data": [195, 201, 198, 205, 210, 208, 203, 201]
},
{
"label": "user-svc",
"data": [112, 108, 105, 102, 99, 98, 96, 98]
}
]
}
\`\`\`
The **checkout-svc** degradation started at ~13:45. Its upstream dependency \`inventory-svc\` shows the same pattern — likely the root cause.`,
logs: `I searched your logs for the last 30 minutes and found **1,247 ERROR** entries.
Top error messages:
1. \`NullPointerException in OrderProcessor.java:142\` — 843 occurrences
2. \`Database connection pool exhausted\` — 312 occurrences
3. \`HTTP 429 Too Many Requests from stripe-api\` — 92 occurrences
The \`NullPointerException\` is new — first seen at **14:01 UTC**, which lines up with your latest deployment.`,
barchart: `Here are the **top 8 services** ranked by total error count in the last hour:
\`\`\`ai-barchart
{
"title": "Error Count by Service (last 1h)",
"unit": "errors",
"bars": [
{ "label": "cart-svc", "value": 3842 },
{ "label": "checkout-svc", "value": 2910 },
{ "label": "payment-svc", "value": 1204 },
{ "label": "inventory-svc", "value": 987 },
{ "label": "user-svc", "value": 543 },
{ "label": "recommendation", "value": 312 },
{ "label": "notification-svc", "value": 198 },
{ "label": "auth-svc", "value": 74 }
]
}
\`\`\`
**cart-svc** is the clear outlier — 3.8× more errors than the next service. I'd start there.`,
piechart: `Here is how errors are distributed across your top services:
\`\`\`ai-piechart
{
"title": "Error Share by Service (last 1h)",
"slices": [
{ "label": "cart-svc", "value": 3842 },
{ "label": "checkout-svc", "value": 2910 },
{ "label": "payment-svc", "value": 1204 },
{ "label": "inventory-svc", "value": 987 },
{ "label": "user-svc", "value": 543 },
{ "label": "other", "value": 584 }
]
}
\`\`\`
**cart-svc** and **checkout-svc** together account for more than 65% of all errors. Both share a dependency on \`inventory-svc\` — that's likely the common root cause.`,
timeseries: `Here is the request-rate trend for **checkout-svc** over the last 10 minutes:
\`\`\`ai-timeseries
{
"title": "Request Rate — checkout-svc",
"unit": "req/min",
"columns": ["Time (UTC)", "Requests", "Errors", "Error %"],
"rows": [
["14:50", 1240, 12, "0.97%"],
["14:51", 1318, 14, "1.06%"],
["14:52", 1290, 18, "1.40%"],
["14:53", 1355, 31, "2.29%"],
["14:54", 1401, 58, "4.14%"],
["14:55", 1389, 112, "8.07%"],
["14:56", 1342, 198, "14.75%"],
["14:57", 1278, 176, "13.77%"],
["14:58", 1310, 143, "10.92%"],
["14:59", 1365, 89, "6.52%"]
]
}
\`\`\`
The error rate started climbing at **14:53** — coinciding with a config push to the \`inventory-svc\` dependency.`,
question: `Sure! To narrow down the investigation, I need a bit more context.
\`\`\`ai-question
{
"question": "Which environment are you interested in?",
"type": "radio",
"options": [
{ "value": "production", "label": "Production" },
{ "value": "staging", "label": "Staging" },
{ "value": "development","label": "Development" }
]
}
\`\`\``,
multiselect: `Got it. Which log levels should I focus on?
\`\`\`ai-question
{
"question": "Select the log levels to include:",
"type": "checkbox",
"options": ["ERROR", "WARN", "INFO", "DEBUG", "TRACE"]
}
\`\`\``,
confirm: `I found a potential anomaly in \`cart-svc\`. The error rate jumped from 0.8% to 12.1% in the last 5 minutes.
\`\`\`ai-confirm
{
"message": "Would you like me to create an alert rule for this service so you're notified if it happens again?",
"acceptLabel": "Yes, create alert",
"rejectLabel": "No thanks",
"acceptText": "Yes, please create an alert rule for cart-svc error rate > 5%.",
"rejectText": "No, don't create an alert."
}
\`\`\``,
actionRunQuery: `Sure! I'll update the log query to filter for ERROR-level logs from \`payment-svc\`.
\`\`\`ai-action
{
"actionId": "logs.runQuery",
"description": "Filter logs to ERROR level from payment-svc and re-run the query",
"parameters": {
"filters": [
{ "key": "severity_text", "op": "=", "value": "ERROR" },
{ "key": "service.name", "op": "=", "value": "payment-svc" }
]
}
}
\`\`\``,
actionAddFilter: `I'll add a filter for \`ERROR\` severity to your current query.
\`\`\`ai-action
{
"actionId": "logs.addFilter",
"description": "Add a severity_text = ERROR filter to the current query",
"parameters": {
"key": "severity_text",
"op": "=",
"value": "ERROR"
}
}
\`\`\``,
actionChangeView: `I'll switch the Logs Explorer to the timeseries view so you can see the log volume over time.
\`\`\`ai-action
{
"actionId": "logs.changeView",
"description": "Switch to the timeseries panel view",
"parameters": {
"view": "timeseries"
}
}
\`\`\``,
actionSaveView: `I can save your current query as a named view. What should it be called?
\`\`\`ai-action
{
"actionId": "logs.saveView",
"description": "Save the current log query as \\"Error Logs — Payment\\"",
"parameters": {
"name": "Error Logs — Payment"
}
}
\`\`\``,
};
// eslint-disable-next-line sonarjs/cognitive-complexity
function pickResponse(messages: { role: string; content: string }[]): string {
const lastRaw =
[...messages].reverse().find((m) => m.role === 'user')?.content ?? '';
// Strip the PAGE_CONTEXT block if present — match against the user's actual text
const last = lastRaw
.replace(/\[PAGE_CONTEXT\][\s\S]*?\[\/PAGE_CONTEXT\]\n?/g, '')
.toLowerCase();
// ── Page action triggers ──────────────────────────────────────────────────
if (
last.includes('save view') ||
last.includes('save this view') ||
last.includes('save query')
) {
return CANNED_RESPONSES.actionSaveView;
}
if (
last.includes('change view') ||
last.includes('switch to timeseries') ||
last.includes('timeseries view')
) {
return CANNED_RESPONSES.actionChangeView;
}
if (
last.includes('add filter') ||
last.includes('filter for error') ||
last.includes('show only error')
) {
return CANNED_RESPONSES.actionAddFilter;
}
if (
last.includes('run query') ||
last.includes('update query') ||
last.includes('filter logs') ||
last.includes('search logs') ||
(last.includes('payment') && last.includes('error'))
) {
return CANNED_RESPONSES.actionRunQuery;
}
// ── Original triggers ─────────────────────────────────────────────────────
if (
last.includes('confirm') ||
last.includes('alert') ||
last.includes('anomal')
) {
return CANNED_RESPONSES.confirm;
}
if (
last.includes('pie') ||
last.includes('distribution') ||
last.includes('share')
) {
return CANNED_RESPONSES.piechart;
}
if (
last.includes('bar') ||
last.includes('breakdown') ||
last.includes('top service') ||
last.includes('top 5') ||
last.includes('top 8')
) {
return CANNED_RESPONSES.barchart;
}
if (
last.includes('timeseries') ||
last.includes('time series') ||
last.includes('table') ||
last.includes('request rate')
) {
return CANNED_RESPONSES.timeseries;
}
if (
last.includes('graph') ||
last.includes('linechart') ||
last.includes('line chart') ||
last.includes('latency') ||
last.includes('slow') ||
last.includes('p99') ||
last.includes('over time') ||
last.includes('trend')
) {
return CANNED_RESPONSES.latency;
}
if (
last.includes('select') ||
last.includes('level') ||
last.includes('filter')
) {
return CANNED_RESPONSES.multiselect;
}
if (
last.includes('ask') ||
last.includes('which env') ||
last.includes('environment') ||
last.includes('question')
) {
return CANNED_RESPONSES.question;
}
if (last.includes('error') || last.includes('exception')) {
return CANNED_RESPONSES.error;
}
if (last.includes('log')) {
return CANNED_RESPONSES.logs;
}
return CANNED_RESPONSES.default;
}
import { SSEEvent } from '../../../api/ai/chat';
interface MockChatPayload {
conversationId: string;
messages: { role: 'user' | 'assistant'; content: string }[];
}
export async function* mockStreamChat(
payload: MockChatPayload,
): AsyncGenerator<SSEEvent> {
const text = pickResponse(payload.messages);
const words = text.split(/(?<=\s)/);
const messageId = `mock-${Date.now()}`;
const executionId = `mock-exec-${Date.now()}`;
for (let i = 0; i < words.length; i++) {
// eslint-disable-next-line no-await-in-loop
await new Promise<void>((resolve) => {
setTimeout(resolve, 15 + Math.random() * 30);
});
yield {
type: 'message',
executionId,
messageId,
delta: words[i],
done: false,
actions: null,
eventId: i + 1,
};
}
// Final message event with done: true
yield {
type: 'message',
executionId,
messageId,
delta: '',
done: true,
actions: null,
eventId: words.length + 1,
};
yield {
type: 'done',
executionId,
tokenInput: 0,
tokenOutput: words.length,
latencyMs: 0,
eventId: words.length + 2,
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,62 +0,0 @@
import { PageAction, PageActionDescriptor } from './types';
/**
* Module-level singleton (mirrors BlockRegistry) that maps action IDs to their
* PageAction descriptors. Pages register their actions on mount and unregister
* on unmount via `usePageActions`.
*
* Internal structure:
* _byPage: pageId → PageAction[] (for batch unregister)
* _byId: actionId → PageAction (O(1) lookup at execute time)
*/
// pageId → actions[]
const _byPage = new Map<string, PageAction[]>();
// actionId → action (flat, for O(1) lookup)
const _byId = new Map<string, PageAction>();
export const PageActionRegistry = {
/**
* Register a set of actions under a page scope key.
* Calling register() again with the same pageId replaces the previous set.
*/
register(pageId: string, actions: PageAction[]): void {
// Remove any previously registered actions for this page
const prev = _byPage.get(pageId) ?? [];
prev.forEach((a) => _byId.delete(a.id));
_byPage.set(pageId, actions);
actions.forEach((a) => _byId.set(a.id, a));
},
/** Remove all actions registered under a page scope key. */
unregister(pageId: string): void {
const prev = _byPage.get(pageId) ?? [];
prev.forEach((a) => _byId.delete(a.id));
_byPage.delete(pageId);
},
/** Look up a single action by its dot-namespaced id. */
get(actionId: string): PageAction | undefined {
return _byId.get(actionId);
},
/**
* Returns serialisable descriptors for all currently registered actions,
* with context snapshots already collected. Safe to embed in API payload.
*/
snapshot(): PageActionDescriptor[] {
return Array.from(_byId.values()).map((action) => ({
id: action.id,
description: action.description,
parameters: action.parameters,
context: action.getContext?.(),
}));
},
/** Returns all registered action IDs (useful for debugging). */
ids(): string[] {
return Array.from(_byId.keys());
},
};

View File

@@ -1,93 +0,0 @@
export type JSONSchemaProperty =
| { type: 'string'; description?: string; enum?: string[] }
| { type: 'number'; description?: string }
| { type: 'boolean'; description?: string }
| {
type: 'array';
description?: string;
items: JSONSchemaProperty | JSONSchemaObject;
}
| { type: 'object'; description?: string; properties?: Record<string, JSONSchemaProperty>; required?: string[] };
export type JSONSchemaObject = {
type: 'object';
properties: Record<string, JSONSchemaProperty>;
required?: string[];
};
export interface ActionResult {
/** Short human-readable outcome shown after the action completes. */
summary: string;
/** Optional structured data the block can display. */
data?: Record<string, unknown>;
}
/**
* Describes a single action a page exposes to the AI Assistant.
* Pages register these via `usePageActions`.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export interface PageAction<TParams = Record<string, any>> {
/**
* Stable dot-namespaced identifier — e.g. "logs.runQuery", "dashboard.create".
* The AI uses this to target the correct action.
*/
id: string;
/**
* Natural-language description sent in the PAGE_CONTEXT block.
* The AI uses this to decide which action to invoke.
*/
description: string;
/**
* JSON Schema (draft-07) describing the parameters accepted by this action.
* Sent to the AI so it can generate structurally valid calls.
*/
parameters: JSONSchemaObject;
/**
* Executes the action. Resolves with a result summary on success.
* Rejects with an Error if the action cannot be completed.
*/
execute: (params: TParams) => Promise<ActionResult>;
/**
* When true, ActionBlock executes the action immediately on mount without
* showing a confirmation card. Use for low-risk, reversible actions where
* the user explicitly requested the change (e.g. updating a query filter).
* Default: false (shows Accept / Dismiss card).
*/
autoApply?: boolean;
/**
* Optional: returns a snapshot of the current page state to include in
* the PAGE_CONTEXT block. Called fresh at message-send time.
*/
getContext?: () => unknown;
}
/**
* Serialisable version of PageAction (no function references).
* Safe to embed in the API payload.
*/
export interface PageActionDescriptor {
id: string;
description: string;
parameters: JSONSchemaObject;
/** Context snapshot returned by PageAction.getContext() */
context?: unknown;
}
/**
* The JSON payload the AI emits inside an ```ai-action``` fenced block
* when it wants to invoke an action.
*/
export interface AIActionBlock {
/** Must match a registered PageAction.id */
actionId: string;
/** One-sentence explanation shown in the confirmation card. */
description: string;
/** Parameters chosen by the AI — validated against the action's JSON Schema. */
parameters: Record<string, unknown>;
}

View File

@@ -1,30 +0,0 @@
import { useEffect } from 'react';
import { PageActionRegistry } from './PageActionRegistry';
import { PageAction } from './types';
/**
* Registers page-specific actions into the PageActionRegistry for the lifetime
* of the calling component. Cleanup (unregister) happens automatically on unmount.
*
* Usage:
* const actions = useMemo(() => [
* logsRunQueryAction({ handleRunQuery, ... }),
* ], [handleRunQuery, ...]);
*
* usePageActions('logs-explorer', actions);
*
* IMPORTANT: memoize the `actions` array with useMemo so that the reference
* stays stable and we don't re-register on every render.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function usePageActions(pageId: string, actions: PageAction<any>[]): void {
useEffect(() => {
PageActionRegistry.register(pageId, actions);
return (): void => {
PageActionRegistry.unregister(pageId);
};
// Re-register when actions reference changes (e.g. new callbacks after store update)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageId, actions]);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,141 +0,0 @@
export interface MessageAttachment {
name: string;
type: string;
/** data URI for images, or a download URL for other files */
dataUrl: string;
}
export type MessageRole = 'user' | 'assistant';
export type ActionKind =
| 'follow_up'
| 'open_resource'
| 'navigate'
| 'apply_filter'
| 'open_docs'
| 'undo'
| 'revert';
export interface AssistantAction {
id: string;
label: string;
kind: ActionKind;
payload: Record<string, unknown>;
expiresAt: string | null;
}
export type FeedbackRating = 'positive' | 'negative';
// ---------------------------------------------------------------------------
// Message blocks — ordered content blocks for assistant replies
// ---------------------------------------------------------------------------
export interface TextBlock {
type: 'text';
content: string;
}
export interface ThinkingBlock {
type: 'thinking';
content: string;
}
export interface ToolCallBlock {
type: 'tool_call';
toolCallId: string;
toolName: string;
toolInput: unknown;
result?: unknown;
success?: boolean;
}
export type MessageBlock = TextBlock | ThinkingBlock | ToolCallBlock;
export interface Message {
id: string;
role: MessageRole;
content: string;
attachments?: MessageAttachment[];
/** Ordered content blocks for structured rendering of assistant replies. */
blocks?: MessageBlock[];
/** Suggested follow-up actions returned by the assistant (final message only). */
actions?: AssistantAction[];
/** Persisted feedback rating — set after user votes and the API confirms. */
feedbackRating?: FeedbackRating | null;
createdAt: number;
}
export interface Conversation {
id: string;
/** Opaque thread ID assigned by the backend after first message. */
threadId?: string;
messages: Message[];
createdAt: number;
updatedAt?: number;
title?: string;
}
// ---------------------------------------------------------------------------
// Streaming-only types — live during an active SSE stream, never persisted
// ---------------------------------------------------------------------------
/** A single tool invocation tracked during streaming. */
export interface StreamingToolCall {
/** Matches the toolName field in SSE tool_call / tool_result events. */
toolName: string;
input: unknown;
result?: unknown;
/** True once the corresponding tool_result event has been received. */
done: boolean;
}
/**
* An ordered item in the streaming event timeline.
* Text and tool calls are interleaved in arrival order so the UI renders
* them chronologically rather than grouping all tools above all text.
*/
export type StreamingEventItem =
| { kind: 'text'; content: string }
| { kind: 'thinking'; content: string }
| { kind: 'tool'; toolCall: StreamingToolCall };
/** Data from an SSE `approval` event — user must approve or reject before the stream continues. */
export interface PendingApproval {
approvalId: string;
executionId: string;
actionType: string;
resourceType: string;
summary: string;
diff: { before: unknown; after: unknown } | null;
}
/** A single field in a clarification form. */
export interface ClarificationField {
id: string;
/** 'text' | 'number' | 'select' | 'checkbox' | 'radio' */
type: string;
label: string;
required?: boolean;
options?: string[] | null;
default?: string | string[] | null;
}
/** Data from an SSE `clarification` event — user must submit answers before the stream continues. */
export interface PendingClarification {
clarificationId: string;
executionId: string;
message: string;
discoveredContext: Record<string, unknown> | null;
fields: ClarificationField[];
}
/** Per-conversation streaming state. Present in the store's `streams` map only while active. */
export interface ConversationStreamState {
isStreaming: boolean;
streamingContent: string;
streamingStatus: string;
streamingEvents: StreamingEventItem[];
streamingMessageId: string | null;
pendingApproval: PendingApproval | null;
pendingClarification: PendingClarification | null;
}

View File

@@ -48,12 +48,13 @@
}
.app-content {
flex: 1;
min-width: 0; // allow shrinking below natural width when AI panel is open
width: calc(100% - 54px); // width of the sidebar
z-index: 0;
margin: 0 auto;
&.full-screen-content {
width: 100%;
width: 100% !important;
}
.content-container {
@@ -65,6 +66,12 @@
width: 100%;
}
}
&.side-nav-pinned {
.app-content {
width: calc(100% - 240px);
}
}
}
.chat-support-gateway {

View File

@@ -13,7 +13,7 @@ import { useMutation, useQueries } from 'react-query';
import { useDispatch, useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
import * as Sentry from '@sentry/react';
import { Toaster, TooltipProvider } from '@signozhq/ui';
import { Toaster } from '@signozhq/ui';
import { Flex } from 'antd';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
@@ -36,8 +36,6 @@ import { LOCALSTORAGE } from 'constants/localStorage';
import ROUTES from 'constants/routes';
import { GlobalShortcuts } from 'constants/shortcuts/globalShortcuts';
import { USER_PREFERENCES } from 'constants/userPreferences';
import AIAssistantModal from 'container/AIAssistant/AIAssistantModal';
import AIAssistantPanel from 'container/AIAssistant/AIAssistantPanel';
import SideNav from 'container/SideNav';
import TopNav from 'container/TopNav';
import dayjs from 'dayjs';
@@ -394,7 +392,6 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
const pageTitle = t(routeKey);
const isPublicDashboard = pathname.startsWith('/public/dashboard/');
const isAIAssistantPage = pathname.startsWith('/ai-assistant/');
const renderFullScreen =
pathname === ROUTES.GET_STARTED ||
@@ -770,121 +767,110 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
!showTrialExpiryBanner && showPaymentFailedWarning;
return (
<TooltipProvider>
<Layout className={cx(isDarkMode ? 'darkMode dark' : 'lightMode')}>
<Helmet>
<title>{pageTitle}</title>
</Helmet>
<Layout className={cx(isDarkMode ? 'darkMode dark' : 'lightMode')}>
<Helmet>
<title>{pageTitle}</title>
</Helmet>
{isLoggedIn && (
<div className={cx('app-banner-wrapper')}>
{SHOW_TRIAL_EXPIRY_BANNER && (
<div className="trial-expiry-banner">
You are in free trial period. Your free trial will end on{' '}
<span>{getFormattedDate(trialInfo?.trialEnd || Date.now())}.</span>
{user.role === USER_ROLES.ADMIN ? (
<span>
{' '}
Please{' '}
<a className="upgrade-link" onClick={handleUpgrade}>
upgrade
</a>
to continue using SigNoz features.
<span className="refresh-payment-status">
{' '}
| Already upgraded? <RefreshPaymentStatus type="text" />
</span>
</span>
) : (
'Please contact your administrator for upgrading to a paid plan.'
)}
</div>
)}
{SHOW_WORKSPACE_RESTRICTED_BANNER && renderWorkspaceRestrictedBanner()}
{SHOW_PAYMENT_FAILED_BANNER && (
<div className="payment-failed-banner">
Your bill payment has failed. Your workspace will get suspended on{' '}
{isLoggedIn && (
<div className={cx('app-banner-wrapper')}>
{SHOW_TRIAL_EXPIRY_BANNER && (
<div className="trial-expiry-banner">
You are in free trial period. Your free trial will end on{' '}
<span>{getFormattedDate(trialInfo?.trialEnd || Date.now())}.</span>
{user.role === USER_ROLES.ADMIN ? (
<span>
{getFormattedDateWithMinutes(
dayjs(activeLicense?.event_queue?.scheduled_at).unix() || Date.now(),
)}
.
</span>
{user.role === USER_ROLES.ADMIN ? (
<span>
{' '}
Please{' '}
<a className="upgrade-link" onClick={handleUpgrade}>
upgrade
</a>
to continue using SigNoz features.
<span className="refresh-payment-status">
{' '}
Please{' '}
<a className="upgrade-link" onClick={handleFailedPayment}>
pay the bill
</a>
to continue using SigNoz features.
<span className="refresh-payment-status">
{' '}
| Already paid? <RefreshPaymentStatus type="text" />
</span>
| Already upgraded? <RefreshPaymentStatus type="text" />
</span>
) : (
' Please contact your administrator to pay the bill.'
</span>
) : (
'Please contact your administrator for upgrading to a paid plan.'
)}
</div>
)}
{SHOW_WORKSPACE_RESTRICTED_BANNER && renderWorkspaceRestrictedBanner()}
{SHOW_PAYMENT_FAILED_BANNER && (
<div className="payment-failed-banner">
Your bill payment has failed. Your workspace will get suspended on{' '}
<span>
{getFormattedDateWithMinutes(
dayjs(activeLicense?.event_queue?.scheduled_at).unix() || Date.now(),
)}
</div>
)}
</div>
)}
<Flex
className={cx(
'app-layout',
isDarkMode ? 'darkMode dark' : 'lightMode',
isSideNavPinned ? 'side-nav-pinned' : '',
SHOW_WORKSPACE_RESTRICTED_BANNER ? 'isWorkspaceRestricted' : '',
SHOW_TRIAL_EXPIRY_BANNER ? 'isTrialExpired' : '',
SHOW_PAYMENT_FAILED_BANNER ? 'isPaymentFailed' : '',
.
</span>
{user.role === USER_ROLES.ADMIN ? (
<span>
{' '}
Please{' '}
<a className="upgrade-link" onClick={handleFailedPayment}>
pay the bill
</a>
to continue using SigNoz features.
<span className="refresh-payment-status">
{' '}
| Already paid? <RefreshPaymentStatus type="text" />
</span>
</span>
) : (
' Please contact your administrator to pay the bill.'
)}
</div>
)}
</div>
)}
<Flex
className={cx(
'app-layout',
isDarkMode ? 'darkMode dark' : 'lightMode',
isSideNavPinned ? 'side-nav-pinned' : '',
SHOW_WORKSPACE_RESTRICTED_BANNER ? 'isWorkspaceRestricted' : '',
SHOW_TRIAL_EXPIRY_BANNER ? 'isTrialExpired' : '',
SHOW_PAYMENT_FAILED_BANNER ? 'isPaymentFailed' : '',
)}
>
{isToDisplayLayout && !renderFullScreen && (
<SideNav isPinned={isSideNavPinned} />
)}
<div
className={cx('app-content', {
'full-screen-content': renderFullScreen,
})}
data-overlayscrollbars-initialize
>
{isToDisplayLayout && !renderFullScreen && (
<SideNav isPinned={isSideNavPinned} />
)}
<div
className={cx('app-content', {
'full-screen-content': renderFullScreen,
})}
data-overlayscrollbars-initialize
<Sentry.ErrorBoundary
fallback={<ErrorBoundaryFallback />}
ref={errorBoundaryRef}
>
<Sentry.ErrorBoundary
fallback={<ErrorBoundaryFallback />}
ref={errorBoundaryRef}
>
<LayoutContent data-overlayscrollbars-initialize>
<OverlayScrollbar>
<ChildrenContainer>
{isToDisplayLayout && !renderFullScreen && !isAIAssistantPage && (
<TopNav />
)}
{children}
</ChildrenContainer>
</OverlayScrollbar>
</LayoutContent>
</Sentry.ErrorBoundary>
</div>
<LayoutContent data-overlayscrollbars-initialize>
<OverlayScrollbar>
<ChildrenContainer>
{isToDisplayLayout && !renderFullScreen && <TopNav />}
{children}
</ChildrenContainer>
</OverlayScrollbar>
</LayoutContent>
</Sentry.ErrorBoundary>
</div>
</Flex>
{isLoggedIn && (
<>
<AIAssistantPanel />
<AIAssistantModal />
</>
)}
</Flex>
{showAddCreditCardModal && <ChatSupportGateway />}
{showChangelogModal && changelog && (
<ChangelogModal changelog={changelog} onClose={toggleChangelogModal} />
)}
{showAddCreditCardModal && <ChatSupportGateway />}
{showChangelogModal && changelog && (
<ChangelogModal changelog={changelog} onClose={toggleChangelogModal} />
)}
<Toaster />
</Layout>
</TooltipProvider>
<Toaster />
</Layout>
);
}

View File

@@ -1,24 +0,0 @@
import {
IntegrationType,
RequestIntegrationBtn,
} from 'pages/Integrations/RequestIntegrationBtn';
import Header from './Header/Header';
import HeroSection from './HeroSection/HeroSection';
import ServicesTabs from './ServicesSection/ServicesTabs';
function CloudIntegrationPage(): JSX.Element {
return (
<div>
<Header />
<HeroSection />
<RequestIntegrationBtn
type={IntegrationType.AWS_SERVICES}
message="Can't find the AWS service you're looking for? Request more integrations"
/>
<ServicesTabs />
</div>
);
}
export default CloudIntegrationPage;

View File

@@ -1,37 +0,0 @@
import { useIsDarkMode } from 'hooks/useDarkMode';
import integrationsHeroBgUrl from '@/assets/Images/integrations-hero-bg.png';
import awsDarkUrl from '@/assets/Logos/aws-dark.svg';
import AccountActions from './components/AccountActions';
import './HeroSection.style.scss';
function HeroSection(): JSX.Element {
const isDarkMode = useIsDarkMode();
return (
<div
className="hero-section"
style={
isDarkMode
? {
backgroundImage: `url('${integrationsHeroBgUrl}')`,
}
: {}
}
>
<div className="hero-section__icon">
<img src={awsDarkUrl} alt="aws-logo" />
</div>
<div className="hero-section__details">
<div className="title">Amazon Web Services</div>
<div className="description">
One-click setup for AWS monitoring with SigNoz
</div>
<AccountActions />
</div>
</div>
);
}
export default HeroSection;

View File

@@ -1,213 +0,0 @@
import { Dispatch, SetStateAction, useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { Form, Select, Switch } from 'antd';
import SignozModal from 'components/SignozModal/SignozModal';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
getRegionPreviewText,
useAccountSettingsModal,
} from 'hooks/integration/aws/useAccountSettingsModal';
import useUrlQuery from 'hooks/useUrlQuery';
import history from 'lib/history';
import logEvent from '../../../../api/common/logEvent';
import { CloudAccount } from '../../ServicesSection/types';
import { RegionSelector } from './RegionSelector';
import RemoveIntegrationAccount from './RemoveIntegrationAccount';
import './AccountSettingsModal.style.scss';
interface AccountSettingsModalProps {
onClose: () => void;
account: CloudAccount;
setActiveAccount: Dispatch<SetStateAction<CloudAccount | null>>;
}
function AccountSettingsModal({
onClose,
account,
setActiveAccount,
}: AccountSettingsModalProps): JSX.Element {
const {
form,
isLoading,
selectedRegions,
includeAllRegions,
isRegionSelectOpen,
isSaveDisabled,
setSelectedRegions,
setIncludeAllRegions,
setIsRegionSelectOpen,
handleIncludeAllRegionsChange,
handleSubmit,
handleClose,
} = useAccountSettingsModal({ onClose, account, setActiveAccount });
const queryClient = useQueryClient();
const urlQuery = useUrlQuery();
const handleRemoveIntegrationAccountSuccess = (): void => {
queryClient.invalidateQueries([REACT_QUERY_KEY.AWS_ACCOUNTS]);
urlQuery.delete('cloudAccountId');
handleClose();
history.replace({ search: urlQuery.toString() });
logEvent('AWS Integration: Account removed', {
id: account?.id,
cloudAccountId: account?.cloud_account_id,
});
};
const handleRegionDeselect = useCallback(
(item: string): void => {
if (selectedRegions.includes(item)) {
setSelectedRegions(selectedRegions.filter((region) => region !== item));
if (includeAllRegions) {
setIncludeAllRegions(false);
}
}
},
[
selectedRegions,
includeAllRegions,
setSelectedRegions,
setIncludeAllRegions,
],
);
const renderRegionSelector = useCallback(() => {
if (isRegionSelectOpen) {
return (
<RegionSelector
selectedRegions={selectedRegions}
setSelectedRegions={setSelectedRegions}
setIncludeAllRegions={setIncludeAllRegions}
/>
);
}
return (
<>
<div className="account-settings-modal__body-regions-switch-switch ">
<Switch
checked={includeAllRegions}
onChange={handleIncludeAllRegionsChange}
/>
<button
className="account-settings-modal__body-regions-switch-switch-label"
type="button"
onClick={(): void => handleIncludeAllRegionsChange(!includeAllRegions)}
>
Include all regions
</button>
</div>
<Select
suffixIcon={null}
placeholder="Select Region(s)"
className="cloud-account-setup-form__select account-settings-modal__body-regions-select integrations-select"
onClick={(): void => setIsRegionSelectOpen(true)}
mode="multiple"
maxTagCount={3}
value={getRegionPreviewText(selectedRegions)}
open={false}
onDeselect={handleRegionDeselect}
/>
</>
);
}, [
isRegionSelectOpen,
includeAllRegions,
handleIncludeAllRegionsChange,
selectedRegions,
handleRegionDeselect,
setSelectedRegions,
setIncludeAllRegions,
setIsRegionSelectOpen,
]);
const renderAccountDetails = useCallback(
() => (
<div className="account-settings-modal__body-account-info">
<div className="account-settings-modal__body-account-info-connected-account-details">
<div className="account-settings-modal__body-account-info-connected-account-details-title">
Connected Account details
</div>
<div className="account-settings-modal__body-account-info-connected-account-details-account-id">
AWS Account:{' '}
<span className="account-settings-modal__body-account-info-connected-account-details-account-id-account-id">
{account?.id}
</span>
</div>
</div>
</div>
),
[account?.id],
);
const modalTitle = (
<div className="account-settings-modal__title">
Account settings for{' '}
<span className="account-settings-modal__title-account-id">
{account?.id}
</span>
</div>
);
return (
<SignozModal
open
title={modalTitle}
onCancel={handleClose}
onOk={handleSubmit}
okText="Save"
okButtonProps={{
disabled: isSaveDisabled,
className: 'account-settings-modal__footer-save-button',
loading: isLoading,
}}
cancelButtonProps={{
className: 'account-settings-modal__footer-close-button',
}}
width={672}
rootClassName="account-settings-modal"
>
<Form
form={form}
layout="vertical"
initialValues={{
selectedRegions,
includeAllRegions,
}}
>
<div className="account-settings-modal__body">
{renderAccountDetails()}
<Form.Item
name="selectedRegions"
rules={[
{
validator: async (): Promise<void> => {
if (selectedRegions.length === 0) {
throw new Error('Please select at least one region to monitor');
}
},
message: 'Please select at least one region to monitor',
},
]}
>
{renderRegionSelector()}
</Form.Item>
<div className="integration-detail-content">
<RemoveIntegrationAccount
accountId={account?.id}
onRemoveIntegrationAccountSuccess={handleRemoveIntegrationAccountSuccess}
/>
</div>
</div>
</Form>
</SignozModal>
);
}
export default AccountSettingsModal;

View File

@@ -1,109 +0,0 @@
import { useRef } from 'react';
import { Form } from 'antd';
import cx from 'classnames';
import { useAccountStatus } from 'hooks/integration/aws/useAccountStatus';
import { AccountStatusResponse } from 'types/api/integrations/aws';
import { regions } from 'utils/regions';
import logEvent from '../../../../api/common/logEvent';
import { ModalStateEnum, RegionFormProps } from '../types';
import AlertMessage from './AlertMessage';
import {
ComplianceNote,
MonitoringRegionsSection,
RegionDeploymentSection,
} from './IntegrateNowFormSections';
import RenderConnectionFields from './RenderConnectionParams';
const allRegions = (): string[] =>
regions.flatMap((r) => r.subRegions.map((sr) => sr.name));
const getRegionPreviewText = (regions: string[]): string[] => {
if (regions.includes('all')) {
return allRegions();
}
return regions;
};
export function RegionForm({
form,
modalState,
setModalState,
selectedRegions,
includeAllRegions,
onIncludeAllRegionsChange,
onRegionSelect,
onSubmit,
accountId,
selectedDeploymentRegion,
handleRegionChange,
connectionParams,
isConnectionParamsLoading,
}: RegionFormProps): JSX.Element {
const startTimeRef = useRef(Date.now());
const refetchInterval = 10 * 1000;
const errorTimeout = 10 * 60 * 1000;
const { isLoading: isAccountStatusLoading } = useAccountStatus(accountId, {
refetchInterval,
enabled: !!accountId && modalState === ModalStateEnum.WAITING,
onSuccess: (data: AccountStatusResponse) => {
if (data.data.status.integration.last_heartbeat_ts_ms !== null) {
setModalState(ModalStateEnum.SUCCESS);
logEvent('AWS Integration: Account connected', {
cloudAccountId: data?.data?.cloud_account_id,
status: data?.data?.status,
});
} else if (Date.now() - startTimeRef.current >= errorTimeout) {
setModalState(ModalStateEnum.ERROR);
logEvent('AWS Integration: Account connection attempt timed out', {
id: accountId,
});
}
},
onError: () => {
setModalState(ModalStateEnum.ERROR);
},
});
const isFormDisabled =
modalState === ModalStateEnum.WAITING || isAccountStatusLoading;
return (
<Form
form={form}
className="cloud-account-setup-form"
layout="vertical"
onFinish={onSubmit}
>
<AlertMessage modalState={modalState} />
<div
className={cx(`cloud-account-setup-form__content`, {
disabled: isFormDisabled,
})}
>
<RegionDeploymentSection
regions={regions}
handleRegionChange={handleRegionChange}
isFormDisabled={isFormDisabled}
selectedDeploymentRegion={selectedDeploymentRegion}
/>
<MonitoringRegionsSection
includeAllRegions={includeAllRegions}
selectedRegions={selectedRegions}
onIncludeAllRegionsChange={onIncludeAllRegionsChange}
getRegionPreviewText={getRegionPreviewText}
onRegionSelect={onRegionSelect}
isFormDisabled={isFormDisabled}
/>
<ComplianceNote />
<RenderConnectionFields
isConnectionParamsLoading={isConnectionParamsLoading}
connectionParams={connectionParams}
isFormDisabled={isFormDisabled}
/>
</div>
</Form>
);
}

View File

@@ -1,47 +0,0 @@
.remove-integration-account {
display: flex;
justify-content: space-between;
padding: 16px;
border-radius: 4px;
border: 1px solid color-mix(in srgb, var(--bg-sakura-500) 20%, transparent);
background: color-mix(in srgb, var(--bg-sakura-500) 6%, transparent);
&__header {
display: flex;
flex-direction: column;
gap: 6px;
}
&__title {
color: var(--bg-cherry-500);
font-size: 14px;
letter-spacing: -0.07px;
}
&__subtitle {
color: var(--bg-cherry-300);
font-size: 14px;
line-height: 22px;
letter-spacing: -0.07px;
}
&__button {
display: flex;
align-items: center;
background: var(--bg-cherry-500);
border: none;
color: var(--l1-foreground);
font-size: 12px;
font-weight: 500;
line-height: 13.3px; /* 110.833% */
padding: 9px 13px;
.ant-btn-icon {
margin-inline-end: 4px !important;
}
&:hover {
&.ant-btn-default {
color: var(--l2-foreground) !important;
}
}
}
}

View File

@@ -1,94 +0,0 @@
import { useState } from 'react';
import { useMutation } from 'react-query';
import { Button, Modal } from 'antd';
import logEvent from 'api/common/logEvent';
import removeAwsIntegrationAccount from 'api/Integrations/removeAwsIntegrationAccount';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { useNotifications } from 'hooks/useNotifications';
import { X } from 'lucide-react';
import { INTEGRATION_TELEMETRY_EVENTS } from 'pages/Integrations/utils';
import './RemoveIntegrationAccount.scss';
function RemoveIntegrationAccount({
accountId,
onRemoveIntegrationAccountSuccess,
}: {
accountId: string;
onRemoveIntegrationAccountSuccess: () => void;
}): JSX.Element {
const { notifications } = useNotifications();
const [isModalOpen, setIsModalOpen] = useState(false);
const showModal = (): void => {
setIsModalOpen(true);
};
const {
mutate: removeIntegration,
isLoading: isRemoveIntegrationLoading,
} = useMutation(removeAwsIntegrationAccount, {
onSuccess: () => {
onRemoveIntegrationAccountSuccess?.();
setIsModalOpen(false);
},
onError: () => {
notifications.error({
message: SOMETHING_WENT_WRONG,
});
},
});
const handleOk = (): void => {
logEvent(INTEGRATION_TELEMETRY_EVENTS.AWS_INTEGRATION_ACCOUNT_REMOVED, {
accountId,
});
removeIntegration(accountId);
};
const handleCancel = (): void => {
setIsModalOpen(false);
};
return (
<div className="remove-integration-account">
<div className="remove-integration-account__header">
<div className="remove-integration-account__title">Remove Integration</div>
<div className="remove-integration-account__subtitle">
Removing this integration won&apos;t delete any existing data but will stop
collecting new data from AWS.
</div>
</div>
<Button
className="remove-integration-account__button"
icon={<X size={14} />}
onClick={(): void => showModal()}
>
Remove
</Button>
<Modal
className="remove-integration-modal"
open={isModalOpen}
title="Remove integration"
onOk={handleOk}
onCancel={handleCancel}
okText="Remove Integration"
okButtonProps={{
danger: true,
disabled: isRemoveIntegrationLoading,
}}
>
<div className="remove-integration-modal__text">
Removing this account will remove all components created for sending
telemetry to SigNoz in your AWS account within the next ~15 minutes
(cloudformation stacks named signoz-integration-telemetry-collection in
enabled regions). <br />
<br />
After that, you can delete the cloudformation stack that was created
manually when connecting this account.
</div>
</Modal>
</div>
);
}
export default RemoveIntegrationAccount;

View File

@@ -1,162 +0,0 @@
.cloud-account-setup-success-view {
display: flex;
flex-direction: column;
gap: 40px;
text-align: center;
padding-top: 34px;
p,
h3,
h4 {
margin: 0;
}
&__content {
display: flex;
flex-direction: column;
gap: 14px;
.cloud-account-setup-success-view {
&__title {
h3 {
color: var(--l1-foreground);
font-size: 20px;
font-weight: 500;
line-height: 32px;
}
}
&__description {
color: var(--l2-foreground);
font-size: 14px;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.07px;
}
}
}
&__what-next {
display: flex;
flex-direction: column;
gap: 18px;
text-align: left;
&-title {
color: var(--muted-foreground);
font-size: 11px;
font-weight: 500;
line-height: 18px;
letter-spacing: 0.88px;
text-transform: uppercase;
}
.what-next-items-wrapper {
display: flex;
flex-direction: column;
gap: 12px;
&__item {
display: flex;
gap: 10px;
align-items: baseline;
&.ant-alert {
padding: 14px;
border-radius: 8px;
font-size: 14px;
line-height: 20px; /* 142.857% */
letter-spacing: -0.21px;
}
&.ant-alert-info {
border: 1px solid color-mix(in srgb, var(--bg-robin-600) 50%, transparent);
background: color-mix(in srgb, var(--primary-background) 20%, transparent);
color: var(--primary-foreground);
}
.what-next-item {
color: var(--bg-robin-400);
&-bullet-icon {
font-size: 20px;
line-height: 20px;
}
&-text {
font-size: 14px;
font-weight: 500;
line-height: 20px;
letter-spacing: -0.21px;
}
}
}
}
}
&__footer {
padding-top: 18px;
.ant-btn {
background: var(--primary-background);
color: var(--primary-foreground);
font-size: 14px;
font-weight: 500;
line-height: 20px;
height: 36px;
}
}
}
.lottie-container {
position: absolute;
width: 743.5px;
height: 990.342px;
top: -100px;
left: -36px;
z-index: 1;
}
.lightMode {
.cloud-account-setup-success-view {
&__content {
.cloud-account-setup-success-view {
&__title {
h3 {
color: var(--l1-foreground);
}
}
&__description {
color: var(--l1-foreground);
}
}
}
&__what-next {
&-title {
color: var(--l1-foreground);
}
.what-next-items-wrapper {
&__item {
&.ant-alert-info {
border: 1px solid color-mix(in srgb, var(--bg-robin-600) 20%, transparent);
background: color-mix(
in srgb,
var(--primary-background) 10%,
transparent
);
color: var(--primary-foreground);
}
.what-next-item {
color: var(--primary-foreground);
&-text {
color: var(--primary-foreground);
}
}
}
}
}
&__footer {
.ant-btn {
background: var(--primary-background);
color: var(--primary-foreground);
&:hover {
background: var(--primary-background-hover);
}
}
}
}
}

View File

@@ -1,75 +0,0 @@
import { useState } from 'react';
import Lottie from 'react-lottie';
import { Alert } from 'antd';
import integrationsSuccess from 'assets/Lotties/integrations-success.json';
import solidCheckCircleUrl from '@/assets/Icons/solid-check-circle.svg';
import './SuccessView.style.scss';
export function SuccessView(): JSX.Element {
const [isAnimationComplete, setIsAnimationComplete] = useState(false);
const defaultOptions = {
loop: false,
autoplay: true,
animationData: integrationsSuccess,
rendererSettings: {
preserveAspectRatio: 'xMidYMid slice',
},
};
return (
<>
{!isAnimationComplete && (
<div className="lottie-container">
<Lottie
options={defaultOptions}
height={990.342}
width={743.5}
eventListeners={[
{
eventName: 'complete',
callback: (): void => setIsAnimationComplete(true),
},
]}
/>
</div>
)}
<div className="cloud-account-setup-success-view">
<div className="cloud-account-setup-success-view__icon">
<img src={solidCheckCircleUrl} alt="Success" />
</div>
<div className="cloud-account-setup-success-view__content">
<div className="cloud-account-setup-success-view__title">
<h3>🎉 Success! </h3>
<h3>Your AWS Web Service integration is all set.</h3>
</div>
<div className="cloud-account-setup-success-view__description">
<p>Your observability journey is off to a great start. </p>
<p>Now that your data is flowing, heres what you can do next:</p>
</div>
</div>
<div className="cloud-account-setup-success-view__what-next">
<h4 className="cloud-account-setup-success-view__what-next-title">
WHAT NEXT
</h4>
<div className="what-next-items-wrapper">
<Alert
message={
<div className="what-next-items-wrapper__item">
<div className="what-next-item-bullet-icon"></div>
<div className="what-next-item-text">
Set up your AWS services effortlessly under your enabled account.
</div>
</div>
}
type="info"
className="what-next-items-wrapper__item"
/>
</div>
</div>
</div>
</>
);
}

View File

@@ -1,50 +0,0 @@
import { Link } from 'react-router-dom';
import { ServiceData } from './types';
function DashboardItem({
dashboard,
}: {
dashboard: ServiceData['assets']['dashboards'][number];
}): JSX.Element {
const content = (
<>
<div className="cloud-service-dashboard-item__title">{dashboard.title}</div>
<div className="cloud-service-dashboard-item__preview">
<img
src={dashboard.image}
alt={dashboard.title}
className="cloud-service-dashboard-item__preview-image"
/>
</div>
</>
);
return (
<div className="cloud-service-dashboard-item">
{dashboard.url ? (
<Link to={dashboard.url} className="cloud-service-dashboard-item__link">
{content}
</Link>
) : (
content
)}
</div>
);
}
function CloudServiceDashboards({
service,
}: {
service: ServiceData;
}): JSX.Element {
return (
<>
{service.assets.dashboards.map((dashboard) => (
<DashboardItem key={dashboard.id} dashboard={dashboard} />
))}
</>
);
}
export default CloudServiceDashboards;

View File

@@ -1,89 +0,0 @@
.configure-service-modal {
&__body {
display: flex;
flex-direction: column;
border-radius: 3px;
border: 1px solid var(--l1-border);
padding: 14px;
&-regions-switch-switch {
display: flex;
align-items: center;
gap: 6px;
&-label {
color: var(--l1-foreground);
font-size: 14px;
font-weight: 500;
line-height: 20px;
letter-spacing: -0.07px;
}
}
&-switch-description {
margin-top: 4px;
color: var(--l2-foreground);
font-size: 12px;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.06px;
}
&-form-item {
&:last-child {
margin-bottom: 0px;
}
}
}
.ant-modal-body {
padding-bottom: 0;
}
.ant-modal-footer {
margin: 0;
padding-bottom: 12px;
}
}
.lightMode {
.configure-service-modal {
&__body {
border-color: var(--l1-border);
&-regions-switch-switch {
&-label {
color: var(--l1-foreground);
}
}
&-switch-description {
color: var(--l1-foreground);
}
}
.ant-btn {
&.ant-btn-default {
background: var(--l1-background);
border: 1px solid var(--l1-border);
color: var(--l1-foreground);
&:hover {
border-color: var(--l1-border);
color: var(--l1-foreground);
}
}
&.ant-btn-primary {
// Keep primary button same as dark mode
background: var(--primary-background);
color: var(--l1-background);
&:hover {
background: var(--bg-robin-400);
}
&:disabled {
opacity: 0.6;
}
}
}
}
}

View File

@@ -1,243 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { useQueryClient } from 'react-query';
import { Form, Switch } from 'antd';
import SignozModal from 'components/SignozModal/SignozModal';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
ServiceConfig,
SupportedSignals,
} from 'container/CloudIntegrationPage/ServicesSection/types';
import { useUpdateServiceConfig } from 'hooks/integration/aws/useUpdateServiceConfig';
import { isEqual } from 'lodash-es';
import logEvent from '../../../api/common/logEvent';
import S3BucketsSelector from './S3BucketsSelector';
import './ConfigureServiceModal.styles.scss';
export interface IConfigureServiceModalProps {
isOpen: boolean;
onClose: () => void;
serviceName: string;
serviceId: string;
cloudAccountId: string;
supportedSignals: SupportedSignals;
initialConfig?: ServiceConfig;
}
function ConfigureServiceModal({
isOpen,
onClose,
serviceName,
serviceId,
cloudAccountId,
initialConfig,
supportedSignals,
}: IConfigureServiceModalProps): JSX.Element {
const [form] = Form.useForm();
const [isLoading, setIsLoading] = useState(false);
// Track current form values
const initialValues = useMemo(
() => ({
metrics: initialConfig?.metrics?.enabled || false,
logs: initialConfig?.logs?.enabled || false,
s3Buckets: initialConfig?.logs?.s3_buckets || {},
}),
[initialConfig],
);
const [currentValues, setCurrentValues] = useState(initialValues);
const isSaveDisabled = useMemo(
() =>
// disable only if current values are same as the initial config
currentValues.metrics === initialValues.metrics &&
currentValues.logs === initialValues.logs &&
isEqual(currentValues.s3Buckets, initialValues.s3Buckets),
[currentValues, initialValues],
);
const handleS3BucketsChange = useCallback(
(bucketsByRegion: Record<string, string[]>) => {
setCurrentValues((prev) => ({
...prev,
s3Buckets: bucketsByRegion,
}));
form.setFieldsValue({ s3Buckets: bucketsByRegion });
},
[form],
);
const {
mutate: updateServiceConfig,
isLoading: isUpdating,
} = useUpdateServiceConfig();
const queryClient = useQueryClient();
const handleSubmit = useCallback(async (): Promise<void> => {
try {
const values = await form.validateFields();
setIsLoading(true);
updateServiceConfig(
{
serviceId,
payload: {
cloud_account_id: cloudAccountId,
config: {
logs: {
enabled: values.logs,
s3_buckets: values.s3Buckets,
},
metrics: {
enabled: values.metrics,
},
},
},
},
{
onSuccess: () => {
queryClient.invalidateQueries([
REACT_QUERY_KEY.AWS_SERVICE_DETAILS,
serviceId,
]);
onClose();
logEvent('AWS Integration: Service settings saved', {
cloudAccountId,
serviceId,
logsEnabled: values?.logs,
metricsEnabled: values?.metrics,
});
},
onError: (error) => {
console.error('Failed to update service config:', error);
},
},
);
} catch (error) {
console.error('Form submission failed:', error);
} finally {
setIsLoading(false);
}
}, [
form,
updateServiceConfig,
serviceId,
cloudAccountId,
queryClient,
onClose,
]);
const handleClose = useCallback(() => {
form.resetFields();
onClose();
}, [form, onClose]);
return (
<SignozModal
title={
<div className="account-settings-modal__title">Configure {serviceName}</div>
}
centered
open={isOpen}
okText="Save"
okButtonProps={{
disabled: isSaveDisabled,
className: 'account-settings-modal__footer-save-button',
loading: isLoading || isUpdating,
}}
onCancel={handleClose}
onOk={handleSubmit}
cancelText="Close"
cancelButtonProps={{
className: 'account-settings-modal__footer-close-button',
}}
width={672}
rootClassName=" configure-service-modal"
>
<Form
form={form}
layout="vertical"
initialValues={{
metrics: initialConfig?.metrics?.enabled || false,
logs: initialConfig?.logs?.enabled || false,
s3Buckets: initialConfig?.logs?.s3_buckets || {},
}}
>
<div className=" configure-service-modal__body">
{supportedSignals.metrics && (
<Form.Item
name="metrics"
valuePropName="checked"
className="configure-service-modal__body-form-item"
>
<div className="configure-service-modal__body-regions-switch-switch">
<Switch
checked={currentValues.metrics}
onChange={(checked): void => {
setCurrentValues((prev) => ({ ...prev, metrics: checked }));
form.setFieldsValue({ metrics: checked });
}}
/>
<span className="configure-service-modal__body-regions-switch-switch-label">
Metric Collection
</span>
</div>
<div className="configure-service-modal__body-switch-description">
Metric Collection is enabled for this AWS account. We recommend keeping
this enabled, but you can disable metric collection if you do not want
to monitor your AWS infrastructure.
</div>
</Form.Item>
)}
{supportedSignals.logs && (
<>
<Form.Item
name="logs"
valuePropName="checked"
className="configure-service-modal__body-form-item"
>
<div className="configure-service-modal__body-regions-switch-switch">
<Switch
checked={currentValues.logs}
onChange={(checked): void => {
setCurrentValues((prev) => ({ ...prev, logs: checked }));
form.setFieldsValue({ logs: checked });
}}
/>
<span className="configure-service-modal__body-regions-switch-switch-label">
Log Collection
</span>
</div>
<div className="configure-service-modal__body-switch-description">
To ingest logs from your AWS services, you must complete several steps
</div>
</Form.Item>
{currentValues.logs && serviceId === 's3sync' && (
<Form.Item name="s3Buckets" noStyle>
<S3BucketsSelector
initialBucketsByRegion={currentValues.s3Buckets}
onChange={handleS3BucketsChange}
/>
</Form.Item>
)}
</>
)}
</div>
</Form>
</SignozModal>
);
}
ConfigureServiceModal.defaultProps = {
initialConfig: {
metrics: { enabled: false },
logs: { enabled: false },
},
};
export default ConfigureServiceModal;

View File

@@ -1,189 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { Button, Tabs, TabsProps } from 'antd';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import Spinner from 'components/Spinner';
import CloudServiceDashboards from 'container/CloudIntegrationPage/ServicesSection/CloudServiceDashboards';
import CloudServiceDataCollected from 'container/CloudIntegrationPage/ServicesSection/CloudServiceDataCollected';
import { IServiceStatus } from 'container/CloudIntegrationPage/ServicesSection/types';
import dayjs from 'dayjs';
import { useServiceDetails } from 'hooks/integration/aws/useServiceDetails';
import useUrlQuery from 'hooks/useUrlQuery';
import logEvent from '../../../api/common/logEvent';
import ConfigureServiceModal from './ConfigureServiceModal';
const getStatus = (
logsLastReceivedTimestamp: number | undefined,
metricsLastReceivedTimestamp: number | undefined,
): { text: string; className: string } => {
if (!logsLastReceivedTimestamp && !metricsLastReceivedTimestamp) {
return { text: 'No Data Yet', className: 'service-status--no-data' };
}
const latestTimestamp = Math.max(
logsLastReceivedTimestamp || 0,
metricsLastReceivedTimestamp || 0,
);
const isStale = dayjs().diff(dayjs(latestTimestamp), 'minute') > 30;
if (isStale) {
return { text: 'Stale Data', className: 'service-status--stale-data' };
}
return { text: 'Connected', className: 'service-status--connected' };
};
function ServiceStatus({
serviceStatus,
}: {
serviceStatus: IServiceStatus | undefined;
}): JSX.Element {
const logsLastReceivedTimestamp = serviceStatus?.logs?.last_received_ts_ms;
const metricsLastReceivedTimestamp =
serviceStatus?.metrics?.last_received_ts_ms;
const { text, className } = getStatus(
logsLastReceivedTimestamp,
metricsLastReceivedTimestamp,
);
return <div className={`service-status ${className}`}>{text}</div>;
}
function getTabItems(serviceDetailsData: any): TabsProps['items'] {
const dashboards = serviceDetailsData?.assets.dashboards || [];
const dataCollected = serviceDetailsData?.data_collected || {};
const items: TabsProps['items'] = [];
if (dashboards.length) {
items.push({
key: 'dashboards',
label: `Dashboards (${dashboards.length})`,
children: <CloudServiceDashboards service={serviceDetailsData} />,
});
}
items.push({
key: 'data-collected',
label: 'Data Collected',
children: (
<CloudServiceDataCollected
logsData={dataCollected.logs || []}
metricsData={dataCollected.metrics || []}
/>
),
});
return items;
}
function ServiceDetails(): JSX.Element | null {
const urlQuery = useUrlQuery();
const cloudAccountId = urlQuery.get('cloudAccountId');
const serviceId = urlQuery.get('service');
const [isConfigureServiceModalOpen, setIsConfigureServiceModalOpen] = useState(
false,
);
const openServiceConfigModal = (): void => {
setIsConfigureServiceModalOpen(true);
logEvent('AWS Integration: Service settings viewed', {
cloudAccountId,
serviceId,
});
};
const { data: serviceDetailsData, isLoading } = useServiceDetails(
serviceId || '',
cloudAccountId || undefined,
);
const { config, supported_signals } = serviceDetailsData ?? {};
const totalSupportedSignals = Object.entries(supported_signals || {}).filter(
([, value]) => !!value,
).length;
const enabledSignals = useMemo(
() =>
Object.values(config || {}).filter((item) => item && item.enabled).length,
[config],
);
const isAnySignalConfigured = useMemo(
() => !!config?.logs?.enabled || !!config?.metrics?.enabled,
[config],
);
// log telemetry event on visiting details of a service.
useEffect(() => {
if (serviceId) {
logEvent('AWS Integration: Service viewed', {
cloudAccountId,
serviceId,
});
}
}, [cloudAccountId, serviceId]);
if (isLoading) {
return <Spinner size="large" height="50vh" />;
}
if (!serviceDetailsData) {
return null;
}
const tabItems = getTabItems(serviceDetailsData);
return (
<div className="service-details">
<div className="service-details__title-bar">
<div className="service-details__details-title">Details</div>
<div className="service-details__right-actions">
{isAnySignalConfigured && (
<ServiceStatus serviceStatus={serviceDetailsData.status} />
)}
{!!cloudAccountId &&
(isAnySignalConfigured ? (
<Button
className="configure-button configure-button--default"
onClick={openServiceConfigModal}
>
Configure ({enabledSignals}/{totalSupportedSignals})
</Button>
) : (
<Button
type="primary"
className="configure-button configure-button--primary"
onClick={openServiceConfigModal}
>
Enable Service
</Button>
))}
</div>
</div>
<div className="service-details__overview">
<MarkdownRenderer
variables={{}}
markdownContent={serviceDetailsData?.overview}
/>
</div>
<div className="service-details__tabs">
<Tabs items={tabItems} />
</div>
{isConfigureServiceModalOpen && (
<ConfigureServiceModal
isOpen
onClose={(): void => setIsConfigureServiceModalOpen(false)}
serviceName={serviceDetailsData.title}
serviceId={serviceId || ''}
cloudAccountId={cloudAccountId || ''}
initialConfig={serviceDetailsData.config}
supportedSignals={serviceDetailsData.supported_signals || {}}
/>
)}
</div>
);
}
export default ServiceDetails;

View File

@@ -1,75 +0,0 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom-v5-compat';
import Spinner from 'components/Spinner';
import { useGetAccountServices } from 'hooks/integration/aws/useGetAccountServices';
import useUrlQuery from 'hooks/useUrlQuery';
import ServiceItem from './ServiceItem';
interface ServicesListProps {
cloudAccountId: string;
filter: 'all_services' | 'enabled' | 'available';
}
function ServicesList({
cloudAccountId,
filter,
}: ServicesListProps): JSX.Element {
const urlQuery = useUrlQuery();
const navigate = useNavigate();
const { data: services = [], isLoading } = useGetAccountServices(
cloudAccountId,
);
const activeService = urlQuery.get('service');
const handleActiveService = useCallback(
(serviceId: string): void => {
const latestUrlQuery = new URLSearchParams(window.location.search);
latestUrlQuery.set('service', serviceId);
navigate({ search: latestUrlQuery.toString() });
},
[navigate],
);
const filteredServices = useMemo(() => {
if (filter === 'all_services') {
return services;
}
return services.filter((service) => {
const isEnabled =
service?.config?.logs?.enabled || service?.config?.metrics?.enabled;
return filter === 'enabled' ? isEnabled : !isEnabled;
});
}, [services, filter]);
useEffect(() => {
if (activeService || !services?.length) {
return;
}
handleActiveService(services[0].id);
}, [services, activeService, handleActiveService]);
if (isLoading) {
return <Spinner size="large" height="25vh" />;
}
if (!services) {
return <div>No services found</div>;
}
return (
<div className="services-list">
{filteredServices.map((service) => (
<ServiceItem
key={service.id}
service={service}
onClick={handleActiveService}
isActive={service.id === activeService}
/>
))}
</div>
);
}
export default ServicesList;

View File

@@ -1,124 +0,0 @@
import { useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { Color } from '@signozhq/design-tokens';
import type { SelectProps, TabsProps } from 'antd';
import { Select, Tabs } from 'antd';
import { getAwsServices } from 'api/integration/aws';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import useUrlQuery from 'hooks/useUrlQuery';
import { ChevronDown } from 'lucide-react';
import ServiceDetails from './ServiceDetails';
import ServicesList from './ServicesList';
import './ServicesTabs.style.scss';
export enum ServiceFilterType {
ALL_SERVICES = 'all_services',
ENABLED = 'enabled',
AVAILABLE = 'available',
}
interface ServicesFilterProps {
cloudAccountId: string;
onFilterChange: (value: ServiceFilterType) => void;
}
function ServicesFilter({
cloudAccountId,
onFilterChange,
}: ServicesFilterProps): JSX.Element | null {
const { data: services, isLoading } = useQuery(
[REACT_QUERY_KEY.AWS_SERVICES, cloudAccountId],
() => getAwsServices(cloudAccountId),
);
const { enabledCount, availableCount } = useMemo(() => {
if (!services) {
return { enabledCount: 0, availableCount: 0 };
}
return services.reduce(
(acc, service) => {
const isEnabled =
service?.config?.logs?.enabled || service?.config?.metrics?.enabled;
return {
enabledCount: acc.enabledCount + (isEnabled ? 1 : 0),
availableCount: acc.availableCount + (isEnabled ? 0 : 1),
};
},
{ enabledCount: 0, availableCount: 0 },
);
}, [services]);
const selectOptions: SelectProps['options'] = useMemo(
() => [
{ value: 'all_services', label: `All Services (${services?.length || 0})` },
{ value: 'enabled', label: `Enabled (${enabledCount})` },
{ value: 'available', label: `Available (${availableCount})` },
],
[services, enabledCount, availableCount],
);
if (isLoading) {
return null;
}
if (!services?.length) {
return null;
}
return (
<div className="services-filter">
<Select
style={{ width: '100%' }}
defaultValue={ServiceFilterType.ALL_SERVICES}
options={selectOptions}
className="services-sidebar__select"
suffixIcon={<ChevronDown size={16} color={Color.BG_VANILLA_400} />}
onChange={onFilterChange}
/>
</div>
);
}
function ServicesSection(): JSX.Element {
const urlQuery = useUrlQuery();
const cloudAccountId = urlQuery.get('cloudAccountId') || '';
const [activeFilter, setActiveFilter] = useState<
'all_services' | 'enabled' | 'available'
>('all_services');
return (
<div className="services-section">
<div className="services-section__sidebar">
<ServicesFilter
cloudAccountId={cloudAccountId}
onFilterChange={setActiveFilter}
/>
<ServicesList cloudAccountId={cloudAccountId} filter={activeFilter} />
</div>
<div className="services-section__content">
<ServiceDetails />
</div>
</div>
);
}
function ServicesTabs(): JSX.Element {
const tabItems: TabsProps['items'] = [
{
key: 'services',
label: 'Services For Integration',
children: <ServicesSection />,
},
];
return (
<div className="services-tabs">
<Tabs defaultActiveKey="services" items={tabItems} />
</div>
);
}
export default ServicesTabs;

View File

@@ -1,161 +0,0 @@
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
import { server } from 'mocks-server/server';
import { rest, RestRequest } from 'msw'; // Import RestRequest for req.json() typing
import { UpdateServiceConfigPayload } from '../types';
import { accountsResponse, CLOUD_ACCOUNT_ID, initialBuckets } from './mockData';
import {
assertGenericModalElements,
assertS3SyncSpecificElements,
renderModal,
} from './utils';
// --- MOCKS ---
jest.mock('hooks/useUrlQuery', () => ({
__esModule: true,
default: jest.fn(() => ({
get: jest.fn((paramName: string) => {
if (paramName === 'cloudAccountId') {
return CLOUD_ACCOUNT_ID;
}
return null;
}),
})),
}));
// --- TEST SUITE ---
describe('ConfigureServiceModal for S3 Sync service', () => {
jest.setTimeout(10000);
beforeEach(() => {
server.use(
rest.get(
'http://localhost/api/v1/cloud-integrations/aws/accounts',
(req, res, ctx) => res(ctx.json(accountsResponse)),
),
);
});
it('should render with logs collection switch and bucket selectors (no buckets initially selected)', async () => {
act(() => {
renderModal({}); // No initial S3 buckets, defaults to 's3sync' serviceId
});
await assertGenericModalElements(); // Use new generic assertion
await assertS3SyncSpecificElements({}); // Use new S3-specific assertion
});
it('should render with logs collection switch and bucket selectors (some buckets initially selected)', async () => {
act(() => {
renderModal(initialBuckets); // Defaults to 's3sync' serviceId
});
await assertGenericModalElements(); // Use new generic assertion
await assertS3SyncSpecificElements(initialBuckets); // Use new S3-specific assertion
});
it('should enable save button after adding a new bucket via combobox', async () => {
act(() => {
renderModal(initialBuckets); // Defaults to 's3sync' serviceId
});
await assertGenericModalElements();
await assertS3SyncSpecificElements(initialBuckets);
expect(screen.getByRole('button', { name: /save/i })).toBeDisabled();
const targetCombobox = screen.getAllByRole('combobox')[0];
const newBucketName = 'a-newly-added-bucket';
act(() => {
fireEvent.change(targetCombobox, { target: { value: newBucketName } });
fireEvent.keyDown(targetCombobox, {
key: 'Enter',
code: 'Enter',
keyCode: 13,
});
});
await waitFor(() => {
expect(screen.getByLabelText(newBucketName)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /save/i })).toBeEnabled();
});
});
it('should send updated bucket configuration on save', async () => {
let capturedPayload: UpdateServiceConfigPayload | null = null;
const mockUpdateConfigUrl =
'http://localhost/api/v1/cloud-integrations/aws/services/s3sync/config';
// Override POST handler specifically for this test to capture payload
server.use(
rest.post(mockUpdateConfigUrl, async (req: RestRequest, res, ctx) => {
capturedPayload = await req.json();
return res(ctx.status(200), ctx.json({ message: 'Config updated' }));
}),
);
act(() => {
renderModal(initialBuckets); // Defaults to 's3sync' serviceId
});
await assertGenericModalElements();
await assertS3SyncSpecificElements(initialBuckets);
expect(screen.getByRole('button', { name: /save/i })).toBeDisabled();
const newBucketName = 'another-new-bucket';
// As before, targeting the first combobox, assumed to be for 'ap-south-1'.
const targetCombobox = screen.getAllByRole('combobox')[0];
act(() => {
fireEvent.change(targetCombobox, { target: { value: newBucketName } });
fireEvent.keyDown(targetCombobox, {
key: 'Enter',
code: 'Enter',
keyCode: 13,
});
});
await waitFor(() => {
expect(screen.getByLabelText(newBucketName)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /save/i })).toBeEnabled();
act(() => {
fireEvent.click(screen.getByRole('button', { name: /save/i }));
});
});
await waitFor(() => {
expect(capturedPayload).not.toBeNull();
});
expect(capturedPayload).toEqual({
cloud_account_id: CLOUD_ACCOUNT_ID,
config: {
logs: {
enabled: true,
s3_buckets: {
'us-east-2': ['first-bucket', 'second-bucket'], // Existing buckets
'ap-south-1': [newBucketName], // Newly added bucket for the first region
},
},
metrics: {},
},
});
});
it('should not render S3 bucket region selector UI for services other than s3sync', async () => {
const otherServiceId = 'cloudwatch';
act(() => {
renderModal({}, otherServiceId);
});
await assertGenericModalElements();
await waitFor(() => {
expect(
screen.queryByRole('heading', { name: /select s3 buckets by region/i }),
).not.toBeInTheDocument();
const regions = accountsResponse.data.accounts[0]?.config?.regions || [];
regions.forEach((region) => {
expect(
screen.queryByText(`Enter S3 bucket names for ${region}`),
).not.toBeInTheDocument();
});
});
});
});

View File

@@ -1,44 +0,0 @@
import { IConfigureServiceModalProps } from '../ConfigureServiceModal';
const CLOUD_ACCOUNT_ID = '123456789012';
const initialBuckets = { 'us-east-2': ['first-bucket', 'second-bucket'] };
const accountsResponse = {
status: 'success',
data: {
accounts: [
{
id: 'a1b2c3d4-e5f6-7890-1234-567890abcdef',
cloud_account_id: CLOUD_ACCOUNT_ID,
config: {
regions: ['ap-south-1', 'ap-south-2', 'us-east-1', 'us-east-2'],
},
status: {
integration: {
last_heartbeat_ts_ms: 1747114366214,
},
},
},
],
},
};
const defaultModalProps: Omit<IConfigureServiceModalProps, 'initialConfig'> = {
isOpen: true,
onClose: jest.fn(),
serviceName: 'S3 Sync',
serviceId: 's3sync',
cloudAccountId: CLOUD_ACCOUNT_ID,
supportedSignals: {
logs: true,
metrics: false,
},
};
export {
accountsResponse,
CLOUD_ACCOUNT_ID,
defaultModalProps,
initialBuckets,
};

View File

@@ -1,78 +0,0 @@
import { render, RenderResult, screen, waitFor } from '@testing-library/react';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import ConfigureServiceModal from '../ConfigureServiceModal';
import { accountsResponse, defaultModalProps } from './mockData';
/**
* Renders the ConfigureServiceModal with specified S3 bucket initial configurations.
*/
const renderModal = (
initialConfigLogsS3Buckets: Record<string, string[]> = {},
serviceId = 's3sync',
): RenderResult => {
const initialConfig = {
logs: { enabled: true, s3_buckets: initialConfigLogsS3Buckets },
metrics: { enabled: false },
};
return render(
<MockQueryClientProvider>
<ConfigureServiceModal
{...defaultModalProps}
serviceId={serviceId}
initialConfig={initialConfig}
/>
</MockQueryClientProvider>,
);
};
/**
* Asserts that generic UI elements of the modal are present.
*/
const assertGenericModalElements = async (): Promise<void> => {
await waitFor(() => {
expect(screen.getByRole('switch')).toBeInTheDocument();
expect(screen.getByText(/log collection/i)).toBeInTheDocument();
expect(
screen.getByText(
/to ingest logs from your aws services, you must complete several steps/i,
),
).toBeInTheDocument();
});
};
/**
* Asserts the state of S3 bucket selectors for each region, specific to S3 Sync.
*/
const assertS3SyncSpecificElements = async (
expectedBucketsByRegion: Record<string, string[]> = {},
): Promise<void> => {
const regions = accountsResponse.data.accounts[0]?.config?.regions || [];
await waitFor(() => {
expect(
screen.getByRole('heading', { name: /select s3 buckets by region/i }),
).toBeInTheDocument();
regions.forEach((region) => {
expect(screen.getByText(region)).toBeInTheDocument();
const bucketsForRegion = expectedBucketsByRegion[region] || [];
if (bucketsForRegion.length > 0) {
bucketsForRegion.forEach((bucket) => {
expect(screen.getByText(bucket)).toBeInTheDocument();
});
} else {
expect(
screen.getByText(`Enter S3 bucket names for ${region}`),
).toBeInTheDocument();
}
});
});
};
export {
assertGenericModalElements,
assertS3SyncSpecificElements,
renderModal,
};

View File

@@ -1,64 +0,0 @@
import { I18nextProvider } from 'react-i18next';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import {
IntegrationType,
RequestIntegrationBtn,
} from 'pages/Integrations/RequestIntegrationBtn';
import i18n from 'ReactI18';
describe('Request AWS integration', () => {
it('should render the request integration button', async () => {
let capturedPayload: any;
server.use(
rest.post('http://localhost/api/v1/event', async (req, res, ctx) => {
capturedPayload = await req.json();
return res(
ctx.status(200),
ctx.json({
statusCode: 200,
error: null,
payload: 'Event Processed Successfully',
}),
);
}),
);
act(() => {
render(
<I18nextProvider i18n={i18n}>
<RequestIntegrationBtn type={IntegrationType.AWS_SERVICES} />{' '}
</I18nextProvider>,
);
});
expect(
screen.getByText(
/can't find what youre looking for\? request more integrations/i,
),
).toBeInTheDocument();
await act(() => {
fireEvent.change(screen.getByPlaceholderText(/Enter integration name/i), {
target: { value: 's3 sync' },
});
const submitButton = screen.getByRole('button', { name: /submit/i });
expect(submitButton).toBeEnabled();
fireEvent.click(submitButton);
});
expect(capturedPayload.eventName).toBeDefined();
expect(capturedPayload.attributes).toBeDefined();
expect(capturedPayload.eventName).toBe('AWS service integration requested');
expect(capturedPayload.attributes).toEqual({
screen: 'AWS integration details',
integration: 's3 sync',
deployment_url: 'localhost',
user_email: null,
});
});
});

View File

@@ -1,8 +1,10 @@
.hero-section {
height: 308px;
padding: 26px 16px;
padding: 16px;
display: flex;
gap: 24px;
flex-direction: column;
gap: 16px;
position: relative;
overflow: hidden;
background-position: right;
@@ -30,7 +32,36 @@
flex-direction: column;
gap: 12px;
.title {
&-header {
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
&__icon {
height: fit-content;
background-color: var(--l1-background);
padding: 12px;
border: 1px solid var(--l2-background);
border-radius: 6px;
width: 60px;
height: 60px;
}
&__title {
color: var(--l1-foreground);
font-size: 24px;
font-weight: 500;
line-height: 20px;
letter-spacing: -0.12px;
}
}
&__description {
color: var(--l2-foreground);
}
&-title {
color: var(--l1-foreground);
font-size: 24px;
font-weight: 500;
@@ -38,7 +69,7 @@
letter-spacing: -0.12px;
}
.description {
&-description {
color: var(--l2-foreground);
font-size: 12px;
font-weight: 400;

View File

@@ -0,0 +1,28 @@
import awsDarkLogoUrl from '@/assets/Logos/aws-dark.svg';
import AccountActions from './components/AccountActions';
import './HeroSection.style.scss';
function HeroSection(): JSX.Element {
return (
<div className="hero-section">
<div className="hero-section__details">
<div className="hero-section__details-header">
<div className="hero-section__icon">
<img src={awsDarkLogoUrl} alt="AWS" />
</div>
<div className="hero-section__details-title">AWS</div>
</div>
<div className="hero-section__details-description">
AWS is a cloud computing platform that provides a range of services for
building and running applications.
</div>
</div>
<AccountActions />
</div>
);
}
export default HeroSection;

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