mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-02 09:30:42 +01:00
Compare commits
28 Commits
nv/area-ch
...
feat/quick
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd30597641 | ||
|
|
70335dc707 | ||
|
|
eb2d094b1f | ||
|
|
7c50fe3763 | ||
|
|
160a1b018c | ||
|
|
27acdbd970 | ||
|
|
7eb610287e | ||
|
|
74c946fe79 | ||
|
|
23dd98bee7 | ||
|
|
aca35a1309 | ||
|
|
c65f6fa77d | ||
|
|
4d015a927e | ||
|
|
abebea532b | ||
|
|
764fe8ec69 | ||
|
|
da9b4644df | ||
|
|
095821264e | ||
|
|
5069bf80b0 | ||
|
|
1086914a3b | ||
|
|
3e2daaea0e | ||
|
|
1b8155fea9 | ||
|
|
dac406eb93 | ||
|
|
f69bce998a | ||
|
|
ae9f58ba44 | ||
|
|
a930da0901 | ||
|
|
5314cb7828 | ||
|
|
02c5555a48 | ||
|
|
0bdc7bf6a1 | ||
|
|
a8c04cb563 |
@@ -20,6 +20,16 @@ You are the Playwright Test Generator for the SigNoz frontend. You take a plan w
|
||||
await expect(page.getByRole('tab', { name: /alert rules/i })).toBeVisible();
|
||||
});
|
||||
```
|
||||
- **Extended fixtures:** For features needing complex setup (seeded data, API calls, cleanup), import from domain-specific fixtures that extend `auth`. See [docs/contributing/tests/e2e.md](../../docs/contributing/tests/e2e.md) for the full pattern.
|
||||
- `fixtures/alerts/alert-rules` — worker-scoped rule list + test-scoped rule factory
|
||||
- `fixtures/alerts/alert-history` — extends alert-rules, adds history fixtures (waits on ruler evaluation)
|
||||
```ts
|
||||
// Alert list tests - need rules, no history
|
||||
import { test, expect } from '../../../fixtures/alerts/alert-rules';
|
||||
|
||||
// Alert history tests - need evaluated history rows
|
||||
import { test, expect } from '../../../fixtures/alerts/alert-history';
|
||||
```
|
||||
- **Test titles:** `TC-NN <short description>` — matches the planner's IDs.
|
||||
- **Self-contained state.** The bootstrap creates a fresh stack with **zero** dashboards / alerts / etc. — never assume pre-existing data. Two cleanup shapes are valid; pick based on the spec size:
|
||||
- **Per-test `try / finally`** — small specs (~ <10 scenarios) where each test owns its data.
|
||||
|
||||
@@ -49,6 +49,7 @@ Don't try to start the stack yourself — it can take ~4 minutes on a cold build
|
||||
- **The list pages render zero-state when the workspace is empty.** Many locators (search input, sort button, `new-dashboard-cta` testid, "All Dashboards" header) are absent in zero-state. A 30s timeout on those usually means the workspace was empty — seed first via `createDashboardViaApi`.
|
||||
- **The "Enter dashboard name…" inline field is a `RequestDashboardBtn` (template-request feedback form), not a create flow.** Tests that try to use it to create a named dashboard will silently no-op. The only UI create paths are the "New dashboard" dropdown → "Create dashboard" (default name "Sample Title", see `DEFAULT_DASHBOARD_TITLE`) or "Import JSON".
|
||||
- **Auth.** `tests/e2e/fixtures/auth.ts` logs in once per worker and caches `storageState` (cookies + localStorage with `AUTH_TOKEN`). For API-driven seeding/cleanup, use `authToken(page)` from `helpers/dashboards.ts` and pass `Authorization: Bearer <token>`. Never re-implement login.
|
||||
- **Extended fixtures.** Domain-specific fixtures extend `auth` and add seeded data. Alerts uses `fixtures/alerts/alert-rules` (worker-scoped rule list, test-scoped factory) and `fixtures/alerts/alert-history` (extends alert-rules, waits on ruler evaluation). See [docs/contributing/tests/e2e.md](../../docs/contributing/tests/e2e.md) for the pattern. When a test fails on missing data, check if it imports the wrong fixture level.
|
||||
- **Ant Design popovers** (sort menu, action menu) are click-toggle. The trigger element is often an inline `<svg>` with a `data-testid` — clicking it opens the popover; clicking it again closes. After selecting an option, the popover auto-closes. If a test interacts with the popover twice, wait for the menu items to be visible explicitly between toggles.
|
||||
- **Artifacts.** Every failed test writes to `tests/e2e/artifacts/results/<test-slug>/` — the `error-context.md` accessibility snapshot is the fastest way to see what the page actually looked like when it failed.
|
||||
- **Type-check.** After edits, run `npx tsc --noEmit -p tests/e2e/tsconfig.json` if it succeeds, or rely on `npx playwright test --list` to validate the spec parses.
|
||||
|
||||
2
.github/workflows/integrationci.yaml
vendored
2
.github/workflows/integrationci.yaml
vendored
@@ -58,10 +58,12 @@ jobs:
|
||||
- querierai
|
||||
- rawexportdata
|
||||
- promqlconformance
|
||||
- promapiconformance
|
||||
- querierauthz
|
||||
- role
|
||||
- rootuser
|
||||
- savedview
|
||||
- semconvfamilies
|
||||
- serviceaccount
|
||||
- spanmapper
|
||||
- querier_json_body
|
||||
|
||||
@@ -46,6 +46,7 @@ import (
|
||||
"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/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/version"
|
||||
"github.com/SigNoz/signoz/pkg/zeus"
|
||||
@@ -103,8 +104,8 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
|
||||
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, authtypes.NewRegistry()), nil
|
||||
},
|
||||
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, _ querier.Querier, _ licensing.Licensing, tagModule tag.Module) dashboard.Module {
|
||||
return impldashboard.NewModule(impldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, tagModule)
|
||||
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, _ querier.Querier, _ licensing.Licensing, tagModule tag.Module, systemDashboardRegistry dashboardtypes.SystemDashboardRegistry) dashboard.Module {
|
||||
return impldashboard.NewModule(impldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, tagModule, systemDashboardRegistry)
|
||||
},
|
||||
func(_ licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
|
||||
return noopgateway.NewProviderFactory()
|
||||
|
||||
@@ -63,6 +63,7 @@ import (
|
||||
"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/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/version"
|
||||
"github.com/SigNoz/signoz/pkg/zeus"
|
||||
@@ -136,8 +137,8 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
}
|
||||
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, licensing, onBeforeRoleDelete, authtypes.NewRegistry()), nil
|
||||
},
|
||||
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module) dashboard.Module {
|
||||
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, querier, licensing, tagModule)
|
||||
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module, systemDashboardRegistry dashboardtypes.SystemDashboardRegistry) dashboard.Module {
|
||||
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, querier, licensing, tagModule, systemDashboardRegistry)
|
||||
},
|
||||
func(licensing licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
|
||||
return httpgateway.NewProviderFactory(licensing)
|
||||
|
||||
@@ -109,6 +109,57 @@ components:
|
||||
webhook_url:
|
||||
$ref: '#/components/schemas/ConfigSecretURL'
|
||||
type: object
|
||||
AlertmanagertypesJSMOpsReceiverConfig:
|
||||
properties:
|
||||
api_key:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
http_config:
|
||||
$ref: '#/components/schemas/ConfigHTTPClientConfig'
|
||||
message:
|
||||
type: string
|
||||
priority:
|
||||
type: string
|
||||
send_resolved:
|
||||
type: boolean
|
||||
tags:
|
||||
type: string
|
||||
type: object
|
||||
AlertmanagertypesJiraReceiverConfig:
|
||||
properties:
|
||||
custom_fields:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
description:
|
||||
type: string
|
||||
http_config:
|
||||
$ref: '#/components/schemas/ConfigHTTPClientConfig'
|
||||
issue_type:
|
||||
type: string
|
||||
labels:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
priority:
|
||||
type: string
|
||||
project:
|
||||
type: string
|
||||
reopen_duration:
|
||||
$ref: '#/components/schemas/ModelDuration'
|
||||
reopen_transition:
|
||||
type: string
|
||||
resolve_transition:
|
||||
type: string
|
||||
send_resolved:
|
||||
type: boolean
|
||||
site:
|
||||
type: string
|
||||
summary:
|
||||
type: string
|
||||
wont_fix_resolution:
|
||||
type: string
|
||||
type: object
|
||||
AlertmanagertypesMaintenanceKind:
|
||||
enum:
|
||||
- fixed
|
||||
@@ -162,6 +213,10 @@ components:
|
||||
oneOf:
|
||||
- required:
|
||||
- googlechat_configs
|
||||
- required:
|
||||
- jira_configs
|
||||
- required:
|
||||
- jsmops_configs
|
||||
- required:
|
||||
- discord_configs
|
||||
- required:
|
||||
@@ -192,8 +247,6 @@ components:
|
||||
- msteams_configs
|
||||
- required:
|
||||
- msteamsv2_configs
|
||||
- required:
|
||||
- jira_configs
|
||||
- required:
|
||||
- rocketchat_configs
|
||||
- required:
|
||||
@@ -217,7 +270,11 @@ components:
|
||||
type: array
|
||||
jira_configs:
|
||||
items:
|
||||
$ref: '#/components/schemas/ConfigJiraConfig'
|
||||
$ref: '#/components/schemas/AlertmanagertypesJiraReceiverConfig'
|
||||
type: array
|
||||
jsmops_configs:
|
||||
items:
|
||||
$ref: '#/components/schemas/AlertmanagertypesJSMOpsReceiverConfig'
|
||||
type: array
|
||||
mattermost_configs:
|
||||
items:
|
||||
@@ -344,7 +401,11 @@ components:
|
||||
type: array
|
||||
jira_configs:
|
||||
items:
|
||||
$ref: '#/components/schemas/ConfigJiraConfig'
|
||||
$ref: '#/components/schemas/AlertmanagertypesJiraReceiverConfig'
|
||||
type: array
|
||||
jsmops_configs:
|
||||
items:
|
||||
$ref: '#/components/schemas/AlertmanagertypesJSMOpsReceiverConfig'
|
||||
type: array
|
||||
mattermost_configs:
|
||||
items:
|
||||
@@ -2559,6 +2620,7 @@ components:
|
||||
- factor-api-key
|
||||
- license
|
||||
- subscription
|
||||
- deployment-host
|
||||
- logs
|
||||
- traces
|
||||
- metrics
|
||||
@@ -2943,6 +3005,46 @@ components:
|
||||
publicDashboard:
|
||||
$ref: '#/components/schemas/DashboardtypesGettablePublicDasbhboard'
|
||||
type: object
|
||||
DashboardtypesGettableSystemDashboard:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
createdBy:
|
||||
type: string
|
||||
image:
|
||||
type: string
|
||||
locked:
|
||||
type: boolean
|
||||
name:
|
||||
type: string
|
||||
orgId:
|
||||
type: string
|
||||
schemaVersion:
|
||||
type: string
|
||||
source:
|
||||
$ref: '#/components/schemas/DashboardtypesSource'
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesDashboardSpec'
|
||||
tags:
|
||||
items:
|
||||
$ref: '#/components/schemas/TagtypesGettableTag'
|
||||
nullable: true
|
||||
type: array
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
updatedBy:
|
||||
type: string
|
||||
required:
|
||||
- orgId
|
||||
- locked
|
||||
- source
|
||||
- schemaVersion
|
||||
- name
|
||||
- tags
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesHistogramBuckets:
|
||||
properties:
|
||||
bucketCount:
|
||||
@@ -6459,6 +6561,148 @@ components:
|
||||
type: object
|
||||
PreferencetypesValue:
|
||||
type: object
|
||||
PrometheusErrorResponseSchema:
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
errorType:
|
||||
enum:
|
||||
- bad_data
|
||||
- execution
|
||||
- canceled
|
||||
- timeout
|
||||
- internal
|
||||
type: string
|
||||
status:
|
||||
enum:
|
||||
- error
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- errorType
|
||||
- error
|
||||
type: object
|
||||
PrometheusMatrixDataSchema:
|
||||
properties:
|
||||
result:
|
||||
items:
|
||||
$ref: '#/components/schemas/PrometheusMatrixSeriesSchema'
|
||||
nullable: true
|
||||
type: array
|
||||
resultType:
|
||||
enum:
|
||||
- matrix
|
||||
type: string
|
||||
required:
|
||||
- resultType
|
||||
- result
|
||||
type: object
|
||||
PrometheusMatrixSeriesSchema:
|
||||
properties:
|
||||
metric:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
values:
|
||||
items:
|
||||
$ref: '#/components/schemas/PrometheusSamplePairSchema'
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- metric
|
||||
- values
|
||||
type: object
|
||||
PrometheusQueryDataSchema:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/PrometheusMatrixDataSchema'
|
||||
- $ref: '#/components/schemas/PrometheusVectorDataSchema'
|
||||
- $ref: '#/components/schemas/PrometheusScalarDataSchema'
|
||||
- $ref: '#/components/schemas/PrometheusStringDataSchema'
|
||||
type: object
|
||||
PrometheusSamplePairSchema:
|
||||
description: 'A [timestamp, value] pair: float unix seconds, then the string-encoded
|
||||
sample value ("NaN", "+Inf", "-Inf" included).'
|
||||
items:
|
||||
oneOf:
|
||||
- type: number
|
||||
- type: string
|
||||
maxItems: 2
|
||||
minItems: 2
|
||||
nullable: true
|
||||
type: array
|
||||
PrometheusScalarDataSchema:
|
||||
properties:
|
||||
result:
|
||||
$ref: '#/components/schemas/PrometheusSamplePairSchema'
|
||||
resultType:
|
||||
enum:
|
||||
- scalar
|
||||
type: string
|
||||
required:
|
||||
- resultType
|
||||
- result
|
||||
type: object
|
||||
PrometheusStringDataSchema:
|
||||
properties:
|
||||
result:
|
||||
$ref: '#/components/schemas/PrometheusSamplePairSchema'
|
||||
resultType:
|
||||
enum:
|
||||
- string
|
||||
type: string
|
||||
required:
|
||||
- resultType
|
||||
- result
|
||||
type: object
|
||||
PrometheusSuccessResponseSchema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/PrometheusQueryDataSchema'
|
||||
infos:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
status:
|
||||
enum:
|
||||
- success
|
||||
type: string
|
||||
warnings:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
PrometheusVectorDataSchema:
|
||||
properties:
|
||||
result:
|
||||
items:
|
||||
$ref: '#/components/schemas/PrometheusVectorSampleSchema'
|
||||
nullable: true
|
||||
type: array
|
||||
resultType:
|
||||
enum:
|
||||
- vector
|
||||
type: string
|
||||
required:
|
||||
- resultType
|
||||
- result
|
||||
type: object
|
||||
PrometheusVectorSampleSchema:
|
||||
properties:
|
||||
metric:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
value:
|
||||
$ref: '#/components/schemas/PrometheusSamplePairSchema'
|
||||
required:
|
||||
- metric
|
||||
- value
|
||||
type: object
|
||||
PromotetypesPromotePath:
|
||||
properties:
|
||||
indexes:
|
||||
@@ -8010,6 +8254,7 @@ components:
|
||||
- logs
|
||||
- metrics
|
||||
- meter
|
||||
- ai_observability
|
||||
type: string
|
||||
SavedviewtypesUpdatableSavedView:
|
||||
properties:
|
||||
@@ -8800,6 +9045,7 @@ components:
|
||||
- span
|
||||
- trace
|
||||
- resource
|
||||
- scope
|
||||
- attribute
|
||||
- body
|
||||
- ""
|
||||
@@ -15356,6 +15602,73 @@ paths:
|
||||
summary: Migrate dashboard to v2
|
||||
tags:
|
||||
- dashboard
|
||||
/api/v2/dashboards/system/{name}:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns a dashboard SigNoz ships and owns, addressed by its stable
|
||||
definition name (e.g. `ai-o11y-overview`) rather than its id. System dashboards
|
||||
are read-only and upgraded through releases. The dashboard's own `name` field
|
||||
carries a reserved prefix that the path segment must not include.
|
||||
operationId: GetSystemDashboard
|
||||
parameters:
|
||||
- in: path
|
||||
name: name
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/DashboardtypesGettableSystemDashboard'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:read
|
||||
- tokenizer:
|
||||
- dashboard:read
|
||||
summary: Get system dashboard
|
||||
tags:
|
||||
- dashboard
|
||||
/api/v2/factor_password/forgot:
|
||||
post:
|
||||
deprecated: false
|
||||
@@ -15467,10 +15780,8 @@ paths:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- api_key: []
|
||||
- tokenizer: []
|
||||
summary: Get features
|
||||
tags:
|
||||
- features
|
||||
@@ -23942,9 +24253,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- deployment-host:list
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- deployment-host:list
|
||||
summary: Get host info from Zeus.
|
||||
tags:
|
||||
- zeus
|
||||
@@ -23998,9 +24309,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- deployment-host:update
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- deployment-host:update
|
||||
summary: Put host in Zeus for a deployment.
|
||||
tags:
|
||||
- zeus
|
||||
@@ -24810,6 +25121,374 @@ paths:
|
||||
summary: Replace variables
|
||||
tags:
|
||||
- querier
|
||||
/prometheus/api/v1/query:
|
||||
get:
|
||||
description: 'Prometheus-compatible endpoint: the request and response contract
|
||||
is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/).
|
||||
Parameters are accepted as URL query parameters or a form-encoded body, on
|
||||
GET and POST alike.'
|
||||
operationId: PrometheusQuery
|
||||
parameters:
|
||||
- description: PromQL expression.
|
||||
in: query
|
||||
name: query
|
||||
required: true
|
||||
schema:
|
||||
description: PromQL expression.
|
||||
type: string
|
||||
- description: 'Evaluation timestamp: RFC3339 or float unix seconds. Defaults
|
||||
to the server''s current time.'
|
||||
in: query
|
||||
name: time
|
||||
schema:
|
||||
description: 'Evaluation timestamp: RFC3339 or float unix seconds. Defaults
|
||||
to the server''s current time.'
|
||||
type: string
|
||||
- description: 'Evaluation timeout: duration string or float seconds.'
|
||||
in: query
|
||||
name: timeout
|
||||
schema:
|
||||
description: 'Evaluation timeout: duration string or float seconds.'
|
||||
type: string
|
||||
- description: Any non-empty value includes query statistics in the response.
|
||||
in: query
|
||||
name: stats
|
||||
schema:
|
||||
description: Any non-empty value includes query statistics in the response.
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusSuccessResponseSchema'
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"422":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Unprocessable Entity
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Internal Server Error
|
||||
"503":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Service Unavailable
|
||||
security:
|
||||
- api_key:
|
||||
- metrics:read
|
||||
- tokenizer:
|
||||
- metrics:read
|
||||
summary: Prometheus instant query
|
||||
tags:
|
||||
- prometheus
|
||||
post:
|
||||
description: 'Prometheus-compatible endpoint: the request and response contract
|
||||
is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/).
|
||||
Parameters are accepted as URL query parameters or a form-encoded body, on
|
||||
GET and POST alike.'
|
||||
operationId: PrometheusQueryPost
|
||||
parameters:
|
||||
- description: PromQL expression.
|
||||
in: query
|
||||
name: query
|
||||
required: true
|
||||
schema:
|
||||
description: PromQL expression.
|
||||
type: string
|
||||
- description: 'Evaluation timestamp: RFC3339 or float unix seconds. Defaults
|
||||
to the server''s current time.'
|
||||
in: query
|
||||
name: time
|
||||
schema:
|
||||
description: 'Evaluation timestamp: RFC3339 or float unix seconds. Defaults
|
||||
to the server''s current time.'
|
||||
type: string
|
||||
- description: 'Evaluation timeout: duration string or float seconds.'
|
||||
in: query
|
||||
name: timeout
|
||||
schema:
|
||||
description: 'Evaluation timeout: duration string or float seconds.'
|
||||
type: string
|
||||
- description: Any non-empty value includes query statistics in the response.
|
||||
in: query
|
||||
name: stats
|
||||
schema:
|
||||
description: Any non-empty value includes query statistics in the response.
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusSuccessResponseSchema'
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"422":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Unprocessable Entity
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Internal Server Error
|
||||
"503":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Service Unavailable
|
||||
security:
|
||||
- api_key:
|
||||
- metrics:read
|
||||
- tokenizer:
|
||||
- metrics:read
|
||||
summary: Prometheus instant query
|
||||
tags:
|
||||
- prometheus
|
||||
/prometheus/api/v1/query_range:
|
||||
get:
|
||||
description: 'Prometheus-compatible endpoint: the request and response contract
|
||||
is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/).
|
||||
Parameters are accepted as URL query parameters or a form-encoded body, on
|
||||
GET and POST alike.'
|
||||
operationId: PrometheusQueryRange
|
||||
parameters:
|
||||
- description: PromQL expression.
|
||||
in: query
|
||||
name: query
|
||||
required: true
|
||||
schema:
|
||||
description: PromQL expression.
|
||||
type: string
|
||||
- description: 'Range start: RFC3339 or float unix seconds.'
|
||||
in: query
|
||||
name: start
|
||||
required: true
|
||||
schema:
|
||||
description: 'Range start: RFC3339 or float unix seconds.'
|
||||
type: string
|
||||
- description: 'Range end: RFC3339 or float unix seconds.'
|
||||
in: query
|
||||
name: end
|
||||
required: true
|
||||
schema:
|
||||
description: 'Range end: RFC3339 or float unix seconds.'
|
||||
type: string
|
||||
- description: 'Resolution step: duration string or float seconds.'
|
||||
in: query
|
||||
name: step
|
||||
required: true
|
||||
schema:
|
||||
description: 'Resolution step: duration string or float seconds.'
|
||||
type: string
|
||||
- description: 'Evaluation timeout: duration string or float seconds.'
|
||||
in: query
|
||||
name: timeout
|
||||
schema:
|
||||
description: 'Evaluation timeout: duration string or float seconds.'
|
||||
type: string
|
||||
- description: Any non-empty value includes query statistics in the response.
|
||||
in: query
|
||||
name: stats
|
||||
schema:
|
||||
description: Any non-empty value includes query statistics in the response.
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusSuccessResponseSchema'
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"422":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Unprocessable Entity
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Internal Server Error
|
||||
"503":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Service Unavailable
|
||||
security:
|
||||
- api_key:
|
||||
- metrics:read
|
||||
- tokenizer:
|
||||
- metrics:read
|
||||
summary: Prometheus range query
|
||||
tags:
|
||||
- prometheus
|
||||
post:
|
||||
description: 'Prometheus-compatible endpoint: the request and response contract
|
||||
is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/).
|
||||
Parameters are accepted as URL query parameters or a form-encoded body, on
|
||||
GET and POST alike.'
|
||||
operationId: PrometheusQueryRangePost
|
||||
parameters:
|
||||
- description: PromQL expression.
|
||||
in: query
|
||||
name: query
|
||||
required: true
|
||||
schema:
|
||||
description: PromQL expression.
|
||||
type: string
|
||||
- description: 'Range start: RFC3339 or float unix seconds.'
|
||||
in: query
|
||||
name: start
|
||||
required: true
|
||||
schema:
|
||||
description: 'Range start: RFC3339 or float unix seconds.'
|
||||
type: string
|
||||
- description: 'Range end: RFC3339 or float unix seconds.'
|
||||
in: query
|
||||
name: end
|
||||
required: true
|
||||
schema:
|
||||
description: 'Range end: RFC3339 or float unix seconds.'
|
||||
type: string
|
||||
- description: 'Resolution step: duration string or float seconds.'
|
||||
in: query
|
||||
name: step
|
||||
required: true
|
||||
schema:
|
||||
description: 'Resolution step: duration string or float seconds.'
|
||||
type: string
|
||||
- description: 'Evaluation timeout: duration string or float seconds.'
|
||||
in: query
|
||||
name: timeout
|
||||
schema:
|
||||
description: 'Evaluation timeout: duration string or float seconds.'
|
||||
type: string
|
||||
- description: Any non-empty value includes query statistics in the response.
|
||||
in: query
|
||||
name: stats
|
||||
schema:
|
||||
description: Any non-empty value includes query statistics in the response.
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusSuccessResponseSchema'
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"422":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Unprocessable Entity
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Internal Server Error
|
||||
"503":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Service Unavailable
|
||||
security:
|
||||
- api_key:
|
||||
- metrics:read
|
||||
- tokenizer:
|
||||
- metrics:read
|
||||
summary: Prometheus range query
|
||||
tags:
|
||||
- prometheus
|
||||
servers:
|
||||
- description: The fully qualified URL to the SigNoz APIServer.
|
||||
url: https://{host}:{port}{base_path}
|
||||
|
||||
@@ -299,8 +299,11 @@ substituted. One subtlety makes it exact: we write stale markers at absent
|
||||
grid points. Without them, the engine's lookback would resurrect a point
|
||||
from up to `lookback` earlier. The marker encodes "absent here" the way the
|
||||
engine itself encodes it. Units evaluate concurrently. Each unit is one
|
||||
series lookup plus one grid statement. A step of 0 is an instant query: a
|
||||
single evaluation at `end`.
|
||||
grid statement: the group-key join resolves the matchers, and the samples
|
||||
primary key takes the metric name straight from the selector. Only a
|
||||
selector without a static `__name__` runs the series lookup first, to learn
|
||||
the concrete metric names. A step of 0 is an instant query: a single
|
||||
evaluation at `end`.
|
||||
|
||||
A note on the window sliver: when the window is narrower than the step, the
|
||||
grid windows cover only `window/step` of the timeline. A sample in a gap
|
||||
@@ -315,8 +318,9 @@ selectors and `last_over_time` transpile at window < step too.
|
||||
|
||||
## Series lookup
|
||||
|
||||
Both paths resolve matchers the same way, once per selector
|
||||
(`selectSeries`). The series tables hold one row per (fingerprint, bucket)
|
||||
The engine path resolves matchers once per selector (`selectSeries`); the
|
||||
transpiled path builds the same conditions into its group-key join. Both
|
||||
read the same tables. The series tables hold one row per (fingerprint, bucket)
|
||||
at 1h/6h/1d/1w granularities. The shared schema package
|
||||
(`pkg/telemetryschema/metricstelemetryschema`) picks the table whose bucket
|
||||
fits the window. It rounds the window start down to the bucket boundary, so
|
||||
|
||||
@@ -112,6 +112,41 @@ These two folders look similar but mean different things:
|
||||
|
||||
Rule of thumb: if it's a `test.extend` fixture, put it in `fixtures/`. If it's a function you call explicitly (or a constant the function uses), put it in `helpers/`. If it's a static file the helpers read, put it in `testdata/`.
|
||||
|
||||
### Extended fixtures
|
||||
|
||||
For features needing complex setup (API-seeded data, ruler evaluation waits, cleanup), create domain-specific fixtures that extend `auth`. Group them in `fixtures/<domain>/`.
|
||||
|
||||
**Fixture scopes:**
|
||||
- **test scope** — fresh data per test. Use for mutations (edit, delete, rename).
|
||||
- **worker scope** — shared across tests in one worker. Use for read-only data. Worker scope pays the setup cost once per worker instead of once per test.
|
||||
|
||||
**The alerts pattern** (`fixtures/alerts/`) demonstrates extending fixtures:
|
||||
|
||||
```
|
||||
fixtures/alerts/
|
||||
├── alert-rules.ts # extends auth — worker-scoped rule list + test-scoped factory
|
||||
└── alert-history.ts # extends alert-rules — adds history fixtures (waits on ruler)
|
||||
```
|
||||
|
||||
Specs import from the fixture they need:
|
||||
|
||||
```ts
|
||||
// List tests — just need rules, no history
|
||||
import { test, expect } from '../../../fixtures/alerts/alert-rules';
|
||||
|
||||
// History tests — need history rows from ruler evaluation
|
||||
import { test, expect } from '../../../fixtures/alerts/alert-history';
|
||||
```
|
||||
|
||||
**When creating new fixtures:**
|
||||
|
||||
1. **Identify scope** — Will tests mutate the data? If yes, test-scoped. If read-only, worker-scoped.
|
||||
2. **Group by domain** — Put fixtures in `fixtures/<domain>/`. Helpers in `helpers/<domain>/`.
|
||||
3. **Extend existing fixtures** — Chain from `auth` or another fixture to inherit its setup.
|
||||
4. **Handle timeouts** — Worker-scoped fixtures that wait on backend processing need explicit timeouts.
|
||||
5. **Clean up** — Always delete seeded data in the fixture teardown (after `use()`).
|
||||
6. **Extract logic into functions** — Keep the `test.extend()` block lean; move setup/teardown logic to named functions so the extend block reads as a manifest of "what fixtures exist."
|
||||
|
||||
Each spec follows these principles:
|
||||
|
||||
1. **Directory per feature**: `tests/e2e/tests/<feature>/*.spec.ts`. Cross-resource junction concerns (e.g. cascade-delete) go in their own file, not packed into one giant spec.
|
||||
@@ -232,11 +267,14 @@ cd tests/e2e
|
||||
# Single feature dir
|
||||
npx playwright test tests/alerts/ --project=chromium
|
||||
|
||||
# Single sub-area
|
||||
npx playwright test tests/alerts/history/ --project=chromium
|
||||
|
||||
# Single file
|
||||
npx playwright test tests/alerts/alerts.spec.ts --project=chromium
|
||||
npx playwright test tests/alerts/page.spec.ts --project=chromium
|
||||
|
||||
# Single test by title grep
|
||||
npx playwright test --project=chromium -g "TC-01"
|
||||
npx playwright test --project=chromium -g "AL-01"
|
||||
```
|
||||
|
||||
### Iterative modes
|
||||
@@ -270,7 +308,14 @@ yarn test:staging
|
||||
| `SIGNOZ_E2E_PASSWORD` | Admin password. Bootstrap writes the integration-test default. |
|
||||
| `SIGNOZ_E2E_SEEDER_URL` | Seeder HTTP base URL — hit by specs that need per-test telemetry. |
|
||||
|
||||
Loading order in `playwright.config.ts`: `.env` first (user-provided, staging), then `.env.local` with `override: true` (bootstrap-generated, local mode). Anything already set in `process.env` at yarn-test time wins because dotenv doesn't touch vars that are already present.
|
||||
Precedence in `playwright.config.ts`, lowest to highest: `.env` (user-provided, staging) → `.env.local` (bootstrap-generated, local mode) → whatever is already in `process.env`. The config parses both files itself and only fills in keys the environment does not already define, so exporting a variable always wins:
|
||||
|
||||
```bash
|
||||
# runs against a locally served frontend, not whatever .env.local points at
|
||||
SIGNOZ_E2E_BASE_URL=http://127.0.0.1:3301 pnpm test tests/alerts
|
||||
```
|
||||
|
||||
This is deliberately not `dotenv.config({ override: true })`. That flag makes the *file* beat `process.env`, which silently discarded exported values — including the `SIGNOZ_E2E_BASE_URL` in `pnpm test:staging`, whenever a `.env.local` happened to exist.
|
||||
|
||||
### Playwright options
|
||||
|
||||
|
||||
@@ -32,9 +32,9 @@ type module struct {
|
||||
tagModule tag.Module
|
||||
}
|
||||
|
||||
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module) dashboard.Module {
|
||||
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module, systemDashboardRegistry dashboardtypes.SystemDashboardRegistry) dashboard.Module {
|
||||
scopedProviderSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard")
|
||||
pkgDashboardModule := pkgimpldashboard.NewModule(store, settings, analytics, orgGetter, queryParser, tagModule)
|
||||
pkgDashboardModule := pkgimpldashboard.NewModule(store, settings, analytics, orgGetter, queryParser, tagModule, systemDashboardRegistry)
|
||||
|
||||
return &module{
|
||||
pkgDashboardModule: pkgDashboardModule,
|
||||
@@ -361,6 +361,14 @@ func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valu
|
||||
return module.pkgDashboardModule.LockUnlock(ctx, orgID, id, updatedBy, isAdmin, lock)
|
||||
}
|
||||
|
||||
func (module *module) ReconcileSystemDashboards(ctx context.Context, orgID valuer.UUID) error {
|
||||
return module.pkgDashboardModule.ReconcileSystemDashboards(ctx, orgID)
|
||||
}
|
||||
|
||||
func (module *module) GetSystemDashboard(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error) {
|
||||
return module.pkgDashboardModule.GetSystemDashboard(ctx, orgID, name)
|
||||
}
|
||||
|
||||
func (module *module) delete(ctx context.Context, orgID, id valuer.UUID) error {
|
||||
return module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
if err := module.store.DeletePublic(ctx, id.String()); err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
|
||||
@@ -67,7 +67,7 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||
// note: add ee override methods first
|
||||
|
||||
// routes available only in ee version
|
||||
router.HandleFunc("/api/v1/features", am.ViewAccess(ah.getFeatureFlags)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/features", am.OpenAccess(ah.getFeatureFlags)).Methods(http.MethodGet)
|
||||
|
||||
// base overrides
|
||||
router.HandleFunc("/api/v1/version", am.OpenAccess(ah.getVersion)).Methods(http.MethodGet)
|
||||
|
||||
@@ -26,6 +26,55 @@
|
||||
"tooltip_ms_teams_url": "The URL of the Microsoft Teams [webhook](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498) to send alerts to. Learn more about Microsoft Teams integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/ms-teams/).",
|
||||
"tooltip_google_chat_url": "The URL of the Google Chat space [incoming webhook](https://developers.google.com/workspace/chat/quickstart/webhooks) to send alerts to. It must be an https URL on chat.googleapis.com.",
|
||||
"google_chat_webhook_url_invalid": "Webhook URL must be an https URL on chat.googleapis.com",
|
||||
"field_jira_site": "Site URL",
|
||||
"tooltip_jira_site": "Your Jira Cloud base URL, e.g. https://your-domain.atlassian.net. Only Jira Cloud is supported.",
|
||||
"jira_site_invalid": "Site URL must be an https URL on an atlassian.net domain",
|
||||
"jira_required_fields": "Site URL, email, API token, project and issue type are required",
|
||||
"jira_service_account_tip": "Recommended: use a Jira service account so alerts aren't reported under a personal name and the channel keeps working when someone leaves.",
|
||||
"jira_service_account_tip_link": "Learn how",
|
||||
"field_jira_email": "Email",
|
||||
"help_jira_email": "The Atlassian account email used for authentication.",
|
||||
"field_jira_api_token": "API token",
|
||||
"help_jira_api_token": "Create one at id.atlassian.com under Security → API tokens.",
|
||||
"field_jira_project": "Project key",
|
||||
"field_jira_issue_type": "Issue type",
|
||||
"help_jira_issue_type": "An issue type that exists in the project, e.g. Task, Bug or Incident.",
|
||||
"field_jira_summary": "Summary (issue title)",
|
||||
"help_jira_summary": "Template for the Jira issue title.",
|
||||
"field_jira_description": "Description",
|
||||
"help_jira_description": "Template for the issue description. Rendered as rich text with a status panel and links back to SigNoz.",
|
||||
"jira_advanced_section": "Advanced Options",
|
||||
"field_jira_priority": "Priority",
|
||||
"placeholder_jira_priority": "Leave empty to use the project default",
|
||||
"help_jira_priority": "Must match a priority in the project's scheme, e.g. High.",
|
||||
"field_jira_labels": "Labels",
|
||||
"placeholder_jira_labels": "Type a label and press Enter",
|
||||
"help_jira_labels": "signoz and a deduplication label are added automatically.",
|
||||
"field_jira_resolve_transition": "Resolve transition",
|
||||
"field_jira_reopen_transition": "Reopen transition",
|
||||
"help_jira_resolve_transition": "When the alert resolves, SigNoz moves the Jira issue to a \"Done\" status via a workflow transition. This is auto-detected — leave it empty unless your project has more than one \"Done\" transition (e.g. Done vs. Won't Do) and you want to force a specific one by name.",
|
||||
"help_jira_reopen_transition": "When a resolved alert fires again (within the reopen window), SigNoz moves the issue back out of \"Done\" to an active status via a workflow transition. This is auto-detected — leave it empty unless you want to force a specific one by name (e.g. To Do or Reopen).",
|
||||
"placeholder_jira_resolve_transition": "Auto-detected, e.g. Done",
|
||||
"placeholder_jira_reopen_transition": "Auto-detected, e.g. To Do",
|
||||
"field_jira_reopen_duration": "Reopen window",
|
||||
"placeholder_jira_reopen_duration": "e.g. 72h",
|
||||
"help_jira_reopen_duration": "If a resolved alert fires again within this window, the same ticket is reopened; after the window, a re-fire opens a new ticket instead. Default: 3d.",
|
||||
"tooltip_jira_reopen_duration": "Accepted units: m (minutes), h (hours), d (days), w (weeks), y (years) — e.g. 30m, 72h or 3d. Minimum 1m.",
|
||||
"jira_reopen_duration_invalid": "Reopen window must be a duration like 30m, 72h or 3d (minimum 1m)",
|
||||
"jsmops_tip": "Create an API integration on your JSM team's Operations page and paste its key below.",
|
||||
"jsmops_tip_link": "Learn how",
|
||||
"field_jsmops_api_key": "API key",
|
||||
"help_jsmops_api_key": "The JSM Ops integration API key, from your team's Operations → Integrations → API. Make sure the integration is turned on.",
|
||||
"field_jsmops_message": "Message (alert title)",
|
||||
"help_jsmops_message": "Template for the alert title. Truncated to 130 characters.",
|
||||
"field_jsmops_description": "Description",
|
||||
"help_jsmops_description": "Template for the alert description. Rendered as rich text; kept under 15,000 characters.",
|
||||
"jsmops_advanced_section": "Advanced Options",
|
||||
"field_jsmops_priority": "Priority",
|
||||
"help_jsmops_priority": "Template resolving to one of P1–P5. Leave as-is to map from alert severity.",
|
||||
"field_jsmops_tags": "Tags",
|
||||
"placeholder_jsmops_tags": "Type a tag and press Enter",
|
||||
"help_jsmops_tags": "Tags added to every alert.",
|
||||
|
||||
"field_slack_recipient": "Recipient",
|
||||
"field_slack_title": "Title",
|
||||
|
||||
@@ -26,6 +26,55 @@
|
||||
"tooltip_ms_teams_url": "The URL of the Microsoft Teams [webhook](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498) to send alerts to. Learn more about Microsoft Teams integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/ms-teams/).",
|
||||
"tooltip_google_chat_url": "The URL of the Google Chat space [incoming webhook](https://developers.google.com/workspace/chat/quickstart/webhooks) to send alerts to. It must be an https URL on chat.googleapis.com.",
|
||||
"google_chat_webhook_url_invalid": "Webhook URL must be an https URL on chat.googleapis.com",
|
||||
"field_jira_site": "Site URL",
|
||||
"tooltip_jira_site": "Your Jira Cloud base URL, e.g. https://your-domain.atlassian.net. Only Jira Cloud is supported.",
|
||||
"jira_site_invalid": "Site URL must be an https URL on an atlassian.net domain",
|
||||
"jira_required_fields": "Site URL, email, API token, project and issue type are required",
|
||||
"jira_service_account_tip": "Recommended: use a Jira service account so alerts aren't reported under a personal name and the channel keeps working when someone leaves.",
|
||||
"jira_service_account_tip_link": "Learn how",
|
||||
"field_jira_email": "Email",
|
||||
"help_jira_email": "The Atlassian account email used for authentication.",
|
||||
"field_jira_api_token": "API token",
|
||||
"help_jira_api_token": "Create one at id.atlassian.com under Security → API tokens.",
|
||||
"field_jira_project": "Project key",
|
||||
"field_jira_issue_type": "Issue type",
|
||||
"help_jira_issue_type": "An issue type that exists in the project, e.g. Task, Bug or Incident.",
|
||||
"field_jira_summary": "Summary (issue title)",
|
||||
"help_jira_summary": "Template for the Jira issue title.",
|
||||
"field_jira_description": "Description",
|
||||
"help_jira_description": "Template for the issue description. Rendered as rich text with a status panel and links back to SigNoz.",
|
||||
"jira_advanced_section": "Advanced Options",
|
||||
"field_jira_priority": "Priority",
|
||||
"placeholder_jira_priority": "Leave empty to use the project default",
|
||||
"help_jira_priority": "Must match a priority in the project's scheme, e.g. High.",
|
||||
"field_jira_labels": "Labels",
|
||||
"placeholder_jira_labels": "Type a label and press Enter",
|
||||
"help_jira_labels": "signoz and a deduplication label are added automatically.",
|
||||
"field_jira_resolve_transition": "Resolve transition",
|
||||
"field_jira_reopen_transition": "Reopen transition",
|
||||
"help_jira_resolve_transition": "When the alert resolves, SigNoz moves the Jira issue to a \"Done\" status via a workflow transition. This is auto-detected — leave it empty unless your project has more than one \"Done\" transition (e.g. Done vs. Won't Do) and you want to force a specific one by name.",
|
||||
"help_jira_reopen_transition": "When a resolved alert fires again (within the reopen window), SigNoz moves the issue back out of \"Done\" to an active status via a workflow transition. This is auto-detected — leave it empty unless you want to force a specific one by name (e.g. To Do or Reopen).",
|
||||
"placeholder_jira_resolve_transition": "Auto-detected, e.g. Done",
|
||||
"placeholder_jira_reopen_transition": "Auto-detected, e.g. To Do",
|
||||
"field_jira_reopen_duration": "Reopen window",
|
||||
"placeholder_jira_reopen_duration": "e.g. 72h",
|
||||
"help_jira_reopen_duration": "If a resolved alert fires again within this window, the same ticket is reopened; after the window, a re-fire opens a new ticket instead. Default: 3d.",
|
||||
"tooltip_jira_reopen_duration": "Accepted units: m (minutes), h (hours), d (days), w (weeks), y (years) — e.g. 30m, 72h or 3d. Minimum 1m.",
|
||||
"jira_reopen_duration_invalid": "Reopen window must be a duration like 30m, 72h or 3d (minimum 1m)",
|
||||
"jsmops_tip": "Create an API integration on your JSM team's Operations page and paste its key below.",
|
||||
"jsmops_tip_link": "Learn how",
|
||||
"field_jsmops_api_key": "API key",
|
||||
"help_jsmops_api_key": "The JSM Ops integration API key, from your team's Operations → Integrations → API. Make sure the integration is turned on.",
|
||||
"field_jsmops_message": "Message (alert title)",
|
||||
"help_jsmops_message": "Template for the alert title. Truncated to 130 characters.",
|
||||
"field_jsmops_description": "Description",
|
||||
"help_jsmops_description": "Template for the alert description. Rendered as rich text; kept under 15,000 characters.",
|
||||
"jsmops_advanced_section": "Advanced Options",
|
||||
"field_jsmops_priority": "Priority",
|
||||
"help_jsmops_priority": "Template resolving to one of P1–P5. Leave as-is to map from alert severity.",
|
||||
"field_jsmops_tags": "Tags",
|
||||
"placeholder_jsmops_tags": "Type a tag and press Enter",
|
||||
"help_jsmops_tags": "Tags added to every alert.",
|
||||
"field_slack_recipient": "Recipient",
|
||||
"field_slack_title": "Title",
|
||||
"field_slack_description": "Description",
|
||||
|
||||
@@ -53,7 +53,7 @@ export function ErrorResponseHandler(error: AxiosError): ErrorResponse {
|
||||
};
|
||||
}
|
||||
// anything else
|
||||
console.error('any');
|
||||
console.error('ErrorResponseHandler: unclassified error');
|
||||
return {
|
||||
statusCode: 500,
|
||||
payload: null,
|
||||
|
||||
@@ -46,6 +46,8 @@ import type {
|
||||
GetPublicDashboardPathParameters,
|
||||
GetPublicDashboardWidgetQueryRange200,
|
||||
GetPublicDashboardWidgetQueryRangePathParameters,
|
||||
GetSystemDashboard200,
|
||||
GetSystemDashboardPathParameters,
|
||||
ListDashboardViews200,
|
||||
ListDashboardsForUserV2200,
|
||||
ListDashboardsForUserV2Params,
|
||||
@@ -1885,6 +1887,108 @@ export const useMigrateDashboardV2 = <
|
||||
> => {
|
||||
return useMutation(getMigrateDashboardV2MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns a dashboard SigNoz ships and owns, addressed by its stable definition name (e.g. `ai-o11y-overview`) rather than its id. System dashboards are read-only and upgraded through releases. The dashboard's own `name` field carries a reserved prefix that the path segment must not include.
|
||||
* @summary Get system dashboard
|
||||
*/
|
||||
export const getSystemDashboard = (
|
||||
{ name }: GetSystemDashboardPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetSystemDashboard200>({
|
||||
url: `/api/v2/dashboards/system/${name}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetSystemDashboardQueryKey = ({
|
||||
name,
|
||||
}: GetSystemDashboardPathParameters) => {
|
||||
return [`/api/v2/dashboards/system/${name}`] as const;
|
||||
};
|
||||
|
||||
export const getGetSystemDashboardQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getSystemDashboard>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ name }: GetSystemDashboardPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getSystemDashboard>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetSystemDashboardQueryKey({ name });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getSystemDashboard>>
|
||||
> = ({ signal }) => getSystemDashboard({ name }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!name,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getSystemDashboard>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetSystemDashboardQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getSystemDashboard>>
|
||||
>;
|
||||
export type GetSystemDashboardQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get system dashboard
|
||||
*/
|
||||
|
||||
export function useGetSystemDashboard<
|
||||
TData = Awaited<ReturnType<typeof getSystemDashboard>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ name }: GetSystemDashboardPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getSystemDashboard>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetSystemDashboardQueryOptions({ name }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get system dashboard
|
||||
*/
|
||||
export const invalidateGetSystemDashboard = async (
|
||||
queryClient: QueryClient,
|
||||
{ name }: GetSystemDashboardPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetSystemDashboardQueryKey({ name }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint returns the sanitized v2-shape dashboard data for public access. Each panel query is reduced to a safe field subset, so filters and raw query strings are not exposed.
|
||||
* @summary Get public dashboard data (v2)
|
||||
|
||||
396
frontend/src/api/generated/services/prometheus/index.ts
Normal file
396
frontend/src/api/generated/services/prometheus/index.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
InvalidateOptions,
|
||||
MutationFunction,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
PrometheusErrorResponseSchemaDTO,
|
||||
PrometheusQueryParams,
|
||||
PrometheusQueryPostParams,
|
||||
PrometheusQueryRangeParams,
|
||||
PrometheusQueryRangePostParams,
|
||||
PrometheusSuccessResponseSchemaDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType } from '../../../generatedAPIInstance';
|
||||
|
||||
/**
|
||||
* Prometheus-compatible endpoint: the request and response contract is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/). Parameters are accepted as URL query parameters or a form-encoded body, on GET and POST alike.
|
||||
* @summary Prometheus instant query
|
||||
*/
|
||||
export const prometheusQuery = (
|
||||
params: PrometheusQueryParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<PrometheusSuccessResponseSchemaDTO>({
|
||||
url: `/prometheus/api/v1/query`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getPrometheusQueryQueryKey = (params?: PrometheusQueryParams) => {
|
||||
return [`/prometheus/api/v1/query`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getPrometheusQueryQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof prometheusQuery>>,
|
||||
TError = ErrorType<PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO>,
|
||||
>(
|
||||
params: PrometheusQueryParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof prometheusQuery>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getPrometheusQueryQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof prometheusQuery>>> = ({
|
||||
signal,
|
||||
}) => prometheusQuery(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof prometheusQuery>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type PrometheusQueryQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof prometheusQuery>>
|
||||
>;
|
||||
export type PrometheusQueryQueryError = ErrorType<
|
||||
PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO
|
||||
>;
|
||||
|
||||
/**
|
||||
* @summary Prometheus instant query
|
||||
*/
|
||||
|
||||
export function usePrometheusQuery<
|
||||
TData = Awaited<ReturnType<typeof prometheusQuery>>,
|
||||
TError = ErrorType<PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO>,
|
||||
>(
|
||||
params: PrometheusQueryParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof prometheusQuery>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getPrometheusQueryQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Prometheus instant query
|
||||
*/
|
||||
export const invalidatePrometheusQuery = async (
|
||||
queryClient: QueryClient,
|
||||
params: PrometheusQueryParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getPrometheusQueryQueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Prometheus-compatible endpoint: the request and response contract is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/). Parameters are accepted as URL query parameters or a form-encoded body, on GET and POST alike.
|
||||
* @summary Prometheus instant query
|
||||
*/
|
||||
export const prometheusQueryPost = (
|
||||
params: PrometheusQueryPostParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<PrometheusSuccessResponseSchemaDTO>({
|
||||
url: `/prometheus/api/v1/query`,
|
||||
method: 'POST',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getPrometheusQueryPostMutationOptions = <
|
||||
TError = ErrorType<PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof prometheusQueryPost>>,
|
||||
TError,
|
||||
{ params: PrometheusQueryPostParams },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof prometheusQueryPost>>,
|
||||
TError,
|
||||
{ params: PrometheusQueryPostParams },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['prometheusQueryPost'];
|
||||
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 prometheusQueryPost>>,
|
||||
{ params: PrometheusQueryPostParams }
|
||||
> = (props) => {
|
||||
const { params } = props ?? {};
|
||||
|
||||
return prometheusQueryPost(params);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type PrometheusQueryPostMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof prometheusQueryPost>>
|
||||
>;
|
||||
|
||||
export type PrometheusQueryPostMutationError = ErrorType<
|
||||
PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO
|
||||
>;
|
||||
|
||||
/**
|
||||
* @summary Prometheus instant query
|
||||
*/
|
||||
export const usePrometheusQueryPost = <
|
||||
TError = ErrorType<PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof prometheusQueryPost>>,
|
||||
TError,
|
||||
{ params: PrometheusQueryPostParams },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof prometheusQueryPost>>,
|
||||
TError,
|
||||
{ params: PrometheusQueryPostParams },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getPrometheusQueryPostMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Prometheus-compatible endpoint: the request and response contract is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/). Parameters are accepted as URL query parameters or a form-encoded body, on GET and POST alike.
|
||||
* @summary Prometheus range query
|
||||
*/
|
||||
export const prometheusQueryRange = (
|
||||
params: PrometheusQueryRangeParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<PrometheusSuccessResponseSchemaDTO>({
|
||||
url: `/prometheus/api/v1/query_range`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getPrometheusQueryRangeQueryKey = (
|
||||
params?: PrometheusQueryRangeParams,
|
||||
) => {
|
||||
return [
|
||||
`/prometheus/api/v1/query_range`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
};
|
||||
|
||||
export const getPrometheusQueryRangeQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof prometheusQueryRange>>,
|
||||
TError = ErrorType<PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO>,
|
||||
>(
|
||||
params: PrometheusQueryRangeParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof prometheusQueryRange>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getPrometheusQueryRangeQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof prometheusQueryRange>>
|
||||
> = ({ signal }) => prometheusQueryRange(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof prometheusQueryRange>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type PrometheusQueryRangeQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof prometheusQueryRange>>
|
||||
>;
|
||||
export type PrometheusQueryRangeQueryError = ErrorType<
|
||||
PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO
|
||||
>;
|
||||
|
||||
/**
|
||||
* @summary Prometheus range query
|
||||
*/
|
||||
|
||||
export function usePrometheusQueryRange<
|
||||
TData = Awaited<ReturnType<typeof prometheusQueryRange>>,
|
||||
TError = ErrorType<PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO>,
|
||||
>(
|
||||
params: PrometheusQueryRangeParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof prometheusQueryRange>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getPrometheusQueryRangeQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Prometheus range query
|
||||
*/
|
||||
export const invalidatePrometheusQueryRange = async (
|
||||
queryClient: QueryClient,
|
||||
params: PrometheusQueryRangeParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getPrometheusQueryRangeQueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Prometheus-compatible endpoint: the request and response contract is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/). Parameters are accepted as URL query parameters or a form-encoded body, on GET and POST alike.
|
||||
* @summary Prometheus range query
|
||||
*/
|
||||
export const prometheusQueryRangePost = (
|
||||
params: PrometheusQueryRangePostParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<PrometheusSuccessResponseSchemaDTO>({
|
||||
url: `/prometheus/api/v1/query_range`,
|
||||
method: 'POST',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getPrometheusQueryRangePostMutationOptions = <
|
||||
TError = ErrorType<PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof prometheusQueryRangePost>>,
|
||||
TError,
|
||||
{ params: PrometheusQueryRangePostParams },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof prometheusQueryRangePost>>,
|
||||
TError,
|
||||
{ params: PrometheusQueryRangePostParams },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['prometheusQueryRangePost'];
|
||||
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 prometheusQueryRangePost>>,
|
||||
{ params: PrometheusQueryRangePostParams }
|
||||
> = (props) => {
|
||||
const { params } = props ?? {};
|
||||
|
||||
return prometheusQueryRangePost(params);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type PrometheusQueryRangePostMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof prometheusQueryRangePost>>
|
||||
>;
|
||||
|
||||
export type PrometheusQueryRangePostMutationError = ErrorType<
|
||||
PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO
|
||||
>;
|
||||
|
||||
/**
|
||||
* @summary Prometheus range query
|
||||
*/
|
||||
export const usePrometheusQueryRangePost = <
|
||||
TError = ErrorType<PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof prometheusQueryRangePost>>,
|
||||
TError,
|
||||
{ params: PrometheusQueryRangePostParams },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof prometheusQueryRangePost>>,
|
||||
TError,
|
||||
{ params: PrometheusQueryRangePostParams },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getPrometheusQueryRangePostMutationOptions(options));
|
||||
};
|
||||
@@ -385,6 +385,93 @@ export interface AlertmanagertypesGoogleChatReceiverConfigDTO {
|
||||
webhook_url?: ConfigSecretURLDTO;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesJSMOpsReceiverConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
api_key?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
http_config?: ConfigHTTPClientConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
message?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
priority?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
send_resolved?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
tags?: string;
|
||||
}
|
||||
|
||||
export type AlertmanagertypesJiraReceiverConfigDTOCustomFields = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type ModelDurationDTO = number;
|
||||
|
||||
export interface AlertmanagertypesJiraReceiverConfigDTO {
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
custom_fields?: AlertmanagertypesJiraReceiverConfigDTOCustomFields;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
http_config?: ConfigHTTPClientConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
issue_type?: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
labels?: string[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
priority?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
project?: string;
|
||||
reopen_duration?: ModelDurationDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
reopen_transition?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
resolve_transition?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
send_resolved?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
site?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
summary?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
wont_fix_resolution?: string;
|
||||
}
|
||||
|
||||
export enum AlertmanagertypesMaintenanceKindDTO {
|
||||
fixed = 'fixed',
|
||||
recurring = 'recurring',
|
||||
@@ -631,69 +718,6 @@ export interface ConfigIncidentioConfigDTO {
|
||||
url_file?: string;
|
||||
}
|
||||
|
||||
export interface ConfigJiraFieldConfigDTO {
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
enable_update?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
template?: string;
|
||||
}
|
||||
|
||||
export type ModelDurationDTO = number;
|
||||
|
||||
export type ConfigJiraConfigDTOCustomFields = { [key: string]: unknown };
|
||||
|
||||
export interface ConfigJiraConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
api_type?: string;
|
||||
api_url?: ConfigURLType2DTO;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
custom_fields?: ConfigJiraConfigDTOCustomFields;
|
||||
description?: ConfigJiraFieldConfigDTO;
|
||||
http_config?: ConfigHTTPClientConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
issue_type?: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
labels?: string[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
priority?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
project?: string;
|
||||
reopen_duration?: ModelDurationDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
reopen_transition?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
resolve_transition?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
send_resolved?: boolean;
|
||||
summary?: ConfigJiraFieldConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
wont_fix_resolution?: string;
|
||||
}
|
||||
|
||||
export interface ConfigMattermostFieldDTO {
|
||||
/**
|
||||
* @type boolean,null
|
||||
@@ -1652,7 +1676,11 @@ export type AlertmanagertypesPostableChannelDTO = unknown & {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
jira_configs?: ConfigJiraConfigDTO[];
|
||||
jira_configs?: AlertmanagertypesJiraReceiverConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
jsmops_configs?: AlertmanagertypesJSMOpsReceiverConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
@@ -1779,7 +1807,11 @@ export interface AlertmanagertypesReceiverDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
jira_configs?: ConfigJiraConfigDTO[];
|
||||
jira_configs?: AlertmanagertypesJiraReceiverConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
jsmops_configs?: AlertmanagertypesJSMOpsReceiverConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
@@ -2175,6 +2207,7 @@ export enum CoretypesKindDTO {
|
||||
'factor-api-key' = 'factor-api-key',
|
||||
license = 'license',
|
||||
subscription = 'subscription',
|
||||
'deployment-host' = 'deployment-host',
|
||||
logs = 'logs',
|
||||
traces = 'traces',
|
||||
metrics = 'metrics',
|
||||
@@ -3267,6 +3300,67 @@ export interface CommonJSONRefDTO {
|
||||
$ref?: string;
|
||||
}
|
||||
|
||||
export type ConfigJiraConfigDTOCustomFields = { [key: string]: unknown };
|
||||
|
||||
export interface ConfigJiraFieldConfigDTO {
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
enable_update?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
template?: string;
|
||||
}
|
||||
|
||||
export interface ConfigJiraConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
api_type?: string;
|
||||
api_url?: ConfigURLType2DTO;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
custom_fields?: ConfigJiraConfigDTOCustomFields;
|
||||
description?: ConfigJiraFieldConfigDTO;
|
||||
http_config?: ConfigHTTPClientConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
issue_type?: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
labels?: string[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
priority?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
project?: string;
|
||||
reopen_duration?: ModelDurationDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
reopen_transition?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
resolve_transition?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
send_resolved?: boolean;
|
||||
summary?: ConfigJiraFieldConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
wont_fix_resolution?: string;
|
||||
}
|
||||
|
||||
export interface DashboardGridItemDTO {
|
||||
content?: CommonJSONRefDTO;
|
||||
/**
|
||||
@@ -3492,6 +3586,7 @@ export enum TelemetrytypesFieldContextDTO {
|
||||
span = 'span',
|
||||
trace = 'trace',
|
||||
resource = 'resource',
|
||||
scope = 'scope',
|
||||
attribute = 'attribute',
|
||||
body = 'body',
|
||||
'' = '',
|
||||
@@ -4958,6 +5053,53 @@ export interface DashboardtypesGettablePublicDashboardDataV2DTO {
|
||||
publicDashboard?: DashboardtypesGettablePublicDasbhboardDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesGettableSystemDashboardDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
createdBy?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
image?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
locked: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
schemaVersion: string;
|
||||
source: DashboardtypesSourceDTO;
|
||||
spec: DashboardtypesDashboardSpecDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
tags: TagtypesGettableTagDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export enum DashboardtypesPatchOpDTO {
|
||||
add = 'add',
|
||||
remove = 'remove',
|
||||
@@ -7972,6 +8114,164 @@ export interface PreferencetypesUpdatablePreferenceDTO {
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
export enum PrometheusErrorResponseSchemaDTOErrorType {
|
||||
bad_data = 'bad_data',
|
||||
execution = 'execution',
|
||||
canceled = 'canceled',
|
||||
timeout = 'timeout',
|
||||
internal = 'internal',
|
||||
}
|
||||
export enum PrometheusErrorResponseSchemaDTOStatus {
|
||||
error = 'error',
|
||||
}
|
||||
export interface PrometheusErrorResponseSchemaDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
error: string;
|
||||
/**
|
||||
* @enum bad_data,execution,canceled,timeout,internal
|
||||
* @type string
|
||||
*/
|
||||
errorType: PrometheusErrorResponseSchemaDTOErrorType;
|
||||
/**
|
||||
* @enum error
|
||||
* @type string
|
||||
*/
|
||||
status: PrometheusErrorResponseSchemaDTOStatus;
|
||||
}
|
||||
|
||||
export enum PrometheusMatrixDataSchemaDTOResultType {
|
||||
matrix = 'matrix',
|
||||
}
|
||||
export type PrometheusSamplePairSchemaDTOItem = number | string;
|
||||
|
||||
/**
|
||||
* A [timestamp, value] pair: float unix seconds, then the string-encoded sample value ("NaN", "+Inf", "-Inf" included).
|
||||
* @minItems 2
|
||||
* @maxItems 2
|
||||
* @nullable
|
||||
*/
|
||||
export type PrometheusSamplePairSchemaDTO =
|
||||
| PrometheusSamplePairSchemaDTOItem[]
|
||||
| null;
|
||||
|
||||
export type PrometheusMatrixSeriesSchemaDTOMetricAnyOf = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type PrometheusMatrixSeriesSchemaDTOMetric =
|
||||
PrometheusMatrixSeriesSchemaDTOMetricAnyOf | null;
|
||||
|
||||
export interface PrometheusMatrixSeriesSchemaDTO {
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
metric: PrometheusMatrixSeriesSchemaDTOMetric;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
values: (PrometheusSamplePairSchemaDTO | null)[] | null;
|
||||
}
|
||||
|
||||
export interface PrometheusMatrixDataSchemaDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
result: PrometheusMatrixSeriesSchemaDTO[] | null;
|
||||
/**
|
||||
* @enum matrix
|
||||
* @type string
|
||||
*/
|
||||
resultType: PrometheusMatrixDataSchemaDTOResultType;
|
||||
}
|
||||
|
||||
export type PrometheusVectorSampleSchemaDTOMetricAnyOf = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type PrometheusVectorSampleSchemaDTOMetric =
|
||||
PrometheusVectorSampleSchemaDTOMetricAnyOf | null;
|
||||
|
||||
export interface PrometheusVectorSampleSchemaDTO {
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
metric: PrometheusVectorSampleSchemaDTOMetric;
|
||||
value: PrometheusSamplePairSchemaDTO | null;
|
||||
}
|
||||
|
||||
export enum PrometheusVectorDataSchemaDTOResultType {
|
||||
vector = 'vector',
|
||||
}
|
||||
export interface PrometheusVectorDataSchemaDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
result: PrometheusVectorSampleSchemaDTO[] | null;
|
||||
/**
|
||||
* @enum vector
|
||||
* @type string
|
||||
*/
|
||||
resultType: PrometheusVectorDataSchemaDTOResultType;
|
||||
}
|
||||
|
||||
export enum PrometheusScalarDataSchemaDTOResultType {
|
||||
scalar = 'scalar',
|
||||
}
|
||||
export interface PrometheusScalarDataSchemaDTO {
|
||||
result: PrometheusSamplePairSchemaDTO | null;
|
||||
/**
|
||||
* @enum scalar
|
||||
* @type string
|
||||
*/
|
||||
resultType: PrometheusScalarDataSchemaDTOResultType;
|
||||
}
|
||||
|
||||
export enum PrometheusStringDataSchemaDTOResultType {
|
||||
string = 'string',
|
||||
}
|
||||
export interface PrometheusStringDataSchemaDTO {
|
||||
result: PrometheusSamplePairSchemaDTO | null;
|
||||
/**
|
||||
* @enum string
|
||||
* @type string
|
||||
*/
|
||||
resultType: PrometheusStringDataSchemaDTOResultType;
|
||||
}
|
||||
|
||||
export type PrometheusQueryDataSchemaDTO =
|
||||
| PrometheusMatrixDataSchemaDTO
|
||||
| PrometheusVectorDataSchemaDTO
|
||||
| PrometheusScalarDataSchemaDTO
|
||||
| PrometheusStringDataSchemaDTO;
|
||||
|
||||
export enum PrometheusSuccessResponseSchemaDTOStatus {
|
||||
success = 'success',
|
||||
}
|
||||
export interface PrometheusSuccessResponseSchemaDTO {
|
||||
data: PrometheusQueryDataSchemaDTO;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
infos?: string[];
|
||||
/**
|
||||
* @enum success
|
||||
* @type string
|
||||
*/
|
||||
status: PrometheusSuccessResponseSchemaDTOStatus;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
export interface PromotetypesWrappedIndexDTO {
|
||||
fieldDataType?: TelemetrytypesFieldDataTypeDTO;
|
||||
/**
|
||||
@@ -9021,6 +9321,7 @@ export enum SavedviewtypesSourceDTO {
|
||||
logs = 'logs',
|
||||
metrics = 'metrics',
|
||||
meter = 'meter',
|
||||
ai_observability = 'ai_observability',
|
||||
}
|
||||
export interface SavedviewtypesSavedViewSpecDTO {
|
||||
display?: SavedviewtypesDisplayDTO;
|
||||
@@ -11310,6 +11611,17 @@ export type MigrateDashboardV2200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetSystemDashboardPathParameters = {
|
||||
name: string;
|
||||
};
|
||||
export type GetSystemDashboard200 = {
|
||||
data: DashboardtypesGettableSystemDashboardDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetFeatures200 = {
|
||||
/**
|
||||
* @type array
|
||||
@@ -12468,3 +12780,115 @@ export type ReplaceVariables200 = {
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type PrometheusQueryParams = {
|
||||
/**
|
||||
* @type string
|
||||
* @description PromQL expression.
|
||||
*/
|
||||
query: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Evaluation timestamp: RFC3339 or float unix seconds. Defaults to the server's current time.
|
||||
*/
|
||||
time?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Evaluation timeout: duration string or float seconds.
|
||||
*/
|
||||
timeout?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Any non-empty value includes query statistics in the response.
|
||||
*/
|
||||
stats?: string;
|
||||
};
|
||||
|
||||
export type PrometheusQueryPostParams = {
|
||||
/**
|
||||
* @type string
|
||||
* @description PromQL expression.
|
||||
*/
|
||||
query: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Evaluation timestamp: RFC3339 or float unix seconds. Defaults to the server's current time.
|
||||
*/
|
||||
time?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Evaluation timeout: duration string or float seconds.
|
||||
*/
|
||||
timeout?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Any non-empty value includes query statistics in the response.
|
||||
*/
|
||||
stats?: string;
|
||||
};
|
||||
|
||||
export type PrometheusQueryRangeParams = {
|
||||
/**
|
||||
* @type string
|
||||
* @description PromQL expression.
|
||||
*/
|
||||
query: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Range start: RFC3339 or float unix seconds.
|
||||
*/
|
||||
start: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Range end: RFC3339 or float unix seconds.
|
||||
*/
|
||||
end: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Resolution step: duration string or float seconds.
|
||||
*/
|
||||
step: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Evaluation timeout: duration string or float seconds.
|
||||
*/
|
||||
timeout?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Any non-empty value includes query statistics in the response.
|
||||
*/
|
||||
stats?: string;
|
||||
};
|
||||
|
||||
export type PrometheusQueryRangePostParams = {
|
||||
/**
|
||||
* @type string
|
||||
* @description PromQL expression.
|
||||
*/
|
||||
query: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Range start: RFC3339 or float unix seconds.
|
||||
*/
|
||||
start: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Range end: RFC3339 or float unix seconds.
|
||||
*/
|
||||
end: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Resolution step: duration string or float seconds.
|
||||
*/
|
||||
step: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Evaluation timeout: duration string or float seconds.
|
||||
*/
|
||||
timeout?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Any non-empty value includes query statistics in the response.
|
||||
*/
|
||||
stats?: string;
|
||||
};
|
||||
|
||||
@@ -8,12 +8,14 @@ export interface AlertBreadcrumbProps {
|
||||
items: BreadcrumbItemConfig[];
|
||||
className?: string;
|
||||
showDivider?: boolean;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
function AlertBreadcrumb({
|
||||
items,
|
||||
className,
|
||||
showDivider = true,
|
||||
testId,
|
||||
}: AlertBreadcrumbProps): JSX.Element {
|
||||
const breadcrumbItems = items.map((item) => ({
|
||||
title: <BreadcrumbItem {...item} />,
|
||||
@@ -24,6 +26,7 @@ function AlertBreadcrumb({
|
||||
<Breadcrumb
|
||||
className={`${styles.breadcrumb} ${className || ''}`}
|
||||
items={breadcrumbItems}
|
||||
data-testid={testId}
|
||||
/>
|
||||
{showDivider && <Divider className={styles.divider} />}
|
||||
</>
|
||||
|
||||
@@ -7,9 +7,8 @@ import axios from 'axios';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
|
||||
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useDeleteView } from 'hooks/saveViews/useDeleteView';
|
||||
import { useGetAllViews } from 'hooks/saveViews/useGetAllViews';
|
||||
@@ -69,9 +68,7 @@ function ExplorerCard({
|
||||
setIsOpen(newOpen);
|
||||
};
|
||||
|
||||
const viewName = useGetSearchQueryParam(QueryParams.viewName) || '';
|
||||
|
||||
const viewKey = useGetSearchQueryParam(QueryParams.viewKey) || '';
|
||||
const { viewName, viewKey } = useGetSavedViewParams();
|
||||
|
||||
const { options } = useOptionsMenu({
|
||||
storageKey:
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
|
||||
export const ExploreHeaderToolTip = {
|
||||
url: 'https://signoz.io/docs/querying/overview/?utm_source=product&utm_medium=new-query-builder',
|
||||
text: 'More details on how to use query builder',
|
||||
@@ -9,5 +7,3 @@ export const SaveButtonText = {
|
||||
SAVE_AS_NEW_VIEW: 'Save as new view',
|
||||
SAVE_VIEW: 'Save view',
|
||||
};
|
||||
|
||||
export type QuerySearchParamNames = QueryParams.viewName | QueryParams.viewKey;
|
||||
|
||||
@@ -241,28 +241,29 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
))
|
||||
)}
|
||||
|
||||
{!showOnlyWhereClause && currentQuery.builder.queryFormulas.length > 0 && (
|
||||
<div className="qb-formulas-container">
|
||||
{currentQuery.builder.queryFormulas.map((formula, index) => {
|
||||
const query =
|
||||
currentQuery.builder.queryData[index] ||
|
||||
currentQuery.builder.queryData[0];
|
||||
{!showOnlyWhereClause &&
|
||||
currentQuery.builder.queryFormulas?.length > 0 && (
|
||||
<div className="qb-formulas-container">
|
||||
{currentQuery.builder.queryFormulas.map((formula, index) => {
|
||||
const query =
|
||||
currentQuery.builder.queryData[index] ||
|
||||
currentQuery.builder.queryData[0];
|
||||
|
||||
return (
|
||||
<div key={formula.queryName} className="qb-formula">
|
||||
<Formula
|
||||
filterConfigs={filterConfigs}
|
||||
query={query}
|
||||
formula={formula}
|
||||
index={index}
|
||||
isAdditionalFilterEnable={false}
|
||||
isQBV2
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<div key={formula.queryName} className="qb-formula">
|
||||
<Formula
|
||||
filterConfigs={filterConfigs}
|
||||
query={query}
|
||||
formula={formula}
|
||||
index={index}
|
||||
isAdditionalFilterEnable={false}
|
||||
isQBV2
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldShowFooter && (
|
||||
<QueryFooter
|
||||
@@ -290,7 +291,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
</div>
|
||||
))}
|
||||
|
||||
{currentQuery.builder.queryFormulas.map((formula) => (
|
||||
{currentQuery.builder.queryFormulas?.map((formula) => (
|
||||
<div key={formula.queryName} className="formula-name">
|
||||
{formula.queryName}
|
||||
</div>
|
||||
|
||||
@@ -212,6 +212,32 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
|
||||
expect(handleRunQueryMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not crash when builder.queryFormulas/queryTraceOperator are missing (partial/legacy query)', () => {
|
||||
const currentQueryBase = baseQBContext.currentQuery as Query;
|
||||
|
||||
mockedUseQueryBuilder.mockReturnValue({
|
||||
...baseQBContext,
|
||||
currentQuery: {
|
||||
...currentQueryBase,
|
||||
builder: {
|
||||
queryData: currentQueryBase.builder.queryData,
|
||||
queryFormulas: undefined as unknown as [],
|
||||
queryTraceOperator: undefined as unknown as [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
render(<QueryBuilderV2 panelType={PANEL_TYPES.TABLE} version="v4" />),
|
||||
).not.toThrow();
|
||||
|
||||
// query list still renders from queryData, formulas block is skipped
|
||||
expect(document.querySelector('.query-names-section')).toBeInTheDocument();
|
||||
expect(
|
||||
document.querySelector('.qb-formulas-container'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fx button is disabled when functions already exist', () => {
|
||||
const currentQueryBase = baseQBContext.currentQuery as Query;
|
||||
const supersetQueryBase = baseQBContext.supersetQuery as Query;
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
} from 'mocks-server/__mockdata__/roles';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
import {
|
||||
setupAuthzAdmin,
|
||||
setupAuthzDeny,
|
||||
@@ -110,10 +110,7 @@ describe('ServiceAccountDrawer — permissions', () => {
|
||||
it('shows PermissionDeniedCallout in Keys tab when list-keys permission is denied', async () => {
|
||||
server.use(setupAuthzDeny(APIKeyListPermission));
|
||||
|
||||
renderDrawer();
|
||||
await screen.findByDisplayValue('CI Bot');
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: /keys/i }));
|
||||
renderDrawer({ account: 'sa-1', tab: 'keys' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/list:factor-api-key/)).toBeInTheDocument();
|
||||
|
||||
@@ -29,6 +29,7 @@ export enum InfraMonitoringEvents {
|
||||
MetricsView = 'metrics',
|
||||
Total = 'total',
|
||||
Cluster = 'cluster',
|
||||
Container = 'container',
|
||||
DaemonSet = 'daemonSet',
|
||||
Deployment = 'deployment',
|
||||
Job = 'job',
|
||||
|
||||
@@ -29,6 +29,7 @@ function PopoverContent({
|
||||
<Link
|
||||
to={`${ROUTES.LOGS_EXPLORER}?${relatedLogsLink}`}
|
||||
className="contributor-row-popover-buttons__button"
|
||||
data-testid="alert-popover-view-logs"
|
||||
>
|
||||
<div className="icon">
|
||||
<LogsIcon />
|
||||
@@ -40,6 +41,7 @@ function PopoverContent({
|
||||
<Link
|
||||
to={`${ROUTES.TRACES_EXPLORER}?${relatedTracesLink}`}
|
||||
className="contributor-row-popover-buttons__button"
|
||||
data-testid="alert-popover-view-traces"
|
||||
>
|
||||
<div className="icon">
|
||||
<DraftingCompass
|
||||
|
||||
@@ -26,7 +26,10 @@ function ChangePercentage({
|
||||
}: ChangePercentageProps): JSX.Element {
|
||||
if (direction > 0) {
|
||||
return (
|
||||
<div className="change-percentage change-percentage--success">
|
||||
<div
|
||||
className="change-percentage change-percentage--success"
|
||||
data-testid="stats-card-change"
|
||||
>
|
||||
<div className="change-percentage__icon">
|
||||
<ArrowDownLeft size={14} color={Color.BG_FOREST_500} />
|
||||
</div>
|
||||
@@ -38,7 +41,10 @@ function ChangePercentage({
|
||||
}
|
||||
if (direction < 0) {
|
||||
return (
|
||||
<div className="change-percentage change-percentage--error">
|
||||
<div
|
||||
className="change-percentage change-percentage--error"
|
||||
data-testid="stats-card-change"
|
||||
>
|
||||
<div className="change-percentage__icon">
|
||||
<ArrowUpRight size={14} color={Color.BG_CHERRY_500} />
|
||||
</div>
|
||||
@@ -50,7 +56,10 @@ function ChangePercentage({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="change-percentage change-percentage--no-previous-data">
|
||||
<div
|
||||
className="change-percentage change-percentage--no-previous-data"
|
||||
data-testid="stats-card-change"
|
||||
>
|
||||
<div className="change-percentage__label">no previous data</div>
|
||||
</div>
|
||||
);
|
||||
@@ -103,7 +112,12 @@ function StatsCard({
|
||||
const formattedEndTimeForTooltip = convertTimestampToLocaleDateString(endTime);
|
||||
|
||||
return (
|
||||
<div className={`stats-card ${isEmpty ? 'stats-card--empty' : ''}`}>
|
||||
<div
|
||||
className={`stats-card ${isEmpty ? 'stats-card--empty' : ''}`}
|
||||
data-testid="stats-card"
|
||||
data-stats-title={title}
|
||||
data-empty={isEmpty ? 'true' : 'false'}
|
||||
>
|
||||
<div className="stats-card__title-wrapper">
|
||||
<div className="title">{title}</div>
|
||||
<div className="duration-indicator">
|
||||
@@ -123,7 +137,7 @@ function StatsCard({
|
||||
</div>
|
||||
|
||||
<div className="stats-card__stats">
|
||||
<div className="count-label">
|
||||
<div className="count-label" data-testid="stats-card-value">
|
||||
{isEmpty ? emptyMessage : displayValue || totalCurrentCount}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -81,7 +81,11 @@ function StatsGraph({ timeSeries, changeDirection }: Props): JSX.Element {
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
|
||||
<div
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
ref={graphRef}
|
||||
data-testid="stats-card-sparkline"
|
||||
>
|
||||
<Uplot data={[xData, yData]} options={options} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -48,11 +48,16 @@ function TopContributorsCard({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="top-contributors-card">
|
||||
<div className="top-contributors-card" data-testid="top-contributors-card">
|
||||
<div className="top-contributors-card__header">
|
||||
<div className="title">top contributors</div>
|
||||
{topContributorsData.length > 3 && (
|
||||
<Button type="text" className="view-all" onClick={toggleViewAllDrawer}>
|
||||
<Button
|
||||
type="text"
|
||||
className="view-all"
|
||||
onClick={toggleViewAllDrawer}
|
||||
data-testid="top-contributors-view-all"
|
||||
>
|
||||
<div className="label">View all</div>
|
||||
<div className="icon">
|
||||
<ArrowRight
|
||||
|
||||
@@ -68,7 +68,10 @@ function TopContributorsRows({
|
||||
relatedTracesLink={record.relatedTracesLink}
|
||||
relatedLogsLink={record.relatedLogsLink}
|
||||
>
|
||||
<div className="total-contribution">
|
||||
<div
|
||||
className="total-contribution"
|
||||
data-testid="top-contributors-row-count"
|
||||
>
|
||||
{count}/{totalCurrentTriggers}
|
||||
</div>
|
||||
</ConditionalAlertPopover>
|
||||
@@ -78,7 +81,10 @@ function TopContributorsRows({
|
||||
|
||||
const handleRowClick = (
|
||||
record: AlertRuleTopContributors,
|
||||
): HTMLAttributes<AlertRuleTimelineTableResponse> => ({
|
||||
): HTMLAttributes<AlertRuleTimelineTableResponse> & {
|
||||
'data-testid': string;
|
||||
} => ({
|
||||
'data-testid': 'top-contributors-row',
|
||||
onClick: (): void => {
|
||||
logEvent('Alert history: Top contributors row: Clicked', {
|
||||
labels: record.labels,
|
||||
|
||||
@@ -31,7 +31,10 @@ function ViewAllDrawer({
|
||||
}}
|
||||
title="Viewing All Contributors"
|
||||
>
|
||||
<div className="top-contributors-card--view-all">
|
||||
<div
|
||||
className="top-contributors-card--view-all"
|
||||
data-testid="top-contributors-drawer"
|
||||
>
|
||||
<div className="top-contributors-card__content">
|
||||
<TopContributorsRows
|
||||
topContributors={topContributorsData}
|
||||
|
||||
@@ -32,8 +32,8 @@ function GraphWrapper({
|
||||
}, [data?.data]);
|
||||
|
||||
return (
|
||||
<div className="timeline-graph">
|
||||
<div className="timeline-graph__title">
|
||||
<div className="timeline-graph" data-testid="timeline-graph">
|
||||
<div className="timeline-graph__title" data-testid="timeline-graph-title">
|
||||
{totalCurrentTriggers} triggers in {relativeTime}
|
||||
</div>
|
||||
<div className="timeline-graph__chart">
|
||||
|
||||
@@ -118,7 +118,10 @@ function TimelineTableContent(): JSX.Element {
|
||||
|
||||
const handleRowClick = (
|
||||
record: AlertRuleTimelineTableResponse,
|
||||
): HTMLAttributes<AlertRuleTimelineTableResponse> => ({
|
||||
): HTMLAttributes<AlertRuleTimelineTableResponse> & {
|
||||
'data-testid': string;
|
||||
} => ({
|
||||
'data-testid': 'timeline-row',
|
||||
onClick: (): void => {
|
||||
void logEvent('Alert history: Timeline table row: Clicked', {
|
||||
ruleId: record.ruleID,
|
||||
@@ -128,12 +131,15 @@ function TimelineTableContent(): JSX.Element {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="timeline-table">
|
||||
<div className="timeline-table" data-testid="timeline-table">
|
||||
{/* If we don't wait to have the keys, the QuerySearch will not render them at first usage */}
|
||||
{!isLoadingKeys && hardcodedAttributeKeys ? (
|
||||
<div className="timeline-table__filter">
|
||||
<div className="timeline-table__filter-row">
|
||||
<div className="timeline-table__filter-search">
|
||||
<div
|
||||
className="timeline-table__filter-search"
|
||||
data-testid="timeline-filter-search"
|
||||
>
|
||||
<QuerySearch
|
||||
onChange={querySearchOnChange}
|
||||
queryData={queryData}
|
||||
@@ -155,6 +161,7 @@ function TimelineTableContent(): JSX.Element {
|
||||
<Skeleton.Input
|
||||
className="timeline-table__filter--loading-skeleton"
|
||||
active
|
||||
data-testid="timeline-filter-skeleton"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -172,14 +179,17 @@ function TimelineTableContent(): JSX.Element {
|
||||
locale={{
|
||||
emptyText:
|
||||
isError && apiError ? (
|
||||
<div className="timeline-table__error">
|
||||
<div className="timeline-table__error" data-testid="timeline-error">
|
||||
<ErrorContent error={apiError} />
|
||||
</div>
|
||||
) : undefined,
|
||||
}}
|
||||
footer={(): JSX.Element => (
|
||||
<div className="timeline-table__pagination">
|
||||
<div className="timeline-table__pagination-info">
|
||||
<div
|
||||
className="timeline-table__pagination-info"
|
||||
data-testid="timeline-footer-range"
|
||||
>
|
||||
{paginationConfig.showTotal?.(totalItems, [
|
||||
totalItems === 0
|
||||
? 0
|
||||
|
||||
@@ -21,18 +21,14 @@ export const timelineTableColumns = ({
|
||||
sorter: true,
|
||||
width: 140,
|
||||
render: (value): JSX.Element => (
|
||||
<div className="alert-rule-state">
|
||||
<AlertState state={value} showLabel />
|
||||
</div>
|
||||
<AlertState state={value} showLabel testId="timeline-row-state" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'LABELS',
|
||||
dataIndex: 'labels',
|
||||
render: (labels): JSX.Element => (
|
||||
<div className="alert-rule-labels">
|
||||
<AlertLabels labels={labels} />
|
||||
</div>
|
||||
<AlertLabels labels={labels} testId="timeline-row-labels" />
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -40,7 +36,10 @@ export const timelineTableColumns = ({
|
||||
dataIndex: 'unixMilli',
|
||||
width: 200,
|
||||
render: (value): JSX.Element => (
|
||||
<div className="alert-rule__created-at">
|
||||
<div
|
||||
className="alert-rule__created-at"
|
||||
data-testid="timeline-row-created-at"
|
||||
>
|
||||
{formatTimezoneAdjustedTimestamp(value, DATE_TIME_FORMATS.DASH_DATETIME)}
|
||||
</div>
|
||||
),
|
||||
@@ -53,7 +52,7 @@ export const timelineTableColumns = ({
|
||||
if (!record.relatedTracesLink && !record.relatedLogsLink) {
|
||||
return (
|
||||
<Tooltip title="No links available for this item">
|
||||
<Button type="text" ghost disabled>
|
||||
<Button type="text" ghost disabled data-testid="timeline-row-actions">
|
||||
<Ellipsis className="dropdown-icon" size="md" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
@@ -65,7 +64,7 @@ export const timelineTableColumns = ({
|
||||
relatedTracesLink={record.relatedTracesLink ?? ''}
|
||||
relatedLogsLink={record.relatedLogsLink ?? ''}
|
||||
>
|
||||
<Button type="text" ghost>
|
||||
<Button type="text" ghost data-testid="timeline-row-actions">
|
||||
<Ellipsis className="dropdown-icon" size="md" />
|
||||
</Button>
|
||||
</ConditionalAlertPopover>
|
||||
|
||||
@@ -10,6 +10,7 @@ const fieldContextToSuggestionMap: Record<
|
||||
[TelemetrytypesFieldContextDTO.attribute]: 'attribute',
|
||||
// no maps for the following values on suggestion context
|
||||
[TelemetrytypesFieldContextDTO.trace]: undefined,
|
||||
[TelemetrytypesFieldContextDTO.scope]: undefined,
|
||||
[TelemetrytypesFieldContextDTO.body]: undefined,
|
||||
[TelemetrytypesFieldContextDTO.metric]: undefined,
|
||||
[TelemetrytypesFieldContextDTO.log]: undefined,
|
||||
|
||||
@@ -23,6 +23,7 @@ function TimelineTabs(): JSX.Element {
|
||||
{
|
||||
value: TimelineTab.OVERALL_STATUS,
|
||||
label: 'Overall Status',
|
||||
testId: 'timeline-tab-overall-status',
|
||||
},
|
||||
{
|
||||
value: TimelineTab.TOP_5_CONTRIBUTORS,
|
||||
@@ -33,6 +34,7 @@ function TimelineTabs(): JSX.Element {
|
||||
</div>
|
||||
),
|
||||
disabled: true,
|
||||
testId: 'timeline-tab-top-contributors',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -57,14 +59,17 @@ function TimelineFilters(): JSX.Element {
|
||||
{
|
||||
value: TimelineFilter.ALL,
|
||||
label: 'All',
|
||||
testId: 'timeline-filter-all',
|
||||
},
|
||||
{
|
||||
value: TimelineFilter.FIRED,
|
||||
label: 'Fired',
|
||||
testId: 'timeline-filter-fired',
|
||||
},
|
||||
{
|
||||
value: TimelineFilter.RESOLVED,
|
||||
label: 'Resolved',
|
||||
testId: 'timeline-filter-resolved',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import CreateAlertChannels from 'container/CreateAlertChannels';
|
||||
import { ChannelType } from 'container/CreateAlertChannels/config';
|
||||
import { GoogleChatInitialConfig } from 'container/CreateAlertChannels/defaults';
|
||||
import {
|
||||
GoogleChatInitialConfig,
|
||||
JiraInitialConfig,
|
||||
JsmOpsInitialConfig,
|
||||
} from 'container/CreateAlertChannels/defaults';
|
||||
import {
|
||||
googleChatDescriptionDefaultValue,
|
||||
googleChatTitleDefaultValue,
|
||||
@@ -526,6 +530,213 @@ describe('Create Alert Channel', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('Jira', () => {
|
||||
const validSite = 'https://acme.atlassian.net';
|
||||
|
||||
const fillRequired = async (
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
site: string,
|
||||
): Promise<void> => {
|
||||
await user.type(screen.getByTestId('channel-name-textbox'), 'jira-channel');
|
||||
await user.type(screen.getByTestId('jira-site-textbox'), site);
|
||||
await user.type(screen.getByTestId('jira-email-textbox'), 'me@acme.com');
|
||||
await user.type(screen.getByTestId('jira-api-token-textbox'), 'tok123');
|
||||
await user.type(screen.getByTestId('jira-project-textbox'), 'KAN');
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
render(<CreateAlertChannels preType={ChannelType.Jira} />);
|
||||
});
|
||||
|
||||
it('Should check if the selected item in the type dropdown has text "Jira"', () => {
|
||||
expect(screen.getByText('Jira')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should check if the Site URL field is displayed properly', () => {
|
||||
testLabelInputAndHelpValue({
|
||||
labelText: 'field_jira_site',
|
||||
testId: 'jira-site-textbox',
|
||||
});
|
||||
});
|
||||
|
||||
it('Should prefill the issue type with Task', () => {
|
||||
expect(screen.getByTestId('jira-issue-type-textbox')).toHaveValue('Task');
|
||||
});
|
||||
|
||||
it('Should show the service-account recommendation tip linking to the docs', () => {
|
||||
expect(screen.getByTestId('jira-service-account-tip')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'jira_service_account_tip_link' }),
|
||||
).toHaveAttribute(
|
||||
'href',
|
||||
'https://signoz.io/docs/alerts-management/notification-channel/jira/#use-a-service-account-recommended',
|
||||
);
|
||||
});
|
||||
|
||||
it('Should display an error when the site is not an atlassian.net URL', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
await fillRequired(user, 'https://example.com');
|
||||
|
||||
await user.click(screen.getByTestId('save-channel-button'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(errorNotification).toHaveBeenCalledWith({
|
||||
message: 'Error',
|
||||
description: 'jira_site_invalid',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('Should send a jira_configs payload with basic auth', async () => {
|
||||
let requestBody: unknown;
|
||||
server.use(
|
||||
rest.post('http://localhost/api/v1/channels', async (req, res, ctx) => {
|
||||
requestBody = await req.json();
|
||||
return res(
|
||||
ctx.status(201),
|
||||
ctx.json({ status: 'success', data: 'channel created' }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ delay: null });
|
||||
await fillRequired(user, validSite);
|
||||
|
||||
await user.click(screen.getByTestId('save-channel-button'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(successNotification).toHaveBeenCalledWith({
|
||||
message: 'Success',
|
||||
description: 'channel_creation_done',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(requestBody).toStrictEqual({
|
||||
name: 'jira-channel',
|
||||
jira_configs: [
|
||||
{
|
||||
site: validSite,
|
||||
project: 'KAN',
|
||||
issue_type: 'Task',
|
||||
summary: JiraInitialConfig.summary,
|
||||
description: JiraInitialConfig.description,
|
||||
send_resolved: true,
|
||||
http_config: {
|
||||
basic_auth: { username: 'me@acme.com', password: 'tok123' },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
it('Should block save when the reopen window is below the 1m minimum', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
await fillRequired(user, validSite);
|
||||
|
||||
await user.click(screen.getByText('jira_advanced_section'));
|
||||
await user.type(screen.getByTestId('jira-reopen-duration-textbox'), '30s');
|
||||
|
||||
// the rule surfaces an inline message, not just a red border
|
||||
await expect(
|
||||
screen.findByText('jira_reopen_duration_invalid'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('save-channel-button'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(errorNotification).toHaveBeenCalledWith({
|
||||
message: 'Error',
|
||||
description: 'jira_reopen_duration_invalid',
|
||||
}),
|
||||
);
|
||||
}, 15000);
|
||||
});
|
||||
describe('JSM Ops', () => {
|
||||
beforeEach(() => {
|
||||
render(<CreateAlertChannels preType={ChannelType.JsmOps} />);
|
||||
});
|
||||
|
||||
it('Should show "Jira Service Management Ops" as the selected type', () => {
|
||||
expect(screen.getByText('Jira Service Management Ops')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should display the API key field properly', () => {
|
||||
testLabelInputAndHelpValue({
|
||||
labelText: 'field_jsmops_api_key',
|
||||
testId: 'jsmops-api-key-textbox',
|
||||
});
|
||||
});
|
||||
|
||||
it('Should show the tip linking to the JSM Ops docs', () => {
|
||||
expect(screen.getByTestId('jsmops-tip')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'jsmops_tip_link' }),
|
||||
).toHaveAttribute(
|
||||
'href',
|
||||
'https://signoz.io/docs/alerts-management/notification-channel/jsm-ops/',
|
||||
);
|
||||
});
|
||||
|
||||
it('Should block save when the API key is missing', async () => {
|
||||
const user = userEvent.setup();
|
||||
await user.type(
|
||||
screen.getByTestId('channel-name-textbox'),
|
||||
'jsmops-channel',
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('save-channel-button'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(errorNotification).toHaveBeenCalledWith({
|
||||
message: 'Error',
|
||||
description: 'api_key_required',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('Should send a jsmops_configs payload with prefilled defaults', async () => {
|
||||
let requestBody: unknown;
|
||||
server.use(
|
||||
rest.post('http://localhost/api/v1/channels', async (req, res, ctx) => {
|
||||
requestBody = await req.json();
|
||||
return res(
|
||||
ctx.status(201),
|
||||
ctx.json({ status: 'success', data: 'channel created' }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.type(
|
||||
screen.getByTestId('channel-name-textbox'),
|
||||
'jsmops-channel',
|
||||
);
|
||||
await user.type(screen.getByTestId('jsmops-api-key-textbox'), 'key-abc');
|
||||
|
||||
await user.click(screen.getByTestId('save-channel-button'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(successNotification).toHaveBeenCalledWith({
|
||||
message: 'Success',
|
||||
description: 'channel_creation_done',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(requestBody).toStrictEqual({
|
||||
name: 'jsmops-channel',
|
||||
jsmops_configs: [
|
||||
{
|
||||
api_key: 'key-abc',
|
||||
send_resolved: true,
|
||||
message: JsmOpsInitialConfig.message,
|
||||
description: JsmOpsInitialConfig.description,
|
||||
priority: JsmOpsInitialConfig.priority,
|
||||
tags: JsmOpsInitialConfig.tags?.join(','),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('Changing the channel type', () => {
|
||||
async function selectType(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
|
||||
@@ -58,6 +58,38 @@ describe('EditAlertChannels save', () => {
|
||||
expect(edit.calls[0].id).toBe('3');
|
||||
});
|
||||
|
||||
it('blocks jira save when the reopen window is below the 1m minimum', async () => {
|
||||
const edit = mockEditChannel();
|
||||
const jiraInitialValue = {
|
||||
type: 'jira',
|
||||
name: 'jira-channel',
|
||||
site: 'https://acme.atlassian.net',
|
||||
username: 'user@acme.io',
|
||||
password: 'token',
|
||||
project: 'OPS',
|
||||
issue_type: 'Task',
|
||||
send_resolved: true,
|
||||
reopen_duration: '30s',
|
||||
};
|
||||
|
||||
const { unmount } = render(
|
||||
<EditAlertChannels channelId="3" initialValue={jiraInitialValue} />,
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByTestId('save-channel-button'));
|
||||
expect(edit.calls).toHaveLength(0);
|
||||
unmount();
|
||||
|
||||
render(
|
||||
<EditAlertChannels
|
||||
channelId="3"
|
||||
initialValue={{ ...jiraInitialValue, reopen_duration: '72h' }}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByTestId('save-channel-button'));
|
||||
await waitFor(() => expect(edit.calls).toHaveLength(1));
|
||||
});
|
||||
|
||||
it('persists send_resolved toggle in the edit request', async () => {
|
||||
const edit = mockEditChannel();
|
||||
render(
|
||||
|
||||
@@ -105,6 +105,8 @@ export enum ChannelType {
|
||||
Opsgenie = 'opsgenie',
|
||||
MsTeams = 'msteams',
|
||||
GoogleChat = 'googlechat',
|
||||
Jira = 'jira',
|
||||
JsmOps = 'jsmops',
|
||||
}
|
||||
|
||||
// LabelFilterStatement will be used for preparing filter conditions / matchers
|
||||
@@ -134,3 +136,39 @@ export interface GoogleChatChannel extends Channel {
|
||||
title?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
// JiraChannel configures the Jira Cloud alert channel. Auth is basic auth
|
||||
// (Atlassian account email + API token) carried in username / password.
|
||||
export interface JiraChannel extends Channel {
|
||||
// Jira Cloud base URL, e.g. https://acme.atlassian.net
|
||||
site: string;
|
||||
project: string;
|
||||
issue_type: string;
|
||||
// issue title template
|
||||
summary?: string;
|
||||
// issue body template, rendered to rich text server-side
|
||||
description?: string;
|
||||
// basic auth: username is the Atlassian account email, password is the API token
|
||||
username: string;
|
||||
password: string;
|
||||
priority?: string;
|
||||
labels?: string[];
|
||||
resolve_transition?: string;
|
||||
reopen_transition?: string;
|
||||
// duration string, e.g. 72h or 3d
|
||||
reopen_duration?: string;
|
||||
}
|
||||
|
||||
// JsmOpsChannel configures the Jira Service Management Ops alert channel
|
||||
// (ex-Opsgenie alert API). Auth is the JSM integration API key.
|
||||
export interface JsmOpsChannel extends Channel {
|
||||
api_key: string;
|
||||
// alert title template
|
||||
message?: string;
|
||||
// alert body template (markdown, rendered to HTML server-side)
|
||||
description?: string;
|
||||
// priority template, resolves to P1-P5
|
||||
priority?: string;
|
||||
// tags, joined to a comma-separated string for the backend
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import {
|
||||
ChannelType,
|
||||
EmailChannel,
|
||||
GoogleChatChannel,
|
||||
JiraChannel,
|
||||
JsmOpsChannel,
|
||||
MsTeamsChannel,
|
||||
OpsgenieChannel,
|
||||
PagerChannel,
|
||||
@@ -47,6 +49,23 @@ export const GoogleChatInitialConfig: Partial<GoogleChatChannel> = {
|
||||
{{ end }}`,
|
||||
};
|
||||
|
||||
// mirrors DefaultJiraSummaryTemplate / DefaultJiraDescriptionTemplate in
|
||||
// pkg/types/alertmanagertypes/jira.go, which the backend applies when the
|
||||
// summary / description are left empty. The description is markdown here and is
|
||||
// wrapped in the ADF status panel + deep-links server-side.
|
||||
export const JiraInitialConfig: Partial<JiraChannel> = {
|
||||
issue_type: 'Task',
|
||||
summary: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
|
||||
description: `{{ range .Alerts -}}
|
||||
**Alert:** {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}
|
||||
{{ if .Annotations.summary }}
|
||||
**Summary:** {{ .Annotations.summary }}
|
||||
{{ end }}{{ if .Annotations.description }}
|
||||
**Description:** {{ .Annotations.description }}
|
||||
{{ end }}
|
||||
{{ end }}`,
|
||||
};
|
||||
|
||||
export const PagerInitialConfig: Partial<PagerChannel> = {
|
||||
description: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}
|
||||
{{- if gt (len .CommonLabels) (len .GroupLabels) -}}
|
||||
@@ -98,6 +117,33 @@ export const OpsgenieInitialConfig: Partial<OpsgenieChannel> = {
|
||||
'{{ if eq (index .Alerts 0).Labels.severity "critical" }}P1{{ else if eq (index .Alerts 0).Labels.severity "warning" }}P2{{ else if eq (index .Alerts 0).Labels.severity "info" }}P3{{ else }}P4{{ end }}',
|
||||
};
|
||||
|
||||
// mirrors DefaultJSMOpsMessageTemplate / DefaultJSMOpsDescriptionTemplate in
|
||||
// pkg/types/alertmanagertypes/jsmops.go, applied by the backend when message /
|
||||
// description are left empty. send_resolved is seeded on so JSM alerts close on
|
||||
// resolve (the backend cannot default it, see jsmops.go). priority mirrors the
|
||||
// Opsgenie template mapping severity to P1-P5.
|
||||
export const JsmOpsInitialConfig: Partial<JsmOpsChannel> = {
|
||||
send_resolved: true,
|
||||
message: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
|
||||
description: `{{ range .Alerts -}}
|
||||
**Alert:** {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}
|
||||
|
||||
{{ if .Annotations.summary }}**Summary:** {{ .Annotations.summary }}
|
||||
|
||||
{{ end }}{{ if .Annotations.description }}**Description:** {{ .Annotations.description }}
|
||||
|
||||
{{ end }}{{ if .GeneratorURL }}[View in SigNoz]({{ .GeneratorURL }})
|
||||
|
||||
{{ end }}{{ if .Annotations.related_logs }}[View related logs]({{ .Annotations.related_logs }})
|
||||
|
||||
{{ end }}{{ if .Annotations.related_traces }}[View related traces]({{ .Annotations.related_traces }})
|
||||
|
||||
{{ end }}{{ end }}`,
|
||||
priority:
|
||||
'{{ if eq (index .Alerts 0).Labels.severity "critical" }}P1{{ else if eq (index .Alerts 0).Labels.severity "warning" }}P2{{ else if eq (index .Alerts 0).Labels.severity "info" }}P3{{ else }}P4{{ end }}',
|
||||
tags: ['signoz-alert'],
|
||||
};
|
||||
|
||||
export const EmailInitialConfig: Partial<EmailChannel> = {
|
||||
send_resolved: true,
|
||||
html: `<!--
|
||||
@@ -505,12 +551,16 @@ export const ChannelInitialConfig: Record<
|
||||
MsTeamsChannel &
|
||||
OpsgenieChannel &
|
||||
EmailChannel &
|
||||
GoogleChatChannel
|
||||
GoogleChatChannel &
|
||||
JiraChannel &
|
||||
JsmOpsChannel
|
||||
>
|
||||
> = {
|
||||
[ChannelType.Slack]: SlackInitialConfig,
|
||||
[ChannelType.MsTeams]: SlackInitialConfig,
|
||||
[ChannelType.GoogleChat]: GoogleChatInitialConfig,
|
||||
[ChannelType.Jira]: JiraInitialConfig,
|
||||
[ChannelType.JsmOps]: JsmOpsInitialConfig,
|
||||
[ChannelType.Pagerduty]: PagerInitialConfig,
|
||||
[ChannelType.Opsgenie]: OpsgenieInitialConfig,
|
||||
[ChannelType.Email]: EmailInitialConfig,
|
||||
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
ChannelType,
|
||||
EmailChannel,
|
||||
GoogleChatChannel,
|
||||
JiraChannel,
|
||||
JsmOpsChannel,
|
||||
MsTeamsChannel,
|
||||
OpsgenieChannel,
|
||||
PagerChannel,
|
||||
@@ -43,7 +45,11 @@ import { ChannelInitialConfig } from './defaults';
|
||||
import {
|
||||
isChannelType,
|
||||
isValidGoogleChatWebhookURL,
|
||||
isValidJiraReopenDuration,
|
||||
isValidJiraSiteURL,
|
||||
prepareGoogleChatRequest,
|
||||
prepareJiraRequest,
|
||||
prepareJsmOpsRequest,
|
||||
} from './utils';
|
||||
|
||||
import './CreateAlertChannels.styles.scss';
|
||||
@@ -69,7 +75,9 @@ function CreateAlertChannels({
|
||||
MsTeamsChannel &
|
||||
OpsgenieChannel &
|
||||
EmailChannel &
|
||||
GoogleChatChannel
|
||||
GoogleChatChannel &
|
||||
JiraChannel &
|
||||
JsmOpsChannel
|
||||
>
|
||||
>(() => ({
|
||||
send_resolved: true,
|
||||
@@ -434,6 +442,114 @@ function CreateAlertChannels({
|
||||
showErrorModal,
|
||||
]);
|
||||
|
||||
const validateJiraConfig = useCallback((): boolean => {
|
||||
if (
|
||||
!selectedConfig.site ||
|
||||
!selectedConfig.username ||
|
||||
!selectedConfig.password ||
|
||||
!selectedConfig.project ||
|
||||
!selectedConfig.issue_type
|
||||
) {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('jira_required_fields'),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isValidJiraSiteURL(selectedConfig.site)) {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('jira_site_invalid'),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
selectedConfig.reopen_duration &&
|
||||
!isValidJiraReopenDuration(selectedConfig.reopen_duration)
|
||||
) {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('jira_reopen_duration_invalid'),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [selectedConfig, notifications, t]);
|
||||
|
||||
const onJiraHandler = useCallback(async () => {
|
||||
if (!validateJiraConfig()) {
|
||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||
}
|
||||
|
||||
setSavingState(true);
|
||||
|
||||
try {
|
||||
await createChannel({ data: prepareJiraRequest(selectedConfig) });
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_creation_done'),
|
||||
});
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
return { status: 'success', statusMessage: t('channel_creation_done') };
|
||||
} catch (error) {
|
||||
showErrorModal(toAPIError(error as ErrorType<RenderErrorResponseDTO>));
|
||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||
} finally {
|
||||
setSavingState(false);
|
||||
}
|
||||
}, [
|
||||
validateJiraConfig,
|
||||
createChannel,
|
||||
selectedConfig,
|
||||
notifications,
|
||||
t,
|
||||
showErrorModal,
|
||||
]);
|
||||
|
||||
const validateJsmOpsConfig = useCallback((): boolean => {
|
||||
if (!selectedConfig.api_key) {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('api_key_required'),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}, [selectedConfig.api_key, notifications, t]);
|
||||
|
||||
const onJsmOpsHandler = useCallback(async () => {
|
||||
if (!validateJsmOpsConfig()) {
|
||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||
}
|
||||
|
||||
setSavingState(true);
|
||||
|
||||
try {
|
||||
await createChannel({ data: prepareJsmOpsRequest(selectedConfig) });
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_creation_done'),
|
||||
});
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
return { status: 'success', statusMessage: t('channel_creation_done') };
|
||||
} catch (error) {
|
||||
showErrorModal(toAPIError(error as ErrorType<RenderErrorResponseDTO>));
|
||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||
} finally {
|
||||
setSavingState(false);
|
||||
}
|
||||
}, [
|
||||
validateJsmOpsConfig,
|
||||
createChannel,
|
||||
selectedConfig,
|
||||
notifications,
|
||||
t,
|
||||
showErrorModal,
|
||||
]);
|
||||
|
||||
const onSaveHandler = useCallback(
|
||||
async (value: ChannelType) => {
|
||||
if (!selectedConfig.name) {
|
||||
@@ -452,6 +568,8 @@ function CreateAlertChannels({
|
||||
[ChannelType.MsTeams]: onMsTeamsHandler,
|
||||
[ChannelType.Email]: onEmailHandler,
|
||||
[ChannelType.GoogleChat]: onGoogleChatHandler,
|
||||
[ChannelType.Jira]: onJiraHandler,
|
||||
[ChannelType.JsmOps]: onJsmOpsHandler,
|
||||
};
|
||||
|
||||
if (isChannelType(value)) {
|
||||
@@ -484,6 +602,8 @@ function CreateAlertChannels({
|
||||
onMsTeamsHandler,
|
||||
onEmailHandler,
|
||||
onGoogleChatHandler,
|
||||
onJiraHandler,
|
||||
onJsmOpsHandler,
|
||||
notifications,
|
||||
t,
|
||||
],
|
||||
@@ -528,6 +648,20 @@ function CreateAlertChannels({
|
||||
}
|
||||
await testChannel({ data: prepareGoogleChatRequest(selectedConfig) });
|
||||
break;
|
||||
case ChannelType.Jira:
|
||||
if (!validateJiraConfig()) {
|
||||
setTestingState(false);
|
||||
return;
|
||||
}
|
||||
await testChannel({ data: prepareJiraRequest(selectedConfig) });
|
||||
break;
|
||||
case ChannelType.JsmOps:
|
||||
if (!validateJsmOpsConfig()) {
|
||||
setTestingState(false);
|
||||
return;
|
||||
}
|
||||
await testChannel({ data: prepareJsmOpsRequest(selectedConfig) });
|
||||
break;
|
||||
default:
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
@@ -576,6 +710,8 @@ function CreateAlertChannels({
|
||||
prepareMsTeamsRequest,
|
||||
prepareEmailRequest,
|
||||
validateGoogleChatConfig,
|
||||
validateJiraConfig,
|
||||
validateJsmOpsConfig,
|
||||
testChannel,
|
||||
notifications,
|
||||
],
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import {
|
||||
AlertmanagertypesJiraReceiverConfigDTO,
|
||||
AlertmanagertypesJSMOpsReceiverConfigDTO,
|
||||
AlertmanagertypesPostableChannelDTO,
|
||||
ConfigSecretURLDTO,
|
||||
ModelDurationDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { ChannelType, GoogleChatChannel } from './config';
|
||||
import {
|
||||
ChannelType,
|
||||
GoogleChatChannel,
|
||||
JiraChannel,
|
||||
JsmOpsChannel,
|
||||
} from './config';
|
||||
|
||||
export const isChannelType = (type: string): type is ChannelType =>
|
||||
Object.values(ChannelType).includes(type as ChannelType);
|
||||
@@ -37,3 +45,126 @@ export const prepareGoogleChatRequest = (
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const JIRA_CLOUD_HOST_SUFFIX = '.atlassian.net';
|
||||
|
||||
// the backend enforces the same rule, this is only for a nicer error experience
|
||||
export const isValidJiraSiteURL = (url: string): boolean => {
|
||||
try {
|
||||
const { protocol, hostname } = new URL(url);
|
||||
return (
|
||||
protocol === 'https:' &&
|
||||
hostname.toLowerCase().endsWith(JIRA_CLOUD_HOST_SUFFIX)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// mirrors go's prometheus model.Duration units
|
||||
const JIRA_DURATION_UNIT_MS: Record<string, number> = {
|
||||
ms: 1,
|
||||
s: 1_000,
|
||||
m: 60_000,
|
||||
h: 3_600_000,
|
||||
d: 86_400_000,
|
||||
w: 604_800_000,
|
||||
y: 31_536_000_000,
|
||||
};
|
||||
const JIRA_DURATION_RE = /^(\d+(ms|s|m|h|d|w|y))+$/;
|
||||
const JIRA_DURATION_TOKEN_RE = /(\d+)(ms|s|m|h|d|w|y)/g;
|
||||
const JIRA_MIN_REOPEN_MS = 60_000;
|
||||
|
||||
// backend requires the same format and a >= 1m minimum, this is only for a
|
||||
// nicer error experience. Empty and "0" defer to the backend default.
|
||||
export const isValidJiraReopenDuration = (value: string): boolean => {
|
||||
if (!value || value === '0') {
|
||||
return true;
|
||||
}
|
||||
if (!JIRA_DURATION_RE.test(value)) {
|
||||
return false;
|
||||
}
|
||||
let totalMs = 0;
|
||||
for (const [, amount, unit] of value.matchAll(JIRA_DURATION_TOKEN_RE)) {
|
||||
totalMs += Number(amount) * JIRA_DURATION_UNIT_MS[unit];
|
||||
}
|
||||
return totalMs >= JIRA_MIN_REOPEN_MS;
|
||||
};
|
||||
|
||||
// create, update and test all send the same body shape. Optional fields are
|
||||
// omitted when empty so the backend applies its defaults.
|
||||
export const prepareJiraRequest = (
|
||||
config: Partial<JiraChannel>,
|
||||
): AlertmanagertypesPostableChannelDTO => {
|
||||
const jira: AlertmanagertypesJiraReceiverConfigDTO = {
|
||||
site: config.site || '',
|
||||
project: config.project || '',
|
||||
issue_type: config.issue_type || '',
|
||||
send_resolved: config.send_resolved || false,
|
||||
http_config: {
|
||||
basic_auth: {
|
||||
username: config.username || '',
|
||||
password: config.password || '',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (config.summary) {
|
||||
jira.summary = config.summary;
|
||||
}
|
||||
if (config.description) {
|
||||
jira.description = config.description;
|
||||
}
|
||||
if (config.priority) {
|
||||
jira.priority = config.priority;
|
||||
}
|
||||
if (config.labels?.length) {
|
||||
jira.labels = config.labels;
|
||||
}
|
||||
if (config.resolve_transition) {
|
||||
jira.resolve_transition = config.resolve_transition;
|
||||
}
|
||||
if (config.reopen_transition) {
|
||||
jira.reopen_transition = config.reopen_transition;
|
||||
}
|
||||
if (config.reopen_duration) {
|
||||
// the generated type models go's model.Duration as a number, the api takes a
|
||||
// duration string like "72h"
|
||||
jira.reopen_duration = config.reopen_duration as unknown as ModelDurationDTO;
|
||||
}
|
||||
|
||||
return {
|
||||
name: config.name || '',
|
||||
jira_configs: [jira],
|
||||
};
|
||||
};
|
||||
|
||||
// create, update and test all send the same body shape. Optional fields are
|
||||
// omitted when empty so the backend applies its defaults.
|
||||
export const prepareJsmOpsRequest = (
|
||||
config: Partial<JsmOpsChannel>,
|
||||
): AlertmanagertypesPostableChannelDTO => {
|
||||
const jsmops: AlertmanagertypesJSMOpsReceiverConfigDTO = {
|
||||
api_key: config.api_key || '',
|
||||
send_resolved: config.send_resolved || false,
|
||||
};
|
||||
|
||||
if (config.message) {
|
||||
jsmops.message = config.message;
|
||||
}
|
||||
if (config.description) {
|
||||
jsmops.description = config.description;
|
||||
}
|
||||
if (config.priority) {
|
||||
jsmops.priority = config.priority;
|
||||
}
|
||||
if (config.tags?.length) {
|
||||
// the backend takes a comma-separated string and splits it back
|
||||
jsmops.tags = config.tags.join(',');
|
||||
}
|
||||
|
||||
return {
|
||||
name: config.name || '',
|
||||
jsmops_configs: [jsmops],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -34,6 +34,7 @@ function AdvancedOptions(): JSX.Element {
|
||||
})
|
||||
}
|
||||
value={advancedOptions.sendNotificationIfDataIsMissing.toleranceLimit}
|
||||
testId="send-notification-if-data-is-missing-input"
|
||||
/>
|
||||
<Typography.Text>Minutes</Typography.Text>
|
||||
</div>
|
||||
@@ -66,6 +67,7 @@ function AdvancedOptions(): JSX.Element {
|
||||
})
|
||||
}
|
||||
value={advancedOptions.enforceMinimumDatapoints.minimumDatapoints}
|
||||
testId="enforce-minimum-datapoints-input"
|
||||
/>
|
||||
<Typography.Text>Datapoints</Typography.Text>
|
||||
</div>
|
||||
|
||||
@@ -66,6 +66,7 @@ function EvaluationWindowPopover({
|
||||
tabIndex={0}
|
||||
data-value={option.value}
|
||||
data-section-id={sectionId}
|
||||
data-testid={`${sectionId}-option-${option.value}`}
|
||||
onClick={(): void => onChange(option.value)}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
|
||||
@@ -186,6 +186,7 @@ function Footer(): JSX.Element {
|
||||
color="primary"
|
||||
onClick={handleSaveAlert}
|
||||
disabled={disableButtons || Boolean(alertValidationMessage)}
|
||||
testId="save-alert-rule-button"
|
||||
>
|
||||
{isCreatingAlertRule || isUpdatingAlertRule ? (
|
||||
<Loader data-testid="save-alert-rule-loader-icon" size={14} />
|
||||
@@ -218,6 +219,7 @@ function Footer(): JSX.Element {
|
||||
color="secondary"
|
||||
onClick={handleTestNotification}
|
||||
disabled={disableButtons || Boolean(alertValidationMessage)}
|
||||
testId="test-notification-button"
|
||||
>
|
||||
{isTestingAlertRule ? (
|
||||
<Loader data-testid="test-notification-loader-icon" size={14} />
|
||||
@@ -249,6 +251,7 @@ function Footer(): JSX.Element {
|
||||
color="secondary"
|
||||
onClick={handleDiscard}
|
||||
disabled={disableButtons}
|
||||
testId="discard-alert-rule-button"
|
||||
>
|
||||
<X size={14} /> Discard
|
||||
</Button>
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
ChannelType,
|
||||
EmailChannel,
|
||||
GoogleChatChannel,
|
||||
JiraChannel,
|
||||
JsmOpsChannel,
|
||||
MsTeamsChannel,
|
||||
OpsgenieChannel,
|
||||
PagerChannel,
|
||||
@@ -34,7 +36,11 @@ import {
|
||||
} from 'container/CreateAlertChannels/config';
|
||||
import {
|
||||
isValidGoogleChatWebhookURL,
|
||||
isValidJiraReopenDuration,
|
||||
isValidJiraSiteURL,
|
||||
prepareGoogleChatRequest,
|
||||
prepareJiraRequest,
|
||||
prepareJsmOpsRequest,
|
||||
} from 'container/CreateAlertChannels/utils';
|
||||
import FormAlertChannels from 'container/FormAlertChannels';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
@@ -58,7 +64,9 @@ function EditAlertChannels({
|
||||
MsTeamsChannel &
|
||||
OpsgenieChannel &
|
||||
EmailChannel &
|
||||
GoogleChatChannel
|
||||
GoogleChatChannel &
|
||||
JiraChannel &
|
||||
JsmOpsChannel
|
||||
>
|
||||
>({
|
||||
...initialValue,
|
||||
@@ -452,6 +460,124 @@ function EditAlertChannels({
|
||||
t,
|
||||
]);
|
||||
|
||||
const validateJiraConfig = useCallback((): string => {
|
||||
if (
|
||||
!selectedConfig.site ||
|
||||
!selectedConfig.username ||
|
||||
!selectedConfig.password ||
|
||||
!selectedConfig.project ||
|
||||
!selectedConfig.issue_type
|
||||
) {
|
||||
return t('jira_required_fields');
|
||||
}
|
||||
|
||||
if (!isValidJiraSiteURL(selectedConfig.site)) {
|
||||
return t('jira_site_invalid');
|
||||
}
|
||||
|
||||
if (
|
||||
selectedConfig.reopen_duration &&
|
||||
!isValidJiraReopenDuration(selectedConfig.reopen_duration)
|
||||
) {
|
||||
return t('jira_reopen_duration_invalid');
|
||||
}
|
||||
|
||||
return '';
|
||||
}, [selectedConfig, t]);
|
||||
|
||||
const onJiraEditHandler = useCallback(async () => {
|
||||
const validationError = validateJiraConfig();
|
||||
|
||||
if (validationError !== '') {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: validationError,
|
||||
});
|
||||
return { status: 'failed', statusMessage: validationError };
|
||||
}
|
||||
|
||||
setSavingState(true);
|
||||
|
||||
try {
|
||||
await updateChannel({
|
||||
pathParams: { id },
|
||||
data: prepareJiraRequest(selectedConfig),
|
||||
});
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_edit_done'),
|
||||
});
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
return { status: 'success', statusMessage: t('channel_edit_done') };
|
||||
} catch (error) {
|
||||
const apiError = notifyError(error);
|
||||
return {
|
||||
status: 'failed',
|
||||
statusMessage: apiError.getErrorMessage() || t('channel_edit_failed'),
|
||||
};
|
||||
} finally {
|
||||
setSavingState(false);
|
||||
}
|
||||
}, [
|
||||
validateJiraConfig,
|
||||
updateChannel,
|
||||
id,
|
||||
selectedConfig,
|
||||
notifications,
|
||||
notifyError,
|
||||
t,
|
||||
]);
|
||||
|
||||
const validateJsmOpsConfig = useCallback((): string => {
|
||||
if (!selectedConfig.api_key) {
|
||||
return t('api_key_required');
|
||||
}
|
||||
return '';
|
||||
}, [selectedConfig, t]);
|
||||
|
||||
const onJsmOpsEditHandler = useCallback(async () => {
|
||||
const validationError = validateJsmOpsConfig();
|
||||
|
||||
if (validationError !== '') {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: validationError,
|
||||
});
|
||||
return { status: 'failed', statusMessage: validationError };
|
||||
}
|
||||
|
||||
setSavingState(true);
|
||||
|
||||
try {
|
||||
await updateChannel({
|
||||
pathParams: { id },
|
||||
data: prepareJsmOpsRequest(selectedConfig),
|
||||
});
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_edit_done'),
|
||||
});
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
return { status: 'success', statusMessage: t('channel_edit_done') };
|
||||
} catch (error) {
|
||||
const apiError = notifyError(error);
|
||||
return {
|
||||
status: 'failed',
|
||||
statusMessage: apiError.getErrorMessage() || t('channel_edit_failed'),
|
||||
};
|
||||
} finally {
|
||||
setSavingState(false);
|
||||
}
|
||||
}, [
|
||||
validateJsmOpsConfig,
|
||||
updateChannel,
|
||||
id,
|
||||
selectedConfig,
|
||||
notifications,
|
||||
notifyError,
|
||||
t,
|
||||
]);
|
||||
|
||||
const onSaveHandler = useCallback(
|
||||
async (value: ChannelType) => {
|
||||
let result;
|
||||
@@ -469,6 +595,10 @@ function EditAlertChannels({
|
||||
result = await onEmailEditHandler();
|
||||
} else if (value === ChannelType.GoogleChat) {
|
||||
result = await onGoogleChatEditHandler();
|
||||
} else if (value === ChannelType.Jira) {
|
||||
result = await onJiraEditHandler();
|
||||
} else if (value === ChannelType.JsmOps) {
|
||||
result = await onJsmOpsEditHandler();
|
||||
}
|
||||
logEvent('Alert Channel: Save channel', {
|
||||
type: value,
|
||||
@@ -488,10 +618,13 @@ function EditAlertChannels({
|
||||
onOpsgenieEditHandler,
|
||||
onEmailEditHandler,
|
||||
onGoogleChatEditHandler,
|
||||
onJiraEditHandler,
|
||||
onJsmOpsEditHandler,
|
||||
],
|
||||
);
|
||||
|
||||
const performChannelTest = useCallback(
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
async (channelType: ChannelType) => {
|
||||
setTestingState(true);
|
||||
try {
|
||||
@@ -542,6 +675,32 @@ function EditAlertChannels({
|
||||
await testChannel({ data: prepareGoogleChatRequest(selectedConfig) });
|
||||
break;
|
||||
}
|
||||
case ChannelType.Jira: {
|
||||
const validationError = validateJiraConfig();
|
||||
if (validationError !== '') {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: validationError,
|
||||
});
|
||||
setTestingState(false);
|
||||
return;
|
||||
}
|
||||
await testChannel({ data: prepareJiraRequest(selectedConfig) });
|
||||
break;
|
||||
}
|
||||
case ChannelType.JsmOps: {
|
||||
const validationError = validateJsmOpsConfig();
|
||||
if (validationError !== '') {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: validationError,
|
||||
});
|
||||
setTestingState(false);
|
||||
return;
|
||||
}
|
||||
await testChannel({ data: prepareJsmOpsRequest(selectedConfig) });
|
||||
break;
|
||||
}
|
||||
default:
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
@@ -579,6 +738,8 @@ function EditAlertChannels({
|
||||
t,
|
||||
notifyError,
|
||||
validateGoogleChatConfig,
|
||||
validateJiraConfig,
|
||||
validateJsmOpsConfig,
|
||||
testChannel,
|
||||
prepareWebhookRequest,
|
||||
preparePagerRequest,
|
||||
|
||||
@@ -54,7 +54,7 @@ import {
|
||||
} from 'container/OptionsMenu/constants';
|
||||
import { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
|
||||
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
|
||||
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useGetAllViews } from 'hooks/saveViews/useGetAllViews';
|
||||
import { useSaveView } from 'hooks/saveViews/useSaveView';
|
||||
@@ -287,8 +287,7 @@ function ExplorerOptions({
|
||||
|
||||
const compositeQuery = mapCompositeQueryFromQuery(currentQuery, panelType);
|
||||
|
||||
const viewName = useGetSearchQueryParam(QueryParams.viewName) || '';
|
||||
const viewKey = useGetSearchQueryParam(QueryParams.viewKey) || '';
|
||||
const { viewName, viewKey } = useGetSavedViewParams();
|
||||
|
||||
const extraData = viewsData?.data?.data?.find(
|
||||
(view) => view.id === viewKey,
|
||||
|
||||
242
frontend/src/container/FormAlertChannels/Settings/Jira.tsx
Normal file
242
frontend/src/container/FormAlertChannels/Settings/Jira.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Collapse, Form, Input, Select } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
|
||||
|
||||
import { JiraChannel } from '../../CreateAlertChannels/config';
|
||||
import {
|
||||
isValidJiraReopenDuration,
|
||||
isValidJiraSiteURL,
|
||||
} from '../../CreateAlertChannels/utils';
|
||||
|
||||
function JiraSettings({ setSelectedConfig }: JiraProps): JSX.Element {
|
||||
const { t } = useTranslation('channels');
|
||||
|
||||
const update = (patch: Partial<JiraChannel>): void =>
|
||||
setSelectedConfig((value) => ({ ...value, ...patch }));
|
||||
|
||||
const advanced = (
|
||||
<>
|
||||
<Form.Item
|
||||
name="priority"
|
||||
label={t('field_jira_priority')}
|
||||
help={t('help_jira_priority')}
|
||||
>
|
||||
<Input
|
||||
placeholder={t('placeholder_jira_priority')}
|
||||
onChange={(event): void => update({ priority: event.target.value })}
|
||||
data-testid="jira-priority-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="labels"
|
||||
label={t('field_jira_labels')}
|
||||
help={t('help_jira_labels')}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
open={false}
|
||||
placeholder={t('placeholder_jira_labels')}
|
||||
onChange={(value): void => update({ labels: value as string[] })}
|
||||
data-testid="jira-labels-select"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="resolve_transition"
|
||||
label={t('field_jira_resolve_transition')}
|
||||
help={t('help_jira_resolve_transition')}
|
||||
>
|
||||
<Input
|
||||
placeholder={t('placeholder_jira_resolve_transition')}
|
||||
onChange={(event): void =>
|
||||
update({ resolve_transition: event.target.value })
|
||||
}
|
||||
data-testid="jira-resolve-transition-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="reopen_transition"
|
||||
label={t('field_jira_reopen_transition')}
|
||||
help={t('help_jira_reopen_transition')}
|
||||
>
|
||||
<Input
|
||||
placeholder={t('placeholder_jira_reopen_transition')}
|
||||
onChange={(event): void =>
|
||||
update({ reopen_transition: event.target.value })
|
||||
}
|
||||
data-testid="jira-reopen-transition-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="reopen_duration"
|
||||
label={t('field_jira_reopen_duration')}
|
||||
extra={t('help_jira_reopen_duration')}
|
||||
rules={[
|
||||
{
|
||||
validator: (_, value: string): Promise<void> =>
|
||||
isValidJiraReopenDuration(value)
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error(t('jira_reopen_duration_invalid'))),
|
||||
},
|
||||
]}
|
||||
tooltip={{
|
||||
title: (
|
||||
<MarkdownRenderer
|
||||
markdownContent={t('tooltip_jira_reopen_duration')}
|
||||
variables={{}}
|
||||
/>
|
||||
),
|
||||
overlayInnerStyle: { maxWidth: 400 },
|
||||
placement: 'right',
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
placeholder={t('placeholder_jira_reopen_duration')}
|
||||
onChange={(event): void => update({ reopen_duration: event.target.value })}
|
||||
data-testid="jira-reopen-duration-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography.Text
|
||||
color="muted"
|
||||
size="sm"
|
||||
testId="jira-service-account-tip"
|
||||
style={{ display: 'block', marginBottom: 16 }}
|
||||
>
|
||||
{t('jira_service_account_tip')}{' '}
|
||||
<Typography.Link
|
||||
href="https://signoz.io/docs/alerts-management/notification-channel/jira/#use-a-service-account-recommended"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t('jira_service_account_tip_link')}
|
||||
</Typography.Link>
|
||||
</Typography.Text>
|
||||
|
||||
<Form.Item
|
||||
name="site"
|
||||
label={t('field_jira_site')}
|
||||
required
|
||||
rules={[
|
||||
{
|
||||
validator: (_, value: string): Promise<void> =>
|
||||
!value || isValidJiraSiteURL(value)
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error(t('jira_site_invalid'))),
|
||||
},
|
||||
]}
|
||||
tooltip={{
|
||||
title: (
|
||||
<MarkdownRenderer
|
||||
markdownContent={t('tooltip_jira_site')}
|
||||
variables={{}}
|
||||
/>
|
||||
),
|
||||
overlayInnerStyle: { maxWidth: 400 },
|
||||
placement: 'right',
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
placeholder="https://your-domain.atlassian.net"
|
||||
onChange={(event): void => update({ site: event.target.value })}
|
||||
data-testid="jira-site-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="username"
|
||||
label={t('field_jira_email')}
|
||||
help={t('help_jira_email')}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
onChange={(event): void => update({ username: event.target.value })}
|
||||
data-testid="jira-email-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
label={t('field_jira_api_token')}
|
||||
help={t('help_jira_api_token')}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
onChange={(event): void => update({ password: event.target.value })}
|
||||
data-testid="jira-api-token-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="project" label={t('field_jira_project')} required>
|
||||
<Input
|
||||
placeholder="e.g. OPS"
|
||||
onChange={(event): void => update({ project: event.target.value })}
|
||||
data-testid="jira-project-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="issue_type"
|
||||
label={t('field_jira_issue_type')}
|
||||
help={t('help_jira_issue_type')}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
onChange={(event): void => update({ issue_type: event.target.value })}
|
||||
data-testid="jira-issue-type-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="summary"
|
||||
label={t('field_jira_summary')}
|
||||
help={t('help_jira_summary')}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
onChange={(event): void => update({ summary: event.target.value })}
|
||||
data-testid="jira-summary-textarea"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="description"
|
||||
label={t('field_jira_description')}
|
||||
help={t('help_jira_description')}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={6}
|
||||
onChange={(event): void => update({ description: event.target.value })}
|
||||
data-testid="jira-description-textarea"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
items={[
|
||||
{
|
||||
key: 'advanced',
|
||||
label: t('jira_advanced_section'),
|
||||
children: advanced,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface JiraProps {
|
||||
setSelectedConfig: Dispatch<SetStateAction<Partial<JiraChannel>>>;
|
||||
}
|
||||
|
||||
export default JiraSettings;
|
||||
117
frontend/src/container/FormAlertChannels/Settings/JsmOps.tsx
Normal file
117
frontend/src/container/FormAlertChannels/Settings/JsmOps.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Collapse, Form, Input, Select } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { JsmOpsChannel } from '../../CreateAlertChannels/config';
|
||||
|
||||
function JsmOpsSettings({ setSelectedConfig }: JsmOpsProps): JSX.Element {
|
||||
const { t } = useTranslation('channels');
|
||||
|
||||
const update = (patch: Partial<JsmOpsChannel>): void =>
|
||||
setSelectedConfig((value) => ({ ...value, ...patch }));
|
||||
|
||||
const advanced = (
|
||||
<>
|
||||
<Form.Item
|
||||
name="priority"
|
||||
label={t('field_jsmops_priority')}
|
||||
help={t('help_jsmops_priority')}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
onChange={(event): void => update({ priority: event.target.value })}
|
||||
data-testid="jsmops-priority-textarea"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="tags"
|
||||
label={t('field_jsmops_tags')}
|
||||
help={t('help_jsmops_tags')}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
open={false}
|
||||
placeholder={t('placeholder_jsmops_tags')}
|
||||
onChange={(value): void => update({ tags: value as string[] })}
|
||||
data-testid="jsmops-tags-select"
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography.Text
|
||||
color="muted"
|
||||
size="sm"
|
||||
testId="jsmops-tip"
|
||||
style={{ display: 'block', marginBottom: 16 }}
|
||||
>
|
||||
{t('jsmops_tip')}{' '}
|
||||
<Typography.Link
|
||||
href="https://signoz.io/docs/alerts-management/notification-channel/jsm-ops/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t('jsmops_tip_link')}
|
||||
</Typography.Link>
|
||||
</Typography.Text>
|
||||
|
||||
<Form.Item
|
||||
name="api_key"
|
||||
label={t('field_jsmops_api_key')}
|
||||
help={t('help_jsmops_api_key')}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
onChange={(event): void => update({ api_key: event.target.value })}
|
||||
data-testid="jsmops-api-key-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="message"
|
||||
label={t('field_jsmops_message')}
|
||||
help={t('help_jsmops_message')}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
onChange={(event): void => update({ message: event.target.value })}
|
||||
data-testid="jsmops-message-textarea"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="description"
|
||||
label={t('field_jsmops_description')}
|
||||
help={t('help_jsmops_description')}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={6}
|
||||
onChange={(event): void => update({ description: event.target.value })}
|
||||
data-testid="jsmops-description-textarea"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
items={[
|
||||
{
|
||||
key: 'advanced',
|
||||
label: t('jsmops_advanced_section'),
|
||||
children: advanced,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface JsmOpsProps {
|
||||
setSelectedConfig: Dispatch<SetStateAction<Partial<JsmOpsChannel>>>;
|
||||
}
|
||||
|
||||
export default JsmOpsSettings;
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
ChannelType,
|
||||
EmailChannel,
|
||||
GoogleChatChannel,
|
||||
JiraChannel,
|
||||
JsmOpsChannel,
|
||||
OpsgenieChannel,
|
||||
PagerChannel,
|
||||
SlackChannel,
|
||||
@@ -19,6 +21,8 @@ import history from 'lib/history';
|
||||
|
||||
import EmailSettings from './Settings/Email';
|
||||
import GoogleChatSettings from './Settings/GoogleChat';
|
||||
import JiraSettings from './Settings/Jira';
|
||||
import JsmOpsSettings from './Settings/JsmOps';
|
||||
import MsTeamsSettings from './Settings/MsTeams';
|
||||
import OpsgenieSettings from './Settings/Opsgenie';
|
||||
import PagerSettings from './Settings/Pager';
|
||||
@@ -53,6 +57,10 @@ function FormAlertChannels({
|
||||
return <MsTeamsSettings setSelectedConfig={setSelectedConfig} />;
|
||||
case ChannelType.GoogleChat:
|
||||
return <GoogleChatSettings setSelectedConfig={setSelectedConfig} />;
|
||||
case ChannelType.Jira:
|
||||
return <JiraSettings setSelectedConfig={setSelectedConfig} />;
|
||||
case ChannelType.JsmOps:
|
||||
return <JsmOpsSettings setSelectedConfig={setSelectedConfig} />;
|
||||
case ChannelType.Opsgenie:
|
||||
return <OpsgenieSettings setSelectedConfig={setSelectedConfig} />;
|
||||
case ChannelType.Email:
|
||||
@@ -141,6 +149,14 @@ function FormAlertChannels({
|
||||
>
|
||||
Google Chat
|
||||
</Select.Option>
|
||||
|
||||
<Select.Option value="jira" key="jira" data-testid="select-option">
|
||||
Jira
|
||||
</Select.Option>
|
||||
|
||||
<Select.Option value="jsmops" key="jsmops" data-testid="select-option">
|
||||
Jira Service Management Ops
|
||||
</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
@@ -189,7 +205,9 @@ interface FormAlertChannelsProps {
|
||||
PagerChannel &
|
||||
OpsgenieChannel &
|
||||
EmailChannel &
|
||||
GoogleChatChannel
|
||||
GoogleChatChannel &
|
||||
JiraChannel &
|
||||
JsmOpsChannel
|
||||
>
|
||||
>
|
||||
>;
|
||||
|
||||
@@ -119,6 +119,7 @@ function BasicInfo({
|
||||
<SeveritySelect
|
||||
getPopupContainer={popupContainer}
|
||||
defaultValue="critical"
|
||||
data-testid="alert-severity-select"
|
||||
onChange={(value: unknown | string): void => {
|
||||
const s = (value as string) || 'critical';
|
||||
setAlertDef({
|
||||
@@ -147,6 +148,7 @@ function BasicInfo({
|
||||
]}
|
||||
>
|
||||
<InputSmall
|
||||
data-testid="alert-name-input-v1"
|
||||
onChange={(e): void => {
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
@@ -161,6 +163,7 @@ function BasicInfo({
|
||||
name={['annotations', 'description']}
|
||||
>
|
||||
<TextareaMedium
|
||||
data-testid="alert-description-input"
|
||||
onChange={(e): void => {
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
|
||||
@@ -105,7 +105,7 @@ function QuerySection({
|
||||
{
|
||||
label: (
|
||||
<Tooltip title="Query Builder">
|
||||
<Button className="nav-btns">
|
||||
<Button className="nav-btns" data-testid="query-builder-tab">
|
||||
<Atom size={14} />
|
||||
<Typography.Text>Query Builder</Typography.Text>
|
||||
</Button>
|
||||
@@ -122,7 +122,11 @@ function QuerySection({
|
||||
: 'ClickHouse'
|
||||
}
|
||||
>
|
||||
<Button className="nav-btns" disabled={isAnomalyDetection}>
|
||||
<Button
|
||||
className="nav-btns"
|
||||
disabled={isAnomalyDetection}
|
||||
data-testid="clickhouse-tab"
|
||||
>
|
||||
<Terminal size={14} />
|
||||
<Typography.Text>ClickHouse Query</Typography.Text>
|
||||
</Button>
|
||||
@@ -162,7 +166,11 @@ function QuerySection({
|
||||
: 'ClickHouse'
|
||||
}
|
||||
>
|
||||
<Button className="nav-btns" disabled={isAnomalyDetection}>
|
||||
<Button
|
||||
className="nav-btns"
|
||||
disabled={isAnomalyDetection}
|
||||
data-testid="clickhouse-tab"
|
||||
>
|
||||
<Terminal size={14} />
|
||||
<Typography.Text>ClickHouse Query</Typography.Text>
|
||||
</Button>
|
||||
@@ -180,7 +188,11 @@ function QuerySection({
|
||||
: 'PromQL'
|
||||
}
|
||||
>
|
||||
<Button className="nav-btns" disabled={isAnomalyDetection}>
|
||||
<Button
|
||||
className="nav-btns"
|
||||
disabled={isAnomalyDetection}
|
||||
data-testid="promql-tab"
|
||||
>
|
||||
<PromQLIcon
|
||||
fillColor={isDarkMode ? Color.BG_VANILLA_200 : Color.BG_INK_300}
|
||||
/>
|
||||
|
||||
@@ -80,6 +80,7 @@ function RuleOptions({
|
||||
defaultValue={defaultCompareOp}
|
||||
value={alertDef.condition?.op}
|
||||
style={{ minWidth: '120px' }}
|
||||
data-testid="alert-threshold-op-select"
|
||||
onChange={(value: string | unknown): void => {
|
||||
const newOp = (value as string) || '';
|
||||
|
||||
@@ -116,6 +117,7 @@ function RuleOptions({
|
||||
defaultValue={defaultMatchType}
|
||||
style={{ minWidth: '130px' }}
|
||||
value={alertDef.condition?.matchType}
|
||||
data-testid="alert-threshold-match-type-select-v1"
|
||||
onChange={(value: string | unknown): void => handleMatchOptChange(value)}
|
||||
>
|
||||
<Select.Option value="1">{t('option_atleastonce')}</Select.Option>
|
||||
@@ -177,6 +179,7 @@ function RuleOptions({
|
||||
style={{ minWidth: '120px' }}
|
||||
value={alertDef.evalWindow}
|
||||
onChange={onChangeEvalWindow}
|
||||
data-testid="alert-eval-window-select"
|
||||
>
|
||||
<Select.Option value="5m0s">{t('option_5min')}</Select.Option>
|
||||
<Select.Option value="10m0s">{t('option_10min')}</Select.Option>
|
||||
@@ -194,6 +197,7 @@ function RuleOptions({
|
||||
style={{ minWidth: '120px' }}
|
||||
value={alertDef.evalWindow}
|
||||
onChange={onChangeEvalWindow}
|
||||
data-testid="alert-eval-window-select"
|
||||
>
|
||||
<Select.Option value="5m0s">{t('option_5min')}</Select.Option>
|
||||
<Select.Option value="10m0s">{t('option_10min')}</Select.Option>
|
||||
@@ -395,6 +399,7 @@ function RuleOptions({
|
||||
value={alertDef?.condition?.target}
|
||||
onChange={onChange}
|
||||
type="number"
|
||||
data-testid="alert-threshold-target-input"
|
||||
onWheel={(e): void => e.currentTarget.blur()}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -844,8 +844,6 @@ function FormAlertRules({
|
||||
|
||||
return (
|
||||
<>
|
||||
{Element}
|
||||
|
||||
<div
|
||||
id="top"
|
||||
className={`form-alert-rules-container ${
|
||||
@@ -968,6 +966,7 @@ function FormAlertRules({
|
||||
!isChannelConfigurationValid ||
|
||||
queryStatus === 'error'
|
||||
}
|
||||
data-testid="alert-save-button"
|
||||
>
|
||||
{isNewRule ? t('button_createrule') : t('button_savechanges')}
|
||||
</ActionButton>
|
||||
@@ -981,6 +980,7 @@ function FormAlertRules({
|
||||
}
|
||||
type="default"
|
||||
onClick={onTestRuleHandler}
|
||||
data-testid="alert-test-button"
|
||||
>
|
||||
{' '}
|
||||
{t('button_testrule')}
|
||||
@@ -989,6 +989,7 @@ function FormAlertRules({
|
||||
disabled={loading || false}
|
||||
type="default"
|
||||
onClick={onCancelHandler}
|
||||
data-testid="alert-cancel-button"
|
||||
>
|
||||
{isNewRule && t('button_cancelchanges')}
|
||||
{ruleId && !isEmpty(ruleId) && t('button_discard')}
|
||||
@@ -998,6 +999,7 @@ function FormAlertRules({
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
testId="alert-save-confirm-dialog"
|
||||
open={isConfirmSaveOpen}
|
||||
onOpenChange={setIsConfirmSaveOpen}
|
||||
title={t('confirm_save_title')}
|
||||
|
||||
@@ -174,6 +174,7 @@ function LabelSelect({
|
||||
|
||||
<div style={{ display: 'flex', width: '100%' }}>
|
||||
<Input
|
||||
data-testid="alert-labels-input-v1"
|
||||
placeholder={renderPlaceholder()}
|
||||
onChange={handleLabelChange}
|
||||
onKeyUp={(e): void => {
|
||||
|
||||
@@ -53,17 +53,24 @@ export const getUpdatedStepInterval = (evalWindow?: string): number => {
|
||||
};
|
||||
|
||||
export const getSelectedQueryOptions = (
|
||||
queries: Array<
|
||||
| IBuilderQuery
|
||||
| IBuilderTraceOperator
|
||||
| IBuilderFormula
|
||||
| IClickHouseQuery
|
||||
| IPromQLQuery
|
||||
>,
|
||||
): SelectProps['options'] =>
|
||||
queries
|
||||
queries:
|
||||
| Array<
|
||||
| IBuilderQuery
|
||||
| IBuilderTraceOperator
|
||||
| IBuilderFormula
|
||||
| IClickHouseQuery
|
||||
| IPromQLQuery
|
||||
>
|
||||
| undefined
|
||||
| null,
|
||||
): SelectProps['options'] => {
|
||||
if (!queries) {
|
||||
return [];
|
||||
}
|
||||
return queries
|
||||
.filter((query) => !query.disabled)
|
||||
.map((query) => ({
|
||||
label: 'queryName' in query ? query.queryName : query.name,
|
||||
value: 'queryName' in query ? query.queryName : query.name,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -4,8 +4,9 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import styles from './ColumnHeader.module.scss';
|
||||
import cx from 'classnames';
|
||||
import { MouseEventHandler } from 'react';
|
||||
import { DOCS_BASE_URL } from 'constants/app';
|
||||
|
||||
const DOCS_BASE_URL = `${process.env.DOCS_BASE_URL}/docs`;
|
||||
const DOCS_ROOT = `${DOCS_BASE_URL}/docs`;
|
||||
|
||||
interface ColumnHeaderProps {
|
||||
children?: React.ReactNode;
|
||||
@@ -43,7 +44,7 @@ function ColumnHeader({
|
||||
<div onClick={stopPropagationHandler}>
|
||||
{tooltipTitle}{' '}
|
||||
<a
|
||||
href={`${DOCS_BASE_URL}${docPath}`}
|
||||
href={`${DOCS_ROOT}${docPath}`}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
onClick={stopPropagationHandler}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-5);
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.infoIcon {
|
||||
|
||||
@@ -2,8 +2,9 @@ import { Group, Info } from '@signozhq/icons';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
|
||||
import styles from './EntityGroupHeader.module.scss';
|
||||
import { DOCS_BASE_URL } from 'constants/app';
|
||||
|
||||
const DOCS_BASE_URL = `${process.env.DOCS_BASE_URL}/docs`;
|
||||
const DOCS_ROOT = `${DOCS_BASE_URL}/docs`;
|
||||
|
||||
interface EntityGroupHeaderProps {
|
||||
title: string;
|
||||
@@ -28,7 +29,7 @@ function EntityGroupHeader({
|
||||
<>
|
||||
{tooltipTitle}{' '}
|
||||
<a
|
||||
href={`${DOCS_BASE_URL}${docPath}`}
|
||||
href={`${DOCS_ROOT}${docPath}`}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Copy, X } from '@signozhq/icons';
|
||||
import { X } from '@signozhq/icons';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DrawerWrapper, DrawerWrapperProps } from '@signozhq/ui/drawer';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
|
||||
@@ -20,6 +18,7 @@ import {
|
||||
|
||||
import { INFRA_MONITORING_K8S_PARAMS_KEYS } from '../constants';
|
||||
import { useInfraMonitoringSelectedItemParams } from '../hooks';
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
import LoadingContainer from '../LoadingContainer';
|
||||
|
||||
import K8sBaseDetailsContent from './K8sBaseDetailsContent';
|
||||
@@ -95,12 +94,14 @@ export default function K8sBaseDetails<T>({
|
||||
selectedItem,
|
||||
selectedItemParams.clusterName,
|
||||
selectedItemParams.namespaceName,
|
||||
selectedItemParams.containerName,
|
||||
),
|
||||
[
|
||||
queryKeyPrefix,
|
||||
selectedItem,
|
||||
selectedItemParams.clusterName,
|
||||
selectedItemParams.namespaceName,
|
||||
selectedItemParams.containerName,
|
||||
selectedTime,
|
||||
getAutoRefreshQueryKey,
|
||||
],
|
||||
@@ -170,14 +171,9 @@ export default function K8sBaseDetails<T>({
|
||||
[handleClose],
|
||||
);
|
||||
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const handleCopyId = useCallback((): void => {
|
||||
if (selectedItem) {
|
||||
copyToClipboard(selectedItem);
|
||||
toast.success('ID copied to clipboard', { position: 'bottom-left' });
|
||||
}
|
||||
}, [copyToClipboard, selectedItem]);
|
||||
toast.success('ID copied to clipboard', { position: 'bottom-left' });
|
||||
}, []);
|
||||
|
||||
const entityName = entity ? getEntityName(entity) : '';
|
||||
|
||||
@@ -211,17 +207,13 @@ export default function K8sBaseDetails<T>({
|
||||
(isEntityLoading && 'Loading...') ||
|
||||
'-'}
|
||||
</Typography.Text>
|
||||
<TooltipSimple title="Copy ID">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
onClick={handleCopyId}
|
||||
data-testid="copy-id-button"
|
||||
>
|
||||
<Copy size={14} />
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
<CopyButton
|
||||
value={selectedItem ?? ''}
|
||||
ariaLabel="Copy ID"
|
||||
className={styles.copyIdButton}
|
||||
testId="copy-id-button"
|
||||
onCopy={handleCopyId}
|
||||
/>
|
||||
</>
|
||||
) as unknown as string;
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
@@ -42,6 +41,7 @@ import {
|
||||
} from '../hooks';
|
||||
|
||||
import { EntityCountsSection } from './components/EntityCountsSection/EntityCountsSection';
|
||||
import { EntityMetadataItem } from './components/EntityMetadataItem/EntityMetadataItem';
|
||||
import { K8sBaseDetailsContentProps } from './types';
|
||||
import { getDrawerDurationMs } from './useDrawerLifecycleStore';
|
||||
|
||||
@@ -239,41 +239,18 @@ export default function K8sBaseDetailsContent<T>({
|
||||
<>
|
||||
<div className={styles.entityDetailsEntity}>
|
||||
<div className={styles.entityDetailsGrid}>
|
||||
<div className={styles.labelsRow}>
|
||||
{metadataConfig.map((config) => (
|
||||
<Typography.Text
|
||||
{metadataConfig.map((config) => {
|
||||
const value = config.getValue(entity);
|
||||
|
||||
return (
|
||||
<EntityMetadataItem
|
||||
key={config.label}
|
||||
color="muted"
|
||||
size="small"
|
||||
weight="medium"
|
||||
className={styles.entityDetailsMetadataLabel}
|
||||
>
|
||||
{config.label}
|
||||
</Typography.Text>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.valuesRow}>
|
||||
{metadataConfig.map((config) => {
|
||||
const value = config.getValue(entity);
|
||||
|
||||
if (config.render) {
|
||||
return config.render(value, entity);
|
||||
}
|
||||
|
||||
const displayValue = String(value);
|
||||
return (
|
||||
<Typography.Text
|
||||
key={config.label}
|
||||
size="small"
|
||||
weight="medium"
|
||||
className={styles.entityDetailsMetadataValue}
|
||||
>
|
||||
{displayValue}
|
||||
</Typography.Text>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
label={config.label}
|
||||
value={String(value)}
|
||||
renderedValue={config.render?.(value, entity)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{countsConfig &&
|
||||
|
||||
@@ -296,6 +296,7 @@ export function K8sBaseList<
|
||||
params.selectedItem,
|
||||
params.clusterName,
|
||||
params.namespaceName,
|
||||
params.containerName,
|
||||
);
|
||||
queryClient.setQueryData(detailQueryKey, { data: record });
|
||||
}
|
||||
@@ -348,6 +349,12 @@ export function K8sBaseList<
|
||||
params.namespaceName,
|
||||
);
|
||||
}
|
||||
if (params.containerName) {
|
||||
url.searchParams.set(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME,
|
||||
params.containerName,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
url.searchParams.set(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM,
|
||||
|
||||
@@ -19,6 +19,12 @@
|
||||
& [data-hide-expanded='true'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// The icon slot is rendered even when the icon inside it is hidden, and the
|
||||
// header's flex gap then indents the title past the values below it.
|
||||
& [data-slot='icon']:has([data-hide-expanded='true']) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.expandedTableFooter {
|
||||
|
||||
@@ -214,6 +214,7 @@ export function K8sExpandedRow<
|
||||
params.selectedItem,
|
||||
params.clusterName,
|
||||
params.namespaceName,
|
||||
params.containerName,
|
||||
);
|
||||
queryClient.setQueryData(detailQueryKey, { data: row });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
.metadataItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.valueRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
letter-spacing: 0.44px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
// Single-line ellipsis rather than Typography's `truncate`, which line-clamps:
|
||||
// clamping still wraps the text, so a value breaking at a hyphen ends its line
|
||||
// early and leaves a gap between the ellipsis and the copy button.
|
||||
//
|
||||
// This has to be a block: overflow and text-overflow do not apply to inline
|
||||
// boxes, and Typography.Text renders an inline span.
|
||||
.value {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.valueText {
|
||||
font-family: var(--periscope-font-family-mono);
|
||||
}
|
||||
|
||||
// Sized to the icon rather than the default 2rem tap target, so it sits flush
|
||||
// against the value instead of floating in its own block of padding.
|
||||
.copyButton {
|
||||
--button-padding: 2px;
|
||||
--button-height: auto;
|
||||
--button-width: auto;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { ReactNode, useCallback } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
|
||||
import styles from './EntityMetadataItem.module.scss';
|
||||
|
||||
export interface EntityMetadataItemProps {
|
||||
label: string;
|
||||
value: string;
|
||||
/** An entity-supplied renderer, which opts out of clamping, tooltip and copy. */
|
||||
renderedValue?: ReactNode;
|
||||
}
|
||||
|
||||
export function EntityMetadataItem({
|
||||
label,
|
||||
value,
|
||||
renderedValue,
|
||||
}: EntityMetadataItemProps): JSX.Element {
|
||||
const handleCopy = useCallback((): void => {
|
||||
toast.success(`${label} copied to clipboard`, { position: 'bottom-left' });
|
||||
}, [label]);
|
||||
|
||||
return (
|
||||
<div className={styles.metadataItem}>
|
||||
<Typography.Text
|
||||
color="muted"
|
||||
size="small"
|
||||
weight="medium"
|
||||
className={styles.label}
|
||||
>
|
||||
{label}
|
||||
</Typography.Text>
|
||||
|
||||
{renderedValue ?? (
|
||||
<div className={styles.valueRow}>
|
||||
<TooltipSimple title={value} arrow side="bottom" align="start">
|
||||
<span className={styles.value}>
|
||||
<Typography.Text
|
||||
size="small"
|
||||
weight="medium"
|
||||
className={styles.valueText}
|
||||
>
|
||||
{value}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
{!!value && (
|
||||
<CopyButton
|
||||
value={value}
|
||||
size={10}
|
||||
ariaLabel={`Copy ${label}`}
|
||||
className={styles.copyButton}
|
||||
testId={`copy-metadata-${label.toLowerCase().replace(/\s+/g, '-')}`}
|
||||
onCopy={handleCopy}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { EntityMetadataItem } from '../EntityMetadataItem';
|
||||
|
||||
const mockCopyToClipboard = jest.fn();
|
||||
|
||||
jest.mock('react-use', () => ({
|
||||
__esModule: true,
|
||||
useCopyToClipboard: (): [unknown, jest.Mock] => [null, mockCopyToClipboard],
|
||||
}));
|
||||
|
||||
const mockToastSuccess = jest.fn();
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: {
|
||||
success: (...args: unknown[]): unknown => mockToastSuccess(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('EntityMetadataItem', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the label and its value', () => {
|
||||
render(<EntityMetadataItem label="Cluster Name" value="prod-cluster" />);
|
||||
|
||||
expect(screen.getByText('Cluster Name')).toBeInTheDocument();
|
||||
expect(screen.getByText('prod-cluster')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('copies the full value and confirms which field was copied', async () => {
|
||||
render(
|
||||
<EntityMetadataItem
|
||||
label="Image:Tag"
|
||||
value="ghcr.io/open-telemetry/demo:1.12.0-loadgenerator"
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByTestId('copy-metadata-image:tag'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith(
|
||||
'ghcr.io/open-telemetry/demo:1.12.0-loadgenerator',
|
||||
);
|
||||
});
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith(
|
||||
'Image:Tag copied to clipboard',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('offers no copy control when the value is empty', () => {
|
||||
render(<EntityMetadataItem label="Node" value="" />);
|
||||
|
||||
expect(screen.queryByTestId('copy-metadata-node')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exposes the full value on hover', async () => {
|
||||
render(
|
||||
<EntityMetadataItem
|
||||
label="Node"
|
||||
value="gke-mgmt-pl-generator-e2st4-sp-41c1bdc8-zv4t"
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.hover(
|
||||
screen.getByText('gke-mgmt-pl-generator-e2st4-sp-41c1bdc8-zv4t'),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getAllByText('gke-mgmt-pl-generator-e2st4-sp-41c1bdc8-zv4t').length,
|
||||
).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('never presents the value as clickable', () => {
|
||||
render(<EntityMetadataItem label="Node" value="a-very-long-node-name" />);
|
||||
|
||||
const valueEl = screen.getByText('a-very-long-node-name');
|
||||
expect(valueEl).not.toHaveAttribute('data-interactive');
|
||||
expect(valueEl).not.toHaveAttribute('data-truncate');
|
||||
});
|
||||
|
||||
it('triggers the tooltip from the wrapper, never from the text itself', () => {
|
||||
render(<EntityMetadataItem label="Node" value="a-very-long-node-name" />);
|
||||
|
||||
// Radix merges its handlers onto the trigger, and Typography styles
|
||||
// itself interactive off any merged onClick — so the trigger has to stay
|
||||
// off the text.
|
||||
const textEl = screen.getByText('a-very-long-node-name');
|
||||
expect(textEl).not.toHaveAttribute('data-slot', 'tooltip-trigger');
|
||||
expect(textEl.parentElement).toHaveAttribute('data-slot', 'tooltip-trigger');
|
||||
});
|
||||
|
||||
it('leaves an entity-supplied renderer alone', () => {
|
||||
render(
|
||||
<EntityMetadataItem
|
||||
label="Status"
|
||||
value="running"
|
||||
renderedValue={<span data-testid="custom">custom node</span>}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('custom')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('copy-metadata-status')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { jobEntityConfig } from '../Jobs/entity.config';
|
||||
import { daemonSetEntityConfig } from '../DaemonSets/entity.config';
|
||||
import { statefulSetEntityConfig } from '../StatefulSets/entity.config';
|
||||
import { volumeEntityConfig } from '../Volumes/entity.config';
|
||||
import { containerEntityConfig } from '../Containers/entity.config';
|
||||
|
||||
type AnyEntityConfig = K8sEntityConfig<
|
||||
K8sEntityData,
|
||||
@@ -34,6 +35,7 @@ export const entityRegistry: Record<string, AnyEntityConfig> = {
|
||||
[K8sCategories.DAEMONSETS]: registerConfig(daemonSetEntityConfig),
|
||||
[K8sCategories.STATEFULSETS]: registerConfig(statefulSetEntityConfig),
|
||||
[K8sCategories.VOLUMES]: registerConfig(volumeEntityConfig),
|
||||
[K8sCategories.CONTAINERS]: registerConfig(containerEntityConfig),
|
||||
};
|
||||
|
||||
export function getEntityConfig(category: string): AnyEntityConfig | undefined {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { InframonitoringtypesContainerRecordDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
k8sContainerGetSelectedItemExpression,
|
||||
k8sContainerInitialEventsExpression,
|
||||
k8sContainerInitialLogTracesExpression,
|
||||
} from 'container/InfraMonitoringK8sV2/Containers/constants';
|
||||
import { getContainerMetricsQueryPayload } from 'container/InfraMonitoringK8sV2/Containers/metrics';
|
||||
import {
|
||||
getK8sContainerItemKey,
|
||||
getK8sContainerRowKey,
|
||||
} from 'container/InfraMonitoringK8sV2/Containers/table.config';
|
||||
import { getContainerImageWithTag } from 'container/InfraMonitoringK8sV2/Containers/utils';
|
||||
|
||||
function makeContainer(
|
||||
overrides: Partial<InframonitoringtypesContainerRecordDTO> = {},
|
||||
): InframonitoringtypesContainerRecordDTO {
|
||||
return {
|
||||
containerName: 'nginx',
|
||||
podUID: 'pod-uid-1',
|
||||
meta: {
|
||||
'k8s.container.name': 'nginx',
|
||||
'k8s.pod.uid': 'pod-uid-1',
|
||||
'k8s.pod.name': 'web-0',
|
||||
'k8s.namespace.name': 'production',
|
||||
'k8s.cluster.name': 'prod-cluster',
|
||||
'container.image.name': 'nginx',
|
||||
'container.image.tag': '1.27',
|
||||
},
|
||||
...overrides,
|
||||
} as InframonitoringtypesContainerRecordDTO;
|
||||
}
|
||||
|
||||
describe('container identity', () => {
|
||||
it('keys a row by the (pod UID, container name) pair', () => {
|
||||
expect(getK8sContainerRowKey(makeContainer())).toBe('pod-uid-1/nginx');
|
||||
});
|
||||
|
||||
it('carries the container name alongside the pod UID into the drawer params', () => {
|
||||
expect(getK8sContainerItemKey(makeContainer())).toStrictEqual({
|
||||
selectedItem: 'pod-uid-1',
|
||||
containerName: 'nginx',
|
||||
clusterName: null,
|
||||
namespaceName: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to meta when the record fields are empty', () => {
|
||||
const container = makeContainer({ containerName: '', podUID: '' });
|
||||
|
||||
expect(getK8sContainerItemKey(container)).toStrictEqual({
|
||||
selectedItem: 'pod-uid-1',
|
||||
containerName: 'nginx',
|
||||
clusterName: null,
|
||||
namespaceName: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('scopes the details fetch to both halves of the identity', () => {
|
||||
expect(
|
||||
k8sContainerGetSelectedItemExpression({
|
||||
selectedItem: 'pod-uid-1',
|
||||
containerName: 'nginx',
|
||||
}),
|
||||
).toBe("k8s.pod.uid = 'pod-uid-1' AND k8s.container.name = 'nginx'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContainerImageWithTag', () => {
|
||||
it('renders name and tag together', () => {
|
||||
expect(getContainerImageWithTag(makeContainer())).toBe('nginx:1.27');
|
||||
});
|
||||
|
||||
it('drops the tag when the image is not pinned', () => {
|
||||
const container = makeContainer({
|
||||
meta: { 'container.image.name': 'nginx' },
|
||||
});
|
||||
|
||||
expect(getContainerImageWithTag(container)).toBe('nginx');
|
||||
});
|
||||
|
||||
it('renders nothing when the image name is missing', () => {
|
||||
expect(getContainerImageWithTag(makeContainer({ meta: {} }))).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('container drawer expressions', () => {
|
||||
it('scopes logs and traces to the container within its pod', () => {
|
||||
expect(k8sContainerInitialLogTracesExpression(makeContainer())).toBe(
|
||||
"k8s.pod.uid = 'pod-uid-1' AND k8s.cluster.name = 'prod-cluster' AND k8s.namespace.name = 'production' AND k8s.container.name = 'nginx'",
|
||||
);
|
||||
});
|
||||
|
||||
it('scopes events to the pod, since k8s emits events per pod', () => {
|
||||
expect(k8sContainerInitialEventsExpression(makeContainer())).toBe(
|
||||
"k8s.object.kind = 'Pod' AND k8s.object.name = 'web-0' AND k8s.cluster.name = 'prod-cluster' AND attribute.k8s.namespace.name = 'production'",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContainerMetricsQueryPayload', () => {
|
||||
const payloads = getContainerMetricsQueryPayload(makeContainer(), 1000, 2000);
|
||||
|
||||
it('returns one payload per documented chart', () => {
|
||||
expect(payloads).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('scopes every query to the selected container', () => {
|
||||
payloads.forEach((payload) => {
|
||||
payload.query.builder.queryData.forEach((query) => {
|
||||
expect(query.filters?.items).toStrictEqual([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'k8s.pod.uid' }),
|
||||
op: '=',
|
||||
value: 'pod-uid-1',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'k8s.container.name' }),
|
||||
op: '=',
|
||||
value: 'nginx',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('derives cache memory from the working set and RSS queries', () => {
|
||||
const memoryByState = payloads[4];
|
||||
|
||||
expect(
|
||||
memoryByState.query.builder.queryData.map((query) => [
|
||||
query.queryName,
|
||||
query.aggregateAttribute?.key,
|
||||
]),
|
||||
).toStrictEqual([
|
||||
['A', 'container.memory.rss'],
|
||||
['B', 'container.memory.working_set'],
|
||||
]);
|
||||
expect(memoryByState.query.builder.queryFormulas).toStrictEqual([
|
||||
expect.objectContaining({ expression: 'B - A', legend: 'Cache Memory' }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { InframonitoringtypesContainerRecordDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
|
||||
|
||||
import {
|
||||
buildEventsExpression,
|
||||
buildLogsTracesExpression,
|
||||
} from '../Base/utils';
|
||||
import { K8sDetailsMetadataConfig, K8sDetailsWidgetInfo } from '../Base/types';
|
||||
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
|
||||
import { SelectedItemParams } from '../hooks';
|
||||
import {
|
||||
CONTAINERS_DOC_PATH,
|
||||
getContainerImageWithTag,
|
||||
getContainerName,
|
||||
getContainerPodUID,
|
||||
} from './utils';
|
||||
|
||||
/** A container row is identified by the (pod UID, container name) pair. */
|
||||
export const k8sContainerGetSelectedItemExpression = (
|
||||
params: SelectedItemParams,
|
||||
): string =>
|
||||
[
|
||||
`${INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID} = ${formatValueForExpression(
|
||||
params.selectedItem ?? '',
|
||||
)}`,
|
||||
`${INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME} = ${formatValueForExpression(
|
||||
params.containerName ?? '',
|
||||
)}`,
|
||||
].join(' AND ');
|
||||
|
||||
export const k8sContainerGetEntityName = getContainerName;
|
||||
|
||||
export const k8sContainerDetailsMetadataConfig: K8sDetailsMetadataConfig<InframonitoringtypesContainerRecordDTO>[] =
|
||||
[
|
||||
{
|
||||
label: 'Pod',
|
||||
getValue: (c): string =>
|
||||
c.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME] || '',
|
||||
},
|
||||
{
|
||||
label: 'NAMESPACE',
|
||||
getValue: (c): string =>
|
||||
c.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] || '',
|
||||
},
|
||||
{
|
||||
label: 'Node',
|
||||
getValue: (c): string =>
|
||||
c.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME] || '',
|
||||
},
|
||||
{
|
||||
label: 'Cluster Name',
|
||||
getValue: (c): string =>
|
||||
c.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] || '',
|
||||
},
|
||||
{
|
||||
label: 'Image:Tag',
|
||||
getValue: getContainerImageWithTag,
|
||||
},
|
||||
];
|
||||
|
||||
export const k8sContainerInitialLogTracesExpression = (
|
||||
container: InframonitoringtypesContainerRecordDTO,
|
||||
): string => {
|
||||
const base = buildLogsTracesExpression({
|
||||
mainAttributeKey: INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID,
|
||||
mainAttributeValue: getContainerPodUID(container),
|
||||
clusterName: container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME],
|
||||
namespaceName:
|
||||
container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME],
|
||||
});
|
||||
|
||||
const containerName = getContainerName(container);
|
||||
if (!containerName) {
|
||||
return base;
|
||||
}
|
||||
|
||||
const containerClause = `${
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME
|
||||
} = ${formatValueForExpression(containerName)}`;
|
||||
|
||||
return base ? `${base} AND ${containerClause}` : containerClause;
|
||||
};
|
||||
|
||||
/**
|
||||
* Kubernetes emits events against the pod, not the container, so the events tab
|
||||
* is scoped to the container's pod.
|
||||
*/
|
||||
export const k8sContainerInitialEventsExpression = (
|
||||
container: InframonitoringtypesContainerRecordDTO,
|
||||
): string =>
|
||||
buildEventsExpression({
|
||||
objectKind: 'Pod',
|
||||
objectName: container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME] || '',
|
||||
clusterName: container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME],
|
||||
namespaceName:
|
||||
container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME],
|
||||
});
|
||||
|
||||
export const containerWidgetInfo: K8sDetailsWidgetInfo[] = [
|
||||
{
|
||||
title: 'CPU Usage (cores)',
|
||||
yAxisUnit: '',
|
||||
docPath: `${CONTAINERS_DOC_PATH}#cpu-usage-cores-1`,
|
||||
description:
|
||||
'Avg, max and min CPU consumption of the container in cores, showing how bursty it is.',
|
||||
},
|
||||
{
|
||||
title: 'CPU Request, Limit Utilization',
|
||||
yAxisUnit: 'percentunit',
|
||||
docPath: `${CONTAINERS_DOC_PATH}#cpu-request-limit-utilization`,
|
||||
description:
|
||||
'Container CPU usage as a fraction of its own CPU request and limit; limit lines near 100% mean throttling.',
|
||||
},
|
||||
{
|
||||
title: 'Memory Usage (bytes)',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath: `${CONTAINERS_DOC_PATH}#memory-usage-bytes`,
|
||||
description:
|
||||
'Total memory charged to the container, including reclaimable page cache, against the headroom left before its limit.',
|
||||
},
|
||||
{
|
||||
title: 'Memory Request, Limit Utilization',
|
||||
yAxisUnit: 'percentunit',
|
||||
docPath: `${CONTAINERS_DOC_PATH}#memory-request-limit-utilization`,
|
||||
description:
|
||||
'Container memory usage as a fraction of its own memory request and limit; limit lines near 100% risk an OOMKill.',
|
||||
},
|
||||
{
|
||||
title: 'Memory by State',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath: `${CONTAINERS_DOC_PATH}#memory-by-state`,
|
||||
description:
|
||||
'RSS, working set and cache memory of the container, separating heap growth from file cache.',
|
||||
},
|
||||
{
|
||||
title: 'Memory Major Page Faults',
|
||||
yAxisUnit: '',
|
||||
docPath: `${CONTAINERS_DOC_PATH}#memory-major-page-faults`,
|
||||
description:
|
||||
'Major page fault rate of the container; sustained values mean the working set is paging to disk.',
|
||||
},
|
||||
{
|
||||
title: 'File System (bytes)',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath: `${CONTAINERS_DOC_PATH}#file-system-bytes`,
|
||||
description:
|
||||
'Capacity, available and used bytes of the container filesystem.',
|
||||
},
|
||||
{
|
||||
title: 'Container Uptime',
|
||||
yAxisUnit: 's',
|
||||
docPath: `${CONTAINERS_DOC_PATH}#container-uptime`,
|
||||
description:
|
||||
'Time since the container last started; a sawtooth of resets means it is restarting repeatedly.',
|
||||
},
|
||||
{
|
||||
title: 'Node CPU Utilization by Container',
|
||||
yAxisUnit: 'percentunit',
|
||||
docPath: `${CONTAINERS_DOC_PATH}#node-cpu-utilization-by-container`,
|
||||
description:
|
||||
"The container's CPU usage as a fraction of the whole node's capacity, to spot noisy neighbours.",
|
||||
},
|
||||
{
|
||||
title: 'Node Memory Utilization by Container',
|
||||
yAxisUnit: 'percentunit',
|
||||
docPath: `${CONTAINERS_DOC_PATH}#node-memory-utilization-by-container`,
|
||||
description:
|
||||
"The container's memory usage as a fraction of the whole node's capacity.",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,140 @@
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { listContainers } from 'api/generated/services/inframonitoring';
|
||||
import {
|
||||
InframonitoringtypesContainerRecordDTO,
|
||||
InframonitoringtypesResponseTypeDTO,
|
||||
Querybuildertypesv5OrderDirectionDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
|
||||
import { K8sEntityConfig } from '../Base/entity.config.types';
|
||||
import { K8sBaseFilters, K8sDetailsFilters } from '../Base/types';
|
||||
import { InfraMonitoringEntity } from '../constants';
|
||||
import { SelectedItemParams } from '../hooks';
|
||||
import {
|
||||
containerWidgetInfo,
|
||||
k8sContainerDetailsMetadataConfig,
|
||||
k8sContainerGetEntityName,
|
||||
k8sContainerGetSelectedItemExpression,
|
||||
k8sContainerInitialEventsExpression,
|
||||
k8sContainerInitialLogTracesExpression,
|
||||
} from './constants';
|
||||
import { getContainerMetricsQueryPayload } from './metrics';
|
||||
import {
|
||||
getK8sContainerItemKey,
|
||||
getK8sContainerRowKey,
|
||||
k8sContainerColumnsConfig,
|
||||
} from './table.config';
|
||||
|
||||
async function fetchListData(
|
||||
filters: K8sBaseFilters,
|
||||
signal?: AbortSignal,
|
||||
): ReturnType<
|
||||
K8sEntityConfig<
|
||||
InframonitoringtypesContainerRecordDTO,
|
||||
SelectedItemParams
|
||||
>['list']['fetchListData']
|
||||
> {
|
||||
try {
|
||||
const response = await listContainers(
|
||||
{
|
||||
filter: { expression: filters.filter.expression },
|
||||
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
|
||||
offset: filters.offset,
|
||||
limit: filters.limit ?? 10,
|
||||
start: filters.start,
|
||||
end: filters.end,
|
||||
orderBy: filters.orderBy
|
||||
? {
|
||||
key: { name: filters.orderBy.key.name },
|
||||
direction:
|
||||
filters.orderBy.direction === 'asc'
|
||||
? Querybuildertypesv5OrderDirectionDTO.asc
|
||||
: Querybuildertypesv5OrderDirectionDTO.desc,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
|
||||
const data = response.data;
|
||||
return {
|
||||
type:
|
||||
data.type === InframonitoringtypesResponseTypeDTO.grouped_list
|
||||
? ('grouped_list' as const)
|
||||
: ('list' as const),
|
||||
records: data.records,
|
||||
total: data.total,
|
||||
endTimeBeforeRetention: data.endTimeBeforeRetention,
|
||||
warning: data.warning,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
type: 'list' as const,
|
||||
records: [] as InframonitoringtypesContainerRecordDTO[],
|
||||
total: 0,
|
||||
error:
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchEntityData(
|
||||
filters: K8sDetailsFilters,
|
||||
signal?: AbortSignal,
|
||||
): ReturnType<
|
||||
K8sEntityConfig<InframonitoringtypesContainerRecordDTO>['details']['fetchEntityData']
|
||||
> {
|
||||
try {
|
||||
const response = await listContainers(
|
||||
{
|
||||
filter: { expression: filters.filter.expression },
|
||||
start: filters.start,
|
||||
end: filters.end,
|
||||
limit: 1,
|
||||
offset: 0,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
|
||||
return {
|
||||
data: response.data.records.length > 0 ? response.data.records[0] : null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
data: null,
|
||||
error:
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const containerEntityConfig: K8sEntityConfig<
|
||||
InframonitoringtypesContainerRecordDTO,
|
||||
SelectedItemParams
|
||||
> = {
|
||||
list: {
|
||||
entity: InfraMonitoringEntity.CONTAINERS,
|
||||
eventCategory: InfraMonitoringEvents.Container,
|
||||
tableColumns: k8sContainerColumnsConfig,
|
||||
fetchListData,
|
||||
getRowKey: getK8sContainerRowKey,
|
||||
getItemKey: getK8sContainerItemKey,
|
||||
detailsQueryKeyPrefix: 'container',
|
||||
},
|
||||
details: {
|
||||
category: InfraMonitoringEntity.CONTAINERS,
|
||||
eventCategory: InfraMonitoringEvents.Container,
|
||||
queryKeyPrefix: 'container',
|
||||
getSelectedItemExpression: k8sContainerGetSelectedItemExpression,
|
||||
fetchEntityData,
|
||||
getEntityName: k8sContainerGetEntityName,
|
||||
getInitialLogTracesExpression: k8sContainerInitialLogTracesExpression,
|
||||
getInitialEventsExpression: k8sContainerInitialEventsExpression,
|
||||
metadataConfig: k8sContainerDetailsMetadataConfig,
|
||||
entityWidgetInfo: containerWidgetInfo,
|
||||
getEntityQueryPayload: getContainerMetricsQueryPayload,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,275 @@
|
||||
import { InframonitoringtypesContainerRecordDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
|
||||
import { getContainerName, getContainerPodUID } from './utils';
|
||||
|
||||
const QUERY_NAMES = ['A', 'B', 'C', 'D', 'E', 'F'];
|
||||
const STEP_INTERVAL = 60;
|
||||
|
||||
type TimeAggregation = 'avg' | 'max' | 'min' | 'latest';
|
||||
type SpaceAggregation = 'sum' | 'avg' | 'max';
|
||||
|
||||
interface SeriesSpec {
|
||||
metricKey: string;
|
||||
legend: string;
|
||||
timeAggregation: TimeAggregation;
|
||||
spaceAggregation: SpaceAggregation;
|
||||
}
|
||||
|
||||
interface FormulaSpec {
|
||||
expression: string;
|
||||
legend: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every panel is scoped to a single container by the (k8s.pod.uid,
|
||||
* k8s.container.name) pair that identifies its row in the list.
|
||||
*/
|
||||
function buildScopeFilters(
|
||||
container: InframonitoringtypesContainerRecordDTO,
|
||||
): TagFilter {
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
id: 'pod-uid',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
id: `k8s_pod_uid--string--tag--false`,
|
||||
key: INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID,
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value: getContainerPodUID(container),
|
||||
},
|
||||
{
|
||||
id: 'container-name',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
id: `k8s_container_name--string--tag--false`,
|
||||
key: INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME,
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value: getContainerName(container),
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
}
|
||||
|
||||
function buildQuery(
|
||||
container: InframonitoringtypesContainerRecordDTO,
|
||||
start: number,
|
||||
end: number,
|
||||
series: SeriesSpec[],
|
||||
formulas: FormulaSpec[] = [],
|
||||
): GetQueryResultsProps {
|
||||
const filters = buildScopeFilters(container);
|
||||
|
||||
return {
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
graphType: PANEL_TYPES.TIME_SERIES,
|
||||
query: {
|
||||
builder: {
|
||||
queryData: series.map((spec, index) => ({
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
id: `${spec.metricKey.replace(/\./g, '_')}--float64--Gauge--true`,
|
||||
key: spec.metricKey,
|
||||
type: 'Gauge',
|
||||
},
|
||||
aggregateOperator: spec.timeAggregation,
|
||||
dataSource: DataSource.METRICS,
|
||||
disabled: false,
|
||||
expression: QUERY_NAMES[index],
|
||||
filters,
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
legend: spec.legend,
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: QUERY_NAMES[index],
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
spaceAggregation: spec.spaceAggregation,
|
||||
stepInterval: STEP_INTERVAL,
|
||||
timeAggregation: spec.timeAggregation,
|
||||
})),
|
||||
queryFormulas: formulas.map((formula, index) => ({
|
||||
disabled: false,
|
||||
expression: formula.expression,
|
||||
legend: formula.legend,
|
||||
queryName: `F${index + 1}`,
|
||||
})),
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||
id: v4(),
|
||||
promql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
},
|
||||
variables: {},
|
||||
formatForWeb: false,
|
||||
start,
|
||||
end,
|
||||
};
|
||||
}
|
||||
|
||||
/** Absolute usage metrics are summed across series, ratios are averaged. */
|
||||
function usageSeries(metricKey: string, legendPrefix = ''): SeriesSpec[] {
|
||||
const prefix = legendPrefix ? `${legendPrefix} - ` : '';
|
||||
return [
|
||||
{
|
||||
metricKey,
|
||||
legend: `${prefix}Avg`,
|
||||
timeAggregation: 'avg',
|
||||
spaceAggregation: 'sum',
|
||||
},
|
||||
{
|
||||
metricKey,
|
||||
legend: `${prefix}Max`,
|
||||
timeAggregation: 'max',
|
||||
spaceAggregation: 'sum',
|
||||
},
|
||||
{
|
||||
metricKey,
|
||||
legend: `${prefix}Min`,
|
||||
timeAggregation: 'min',
|
||||
spaceAggregation: 'sum',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function utilizationSeries(
|
||||
metricKey: string,
|
||||
legendPrefix: string,
|
||||
): SeriesSpec[] {
|
||||
return (['avg', 'max', 'min'] as TimeAggregation[]).map((timeAggregation) => ({
|
||||
metricKey,
|
||||
legend: `${legendPrefix} - ${
|
||||
timeAggregation.charAt(0).toUpperCase() + timeAggregation.slice(1)
|
||||
}`,
|
||||
timeAggregation,
|
||||
spaceAggregation: 'avg' as const,
|
||||
}));
|
||||
}
|
||||
|
||||
export const getContainerMetricsQueryPayload = (
|
||||
container: InframonitoringtypesContainerRecordDTO,
|
||||
start: number,
|
||||
end: number,
|
||||
): GetQueryResultsProps[] => {
|
||||
const query = (
|
||||
series: SeriesSpec[],
|
||||
formulas?: FormulaSpec[],
|
||||
): GetQueryResultsProps => buildQuery(container, start, end, series, formulas);
|
||||
|
||||
return [
|
||||
query(usageSeries(INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE)),
|
||||
query([
|
||||
...utilizationSeries(
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_CPU_REQUEST_UTILIZATION,
|
||||
'Request util %',
|
||||
),
|
||||
...utilizationSeries(
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_CPU_LIMIT_UTILIZATION,
|
||||
'Limit util %',
|
||||
),
|
||||
]),
|
||||
query([
|
||||
...usageSeries(INFRA_MONITORING_ATTR_KEYS.CONTAINER_MEMORY_USAGE, 'Usage'),
|
||||
{
|
||||
metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_MEMORY_AVAILABLE,
|
||||
legend: 'Available',
|
||||
timeAggregation: 'avg',
|
||||
spaceAggregation: 'sum',
|
||||
},
|
||||
]),
|
||||
query([
|
||||
...utilizationSeries(
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_MEMORY_REQUEST_UTILIZATION,
|
||||
'Request util %',
|
||||
),
|
||||
...utilizationSeries(
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_MEMORY_LIMIT_UTILIZATION,
|
||||
'Limit util %',
|
||||
),
|
||||
]),
|
||||
query(
|
||||
[
|
||||
{
|
||||
metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_MEMORY_RSS,
|
||||
legend: 'RSS Memory',
|
||||
timeAggregation: 'avg',
|
||||
spaceAggregation: 'sum',
|
||||
},
|
||||
{
|
||||
metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_MEMORY_WORKING_SET,
|
||||
legend: 'Working Set Memory',
|
||||
timeAggregation: 'avg',
|
||||
spaceAggregation: 'sum',
|
||||
},
|
||||
],
|
||||
[{ expression: 'B - A', legend: 'Cache Memory' }],
|
||||
),
|
||||
query([
|
||||
{
|
||||
metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_MEMORY_MAJOR_PAGE_FAULTS,
|
||||
legend: 'Major Page Faults',
|
||||
timeAggregation: 'avg',
|
||||
spaceAggregation: 'sum',
|
||||
},
|
||||
]),
|
||||
query([
|
||||
{
|
||||
metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_FILESYSTEM_CAPACITY,
|
||||
legend: 'Capacity',
|
||||
timeAggregation: 'avg',
|
||||
spaceAggregation: 'sum',
|
||||
},
|
||||
{
|
||||
metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_FILESYSTEM_AVAILABLE,
|
||||
legend: 'Available',
|
||||
timeAggregation: 'avg',
|
||||
spaceAggregation: 'sum',
|
||||
},
|
||||
{
|
||||
metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_FILESYSTEM_USAGE,
|
||||
legend: 'Usage',
|
||||
timeAggregation: 'avg',
|
||||
spaceAggregation: 'sum',
|
||||
},
|
||||
]),
|
||||
query([
|
||||
{
|
||||
metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_UPTIME,
|
||||
legend: 'Uptime',
|
||||
timeAggregation: 'latest',
|
||||
spaceAggregation: 'sum',
|
||||
},
|
||||
]),
|
||||
query([
|
||||
{
|
||||
metricKey: INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_CPU_NODE_UTILIZATION,
|
||||
legend: 'Node CPU Utilization',
|
||||
timeAggregation: 'avg',
|
||||
spaceAggregation: 'avg',
|
||||
},
|
||||
]),
|
||||
query([
|
||||
{
|
||||
metricKey: INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_MEMORY_NODE_UTILIZATION,
|
||||
legend: 'Node Memory Utilization',
|
||||
timeAggregation: 'avg',
|
||||
spaceAggregation: 'avg',
|
||||
},
|
||||
]),
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,470 @@
|
||||
import { Container } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import {
|
||||
InframonitoringtypesContainerReadyDTO,
|
||||
InframonitoringtypesContainerRecordDTO,
|
||||
InframonitoringtypesContainerStatusDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
|
||||
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
|
||||
|
||||
import ColumnHeader from '../Base/ColumnHeader';
|
||||
import EntityGroupHeader from '../Base/EntityGroupHeader';
|
||||
import K8sGroupCell from '../Base/K8sGroupCell';
|
||||
import { formatBytes } from '../commonUtils';
|
||||
import {
|
||||
EntityProgressBar,
|
||||
EntityProgressThresholds,
|
||||
GroupedStatusCounts,
|
||||
TextNoData,
|
||||
ValidateColumnValueWrapper,
|
||||
} from '../components';
|
||||
import {
|
||||
INFRA_MONITORING_ATTR_KEYS,
|
||||
InfraMonitoringEntity,
|
||||
} from '../constants';
|
||||
import { SelectedItemParams } from '../hooks';
|
||||
import {
|
||||
CONTAINER_READY_COLORS,
|
||||
CONTAINER_READY_LABELS,
|
||||
CONTAINER_STATUS_COLORS,
|
||||
CONTAINER_STATUS_LABELS,
|
||||
CONTAINERS_DOC_PATH,
|
||||
getContainerImageWithTag,
|
||||
getContainerReadyItems,
|
||||
getContainerStatusItems,
|
||||
} from './utils';
|
||||
|
||||
export function getK8sContainerRowKey(
|
||||
container: InframonitoringtypesContainerRecordDTO,
|
||||
): string {
|
||||
return (
|
||||
[container.podUID, container.containerName].filter(Boolean).join('/') ||
|
||||
container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME] ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
export function getK8sContainerItemKey(
|
||||
container: InframonitoringtypesContainerRecordDTO,
|
||||
): SelectedItemParams {
|
||||
return {
|
||||
selectedItem:
|
||||
container.podUID ||
|
||||
container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID] ||
|
||||
null,
|
||||
containerName:
|
||||
container.containerName ||
|
||||
container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME] ||
|
||||
null,
|
||||
clusterName: null,
|
||||
namespaceName: null,
|
||||
};
|
||||
}
|
||||
|
||||
export type ContainerTableColumnConfig =
|
||||
TableColumnDef<InframonitoringtypesContainerRecordDTO>;
|
||||
|
||||
/**
|
||||
* The grouped table and its nested rows are separate tables, so a column and the
|
||||
* one that replaces it while grouped share a width to keep the two aligned.
|
||||
*/
|
||||
const NAME_COLUMN_WIDTH = 220;
|
||||
const STATUS_COLUMN_WIDTH = 250;
|
||||
|
||||
export const k8sContainerColumnsConfig: ContainerTableColumnConfig[] = [
|
||||
{
|
||||
id: 'containerGroup',
|
||||
header: (): React.ReactNode => <EntityGroupHeader title="Container Group" />,
|
||||
accessorFn: (row): string => row.containerName || '',
|
||||
width: { min: NAME_COLUMN_WIDTH },
|
||||
enableSort: false,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
pin: 'left',
|
||||
visibilityBehavior: 'hidden-on-collapse',
|
||||
cell: ({ isExpanded, toggleExpanded, row }): JSX.Element | null => (
|
||||
<ExpandButtonWrapper isExpanded={isExpanded} toggleExpanded={toggleExpanded}>
|
||||
<K8sGroupCell row={row} />
|
||||
</ExpandButtonWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME,
|
||||
header: (): React.ReactNode => (
|
||||
<EntityGroupHeader
|
||||
title="Container Name"
|
||||
icon={<Container data-hide-expanded="true" size={14} />}
|
||||
docPath={`${CONTAINERS_DOC_PATH}#container-name`}
|
||||
/>
|
||||
),
|
||||
accessorFn: (row): string =>
|
||||
row.containerName ||
|
||||
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME] ||
|
||||
'',
|
||||
width: { min: NAME_COLUMN_WIDTH },
|
||||
enableSort: true,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
pin: 'left',
|
||||
visibilityBehavior: 'hidden-on-expand',
|
||||
cell: ({ value }): React.ReactNode => (
|
||||
<TanStackTable.Text>{value as string}</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'podName',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#pod-name`}>
|
||||
Pod Name
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): string =>
|
||||
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME] || '',
|
||||
width: { min: 260 },
|
||||
enableSort: false,
|
||||
cell: ({ value }): React.ReactNode => {
|
||||
const podName = value as string;
|
||||
if (!podName) {
|
||||
return <TextNoData type="tanstack" />;
|
||||
}
|
||||
return <TanStackTable.Text>{podName}</TanStackTable.Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'namespace',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#additional-columns`}>
|
||||
Namespace
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): string =>
|
||||
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] || '',
|
||||
width: { min: 160 },
|
||||
enableSort: false,
|
||||
cell: ({ value }): React.ReactNode => (
|
||||
<TanStackTable.Text>{value as string}</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'image',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#imagetag`}>
|
||||
Image:Tag
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): string => getContainerImageWithTag(row),
|
||||
width: { min: 240 },
|
||||
enableSort: false,
|
||||
cell: ({ value }): React.ReactNode => {
|
||||
const image = value as string;
|
||||
if (!image) {
|
||||
return <TextNoData type="tanstack" />;
|
||||
}
|
||||
return <TanStackTable.Text>{image}</TanStackTable.Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'containerStatus',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#status`}>Status</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): string => row.status,
|
||||
width: { min: STATUS_COLUMN_WIDTH },
|
||||
enableSort: false,
|
||||
visibilityBehavior: 'hidden-on-expand',
|
||||
cell: ({ row }): React.ReactNode => {
|
||||
if (
|
||||
!row.status ||
|
||||
row.status === InframonitoringtypesContainerStatusDTO.no_data
|
||||
) {
|
||||
return <TextNoData type="tanstack" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge color={CONTAINER_STATUS_COLORS[row.status]} variant="outline">
|
||||
{CONTAINER_STATUS_LABELS[row.status]}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'containerCountsByStatus',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#status`}>Status</ColumnHeader>
|
||||
),
|
||||
accessorFn: (
|
||||
row,
|
||||
): InframonitoringtypesContainerRecordDTO['containerCountsByStatus'] =>
|
||||
row.containerCountsByStatus,
|
||||
width: { min: STATUS_COLUMN_WIDTH },
|
||||
enableSort: false,
|
||||
visibilityBehavior: 'hidden-on-collapse',
|
||||
cell: ({ row, rowId }): React.ReactNode => {
|
||||
if (!row.containerCountsByStatus) {
|
||||
return <TextNoData type="tanstack" />;
|
||||
}
|
||||
return (
|
||||
<GroupedStatusCounts
|
||||
items={getContainerStatusItems(row.containerCountsByStatus)}
|
||||
rowId={rowId}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'containerReady',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#ready`}>Ready</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): string => row.ready,
|
||||
width: { min: 130 },
|
||||
enableSort: false,
|
||||
visibilityBehavior: 'hidden-on-expand',
|
||||
cell: ({ row }): React.ReactNode => {
|
||||
if (
|
||||
!row.ready ||
|
||||
row.ready === InframonitoringtypesContainerReadyDTO.no_data
|
||||
) {
|
||||
return <TextNoData type="tanstack" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge color={CONTAINER_READY_COLORS[row.ready]} variant="outline">
|
||||
{CONTAINER_READY_LABELS[row.ready]}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'containerCountsByReady',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#ready`}>Ready</ColumnHeader>
|
||||
),
|
||||
accessorFn: (
|
||||
row,
|
||||
): InframonitoringtypesContainerRecordDTO['containerCountsByReady'] =>
|
||||
row.containerCountsByReady,
|
||||
width: { min: 130 },
|
||||
enableSort: false,
|
||||
visibilityBehavior: 'hidden-on-collapse',
|
||||
cell: ({ row, rowId }): React.ReactNode => {
|
||||
if (!row.containerCountsByReady) {
|
||||
return <TextNoData type="tanstack" />;
|
||||
}
|
||||
return (
|
||||
<GroupedStatusCounts
|
||||
items={getContainerReadyItems(row.containerCountsByReady)}
|
||||
rowId={rowId}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'containerRestarts',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#restarts`}>
|
||||
Restarts
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): number => row.restarts,
|
||||
width: { min: 130 },
|
||||
enableSort: false,
|
||||
cell: ({ value, rowId }): React.ReactNode => (
|
||||
<ValidateColumnValueWrapper
|
||||
rowId={rowId}
|
||||
value={value as number}
|
||||
entity={InfraMonitoringEntity.CONTAINERS}
|
||||
attribute="Restarts"
|
||||
>
|
||||
<TanStackTable.Text>{value as number}</TanStackTable.Text>
|
||||
</ValidateColumnValueWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'cpu_request',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader
|
||||
docPath={`${CONTAINERS_DOC_PATH}#cpu-req-usage-`}
|
||||
tooltip={<EntityProgressThresholds type="cpu-request" />}
|
||||
>
|
||||
CPU Request Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): number => row.cpuRequestUtilization,
|
||||
width: { min: 210 },
|
||||
enableSort: true,
|
||||
cell: ({ value, rowId }): React.ReactNode => (
|
||||
<ValidateColumnValueWrapper
|
||||
rowId={rowId}
|
||||
value={value as number}
|
||||
entity={InfraMonitoringEntity.CONTAINERS}
|
||||
attribute="CPU Request"
|
||||
>
|
||||
<EntityProgressBar value={value as number} type="cpu-request" />
|
||||
</ValidateColumnValueWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'cpu_limit',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader
|
||||
docPath={`${CONTAINERS_DOC_PATH}#cpu-limit-usage-`}
|
||||
tooltip={<EntityProgressThresholds type="cpu-limit" />}
|
||||
>
|
||||
CPU Limit Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): number => row.cpuLimitUtilization,
|
||||
width: { min: 220 },
|
||||
enableSort: true,
|
||||
cell: ({ value, rowId }): React.ReactNode => (
|
||||
<ValidateColumnValueWrapper
|
||||
rowId={rowId}
|
||||
value={value as number}
|
||||
entity={InfraMonitoringEntity.CONTAINERS}
|
||||
attribute="CPU Limit"
|
||||
>
|
||||
<EntityProgressBar value={value as number} type="cpu-limit" />
|
||||
</ValidateColumnValueWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'cpu',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#cpu-usage-cores`}>
|
||||
CPU Usage (cores)
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): number => row.cpu,
|
||||
width: { min: 160 },
|
||||
enableSort: true,
|
||||
cell: ({ value, rowId }): React.ReactNode => (
|
||||
<ValidateColumnValueWrapper
|
||||
rowId={rowId}
|
||||
value={Number(value)}
|
||||
entity={InfraMonitoringEntity.CONTAINERS}
|
||||
attribute="CPU metric"
|
||||
>
|
||||
<TanStackTable.Text>{Number(value).toFixed(2)}</TanStackTable.Text>
|
||||
</ValidateColumnValueWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'memory_request',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader
|
||||
docPath={`${CONTAINERS_DOC_PATH}#mem-req-usage-`}
|
||||
tooltip={<EntityProgressThresholds type="memory-request" />}
|
||||
>
|
||||
Memory Request Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): number => row.memoryRequestUtilization,
|
||||
width: { min: 210 },
|
||||
enableSort: true,
|
||||
cell: ({ value, rowId }): React.ReactNode => (
|
||||
<ValidateColumnValueWrapper
|
||||
rowId={rowId}
|
||||
value={value as number}
|
||||
entity={InfraMonitoringEntity.CONTAINERS}
|
||||
attribute="Memory Request"
|
||||
>
|
||||
<EntityProgressBar value={value as number} type="memory-request" />
|
||||
</ValidateColumnValueWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'memory_limit',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader
|
||||
docPath={`${CONTAINERS_DOC_PATH}#mem-limit-usage-`}
|
||||
tooltip={<EntityProgressThresholds type="memory-limit" />}
|
||||
>
|
||||
Memory Limit Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): number => row.memoryLimitUtilization,
|
||||
width: { min: 220 },
|
||||
enableSort: true,
|
||||
cell: ({ value, rowId }): React.ReactNode => (
|
||||
<ValidateColumnValueWrapper
|
||||
rowId={rowId}
|
||||
value={value as number}
|
||||
entity={InfraMonitoringEntity.CONTAINERS}
|
||||
attribute="Memory Limit"
|
||||
>
|
||||
<EntityProgressBar value={value as number} type="memory-limit" />
|
||||
</ValidateColumnValueWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'memory',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#mem-usage-wss`}>
|
||||
Memory Usage (WSS)
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): number => row.memory,
|
||||
width: { min: 210, default: '100%' },
|
||||
enableSort: true,
|
||||
cell: ({ value, rowId }): React.ReactNode => (
|
||||
<ValidateColumnValueWrapper
|
||||
rowId={rowId}
|
||||
value={value as number}
|
||||
entity={InfraMonitoringEntity.CONTAINERS}
|
||||
attribute="memory metric"
|
||||
>
|
||||
<TanStackTable.Text>{formatBytes(value as number)}</TanStackTable.Text>
|
||||
</ValidateColumnValueWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'node',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#additional-columns`}>
|
||||
Node
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): string =>
|
||||
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME] || '',
|
||||
width: { default: 100 },
|
||||
enableSort: false,
|
||||
defaultVisibility: false,
|
||||
cell: ({ value }): React.ReactNode => (
|
||||
<TanStackTable.Text>{value as string}</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'cluster',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#additional-columns`}>
|
||||
Cluster
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): string =>
|
||||
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] || '',
|
||||
width: { default: 100 },
|
||||
enableSort: false,
|
||||
defaultVisibility: false,
|
||||
cell: ({ value }): React.ReactNode => (
|
||||
<TanStackTable.Text>{value as string}</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'deployment',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#additional-columns`}>
|
||||
Deployment
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): string =>
|
||||
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] || '',
|
||||
width: { default: 100 },
|
||||
enableSort: false,
|
||||
defaultVisibility: false,
|
||||
cell: ({ value }): React.ReactNode => (
|
||||
<TanStackTable.Text>{value as string}</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
169
frontend/src/container/InfraMonitoringK8sV2/Containers/utils.ts
Normal file
169
frontend/src/container/InfraMonitoringK8sV2/Containers/utils.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { BadgeColor } from '@signozhq/ui/badge';
|
||||
import {
|
||||
InframonitoringtypesContainerCountsByReadyDTO,
|
||||
InframonitoringtypesContainerCountsByStatusDTO,
|
||||
InframonitoringtypesContainerReadyDTO,
|
||||
InframonitoringtypesContainerRecordDTO,
|
||||
InframonitoringtypesContainerStatusDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { StatusCountItem } from '../components/GroupedStatusCounts';
|
||||
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
|
||||
|
||||
export const CONTAINERS_DOC_PATH =
|
||||
'/infrastructure-monitoring/kubernetes/containers';
|
||||
|
||||
/** Renders as `name:tag`; the tag is dropped when the image is not pinned. */
|
||||
export function getContainerImageWithTag(
|
||||
container: InframonitoringtypesContainerRecordDTO,
|
||||
): string {
|
||||
const name = container.meta?.[INFRA_MONITORING_ATTR_KEYS.CONTAINER_IMAGE_NAME];
|
||||
const tag = container.meta?.[INFRA_MONITORING_ATTR_KEYS.CONTAINER_IMAGE_TAG];
|
||||
|
||||
if (!name) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return tag ? `${name}:${tag}` : name;
|
||||
}
|
||||
|
||||
export function getContainerName(
|
||||
container: InframonitoringtypesContainerRecordDTO,
|
||||
): string {
|
||||
return (
|
||||
container.containerName ||
|
||||
container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME] ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
export function getContainerPodUID(
|
||||
container: InframonitoringtypesContainerRecordDTO,
|
||||
): string {
|
||||
return (
|
||||
container.podUID ||
|
||||
container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID] ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
export const CONTAINER_STATUS_COLORS: Record<
|
||||
InframonitoringtypesContainerStatusDTO,
|
||||
BadgeColor
|
||||
> = {
|
||||
[InframonitoringtypesContainerStatusDTO.running]: 'forest',
|
||||
[InframonitoringtypesContainerStatusDTO.completed]: 'robin',
|
||||
[InframonitoringtypesContainerStatusDTO.waiting]: 'amber',
|
||||
[InframonitoringtypesContainerStatusDTO.containercreating]: 'amber',
|
||||
[InframonitoringtypesContainerStatusDTO.terminated]: 'sienna',
|
||||
[InframonitoringtypesContainerStatusDTO.unknown]: 'vanilla',
|
||||
[InframonitoringtypesContainerStatusDTO.no_data]: 'vanilla',
|
||||
[InframonitoringtypesContainerStatusDTO.crashloopbackoff]: 'cherry',
|
||||
[InframonitoringtypesContainerStatusDTO.imagepullbackoff]: 'cherry',
|
||||
[InframonitoringtypesContainerStatusDTO.errimagepull]: 'cherry',
|
||||
[InframonitoringtypesContainerStatusDTO.createcontainerconfigerror]: 'cherry',
|
||||
[InframonitoringtypesContainerStatusDTO.oomkilled]: 'cherry',
|
||||
[InframonitoringtypesContainerStatusDTO.error]: 'cherry',
|
||||
[InframonitoringtypesContainerStatusDTO.containercannotrun]: 'cherry',
|
||||
};
|
||||
|
||||
/** kubectl prints these as single CamelCase words, so the enum value alone is not a usable label. */
|
||||
export const CONTAINER_STATUS_LABELS: Record<
|
||||
InframonitoringtypesContainerStatusDTO,
|
||||
string
|
||||
> = {
|
||||
[InframonitoringtypesContainerStatusDTO.running]: 'Running',
|
||||
[InframonitoringtypesContainerStatusDTO.completed]: 'Completed',
|
||||
[InframonitoringtypesContainerStatusDTO.waiting]: 'Waiting',
|
||||
[InframonitoringtypesContainerStatusDTO.containercreating]:
|
||||
'ContainerCreating',
|
||||
[InframonitoringtypesContainerStatusDTO.terminated]: 'Terminated',
|
||||
[InframonitoringtypesContainerStatusDTO.unknown]: 'Unknown',
|
||||
[InframonitoringtypesContainerStatusDTO.no_data]: 'No data',
|
||||
[InframonitoringtypesContainerStatusDTO.crashloopbackoff]: 'CrashLoopBackOff',
|
||||
[InframonitoringtypesContainerStatusDTO.imagepullbackoff]: 'ImagePullBackOff',
|
||||
[InframonitoringtypesContainerStatusDTO.errimagepull]: 'ErrImagePull',
|
||||
[InframonitoringtypesContainerStatusDTO.createcontainerconfigerror]:
|
||||
'CreateContainerConfigError',
|
||||
[InframonitoringtypesContainerStatusDTO.oomkilled]: 'OOMKilled',
|
||||
[InframonitoringtypesContainerStatusDTO.error]: 'Error',
|
||||
[InframonitoringtypesContainerStatusDTO.containercannotrun]:
|
||||
'ContainerCannotRun',
|
||||
};
|
||||
|
||||
const CONTAINER_ERROR_STATUS_LABELS: Partial<
|
||||
Record<keyof InframonitoringtypesContainerCountsByStatusDTO, string>
|
||||
> = {
|
||||
crashLoopBackOff: 'CrashLoopBackOff',
|
||||
imagePullBackOff: 'ImagePullBackOff',
|
||||
errImagePull: 'ErrImagePull',
|
||||
createContainerConfigError: 'CreateContainerConfigError',
|
||||
oomKilled: 'OOMKilled',
|
||||
error: 'Error',
|
||||
containerCannotRun: 'ContainerCannotRun',
|
||||
};
|
||||
|
||||
export function getContainerStatusItems(
|
||||
counts: InframonitoringtypesContainerCountsByStatusDTO,
|
||||
): StatusCountItem[] {
|
||||
const errorKeys = Object.keys(CONTAINER_ERROR_STATUS_LABELS) as Array<
|
||||
keyof typeof CONTAINER_ERROR_STATUS_LABELS
|
||||
>;
|
||||
|
||||
return [
|
||||
{ value: counts.running, label: 'Running', color: Color.BG_FOREST_500 },
|
||||
{ value: counts.completed, label: 'Completed', color: Color.BG_ROBIN_500 },
|
||||
{
|
||||
value: counts.waiting + counts.containerCreating,
|
||||
label: 'Waiting',
|
||||
color: Color.BG_AMBER_500,
|
||||
breakdown: [
|
||||
{ label: 'Waiting', value: counts.waiting },
|
||||
{ label: 'ContainerCreating', value: counts.containerCreating },
|
||||
],
|
||||
},
|
||||
{
|
||||
value: counts.terminated,
|
||||
label: 'Terminated',
|
||||
color: Color.BG_SIENNA_500,
|
||||
},
|
||||
{ value: counts.unknown, label: 'Unknown', color: Color.BG_SLATE_400 },
|
||||
{
|
||||
value: errorKeys.reduce((sum, key) => sum + counts[key], 0),
|
||||
label: 'Error Status',
|
||||
color: Color.BG_CHERRY_500,
|
||||
breakdown: errorKeys.map((key) => ({
|
||||
label: CONTAINER_ERROR_STATUS_LABELS[key] as string,
|
||||
value: counts[key],
|
||||
})),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const CONTAINER_READY_COLORS: Record<
|
||||
InframonitoringtypesContainerReadyDTO,
|
||||
BadgeColor
|
||||
> = {
|
||||
[InframonitoringtypesContainerReadyDTO.ready]: 'forest',
|
||||
[InframonitoringtypesContainerReadyDTO.not_ready]: 'cherry',
|
||||
[InframonitoringtypesContainerReadyDTO.no_data]: 'vanilla',
|
||||
};
|
||||
|
||||
export const CONTAINER_READY_LABELS: Record<
|
||||
InframonitoringtypesContainerReadyDTO,
|
||||
string
|
||||
> = {
|
||||
[InframonitoringtypesContainerReadyDTO.ready]: 'Ready',
|
||||
[InframonitoringtypesContainerReadyDTO.not_ready]: 'Not Ready',
|
||||
[InframonitoringtypesContainerReadyDTO.no_data]: 'No data',
|
||||
};
|
||||
|
||||
export function getContainerReadyItems(
|
||||
counts: InframonitoringtypesContainerCountsByReadyDTO,
|
||||
): StatusCountItem[] {
|
||||
return [
|
||||
{ value: counts.ready, label: 'Ready', color: Color.BG_FOREST_500 },
|
||||
{ value: counts.notReady, label: 'Not Ready', color: Color.BG_CHERRY_500 },
|
||||
];
|
||||
}
|
||||
@@ -3,8 +3,9 @@ import { Compass, Info } from '@signozhq/icons';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
|
||||
import styles from './ChartHeader.module.scss';
|
||||
import { DOCS_BASE_URL } from 'constants/app';
|
||||
|
||||
const DOCS_BASE_URL = `${process.env.DOCS_BASE_URL}/docs`;
|
||||
const DOCS_ROOT = `${DOCS_BASE_URL}/docs`;
|
||||
|
||||
interface ChartHeaderProps {
|
||||
title: string;
|
||||
@@ -33,7 +34,7 @@ function ChartHeader({
|
||||
<>
|
||||
{tooltipTitle}{' '}
|
||||
<a
|
||||
href={`${DOCS_BASE_URL}${docPath}`}
|
||||
href={`${DOCS_ROOT}${docPath}`}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
|
||||
@@ -37,7 +37,15 @@
|
||||
.title {
|
||||
font-family: var(--periscope-font-family-mono);
|
||||
|
||||
--typography-margin: 0px var(--spacing-4) 0px 0px;
|
||||
--typography-margin: 0px var(--spacing-1) 0px 0px;
|
||||
}
|
||||
|
||||
// Sized to the icon rather than the default tap target, so it sits beside the
|
||||
// entity name instead of a gap away from it.
|
||||
.copyIdButton {
|
||||
--button-padding: 2px;
|
||||
--button-height: auto;
|
||||
--button-width: auto;
|
||||
}
|
||||
|
||||
.entityDetailsEntity {
|
||||
@@ -45,30 +53,12 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
// Tracks size to the entity's field count instead of a fixed four, so entities
|
||||
// with more fields stay on one row rather than spilling a near-empty second one.
|
||||
.entityDetailsGrid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.labelsRow,
|
||||
.valuesRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 1.5fr 1.5fr 1.5fr;
|
||||
gap: 30px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.labelsRow {
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.entityDetailsMetadataLabel {
|
||||
letter-spacing: 0.44px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.entityDetailsMetadataValue {
|
||||
font-family: var(--periscope-font-family-mono);
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: var(--spacing-4) var(--spacing-6);
|
||||
}
|
||||
|
||||
.viewsTabsContainer {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
ArrowUpDown,
|
||||
ArrowUpToLine,
|
||||
Bolt,
|
||||
Box,
|
||||
Boxes,
|
||||
Computer,
|
||||
Container,
|
||||
@@ -31,6 +32,7 @@ import { DataSource } from 'types/common/queryBuilder';
|
||||
import { K8sDynamicList } from './Base/K8sDynamicList';
|
||||
import {
|
||||
GetClustersQuickFiltersConfig,
|
||||
GetContainersQuickFiltersConfig,
|
||||
GetDaemonsetsQuickFiltersConfig,
|
||||
GetDeploymentsQuickFiltersConfig,
|
||||
GetJobsQuickFiltersConfig,
|
||||
@@ -152,6 +154,12 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
|
||||
const categories = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: K8sCategories.CONTAINERS,
|
||||
label: 'Containers',
|
||||
icon: <Box size={14} />,
|
||||
config: GetContainersQuickFiltersConfig(),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.PODS,
|
||||
label: 'Pods',
|
||||
|
||||
@@ -61,10 +61,26 @@ export const INFRA_MONITORING_ATTR_KEYS = {
|
||||
K8S_CONTAINER_CPU_LIMIT: 'k8s.container.cpu_limit',
|
||||
K8S_CONTAINER_MEMORY_REQUEST: 'k8s.container.memory_request',
|
||||
K8S_CONTAINER_MEMORY_LIMIT: 'k8s.container.memory_limit',
|
||||
K8S_CONTAINER_CPU_REQUEST_UTILIZATION: 'k8s.container.cpu_request_utilization',
|
||||
K8S_CONTAINER_CPU_LIMIT_UTILIZATION: 'k8s.container.cpu_limit_utilization',
|
||||
K8S_CONTAINER_MEMORY_REQUEST_UTILIZATION:
|
||||
'k8s.container.memory_request_utilization',
|
||||
K8S_CONTAINER_MEMORY_LIMIT_UTILIZATION:
|
||||
'k8s.container.memory_limit_utilization',
|
||||
K8S_CONTAINER_CPU_NODE_UTILIZATION: 'k8s.container.cpu.node.utilization',
|
||||
K8S_CONTAINER_MEMORY_NODE_UTILIZATION: 'k8s.container.memory.node.utilization',
|
||||
CONTAINER_CPU_USAGE: 'container.cpu.usage',
|
||||
CONTAINER_MEMORY_USAGE: 'container.memory.usage',
|
||||
CONTAINER_MEMORY_AVAILABLE: 'container.memory.available',
|
||||
CONTAINER_MEMORY_WORKING_SET: 'container.memory.working_set',
|
||||
CONTAINER_MEMORY_RSS: 'container.memory.rss',
|
||||
CONTAINER_MEMORY_MAJOR_PAGE_FAULTS: 'container.memory.major_page_faults',
|
||||
CONTAINER_FILESYSTEM_AVAILABLE: 'container.filesystem.available',
|
||||
CONTAINER_FILESYSTEM_CAPACITY: 'container.filesystem.capacity',
|
||||
CONTAINER_FILESYSTEM_USAGE: 'container.filesystem.usage',
|
||||
CONTAINER_UPTIME: 'container.uptime',
|
||||
CONTAINER_IMAGE_NAME: 'container.image.name',
|
||||
CONTAINER_IMAGE_TAG: 'container.image.tag',
|
||||
|
||||
// Deployment
|
||||
K8S_DEPLOYMENT_NAME: 'k8s.deployment.name',
|
||||
@@ -165,6 +181,9 @@ export const K8sCategories = {
|
||||
VOLUMES: 'volumes',
|
||||
};
|
||||
|
||||
/** The section the Kubernetes view opens on when a link names none. */
|
||||
export const DEFAULT_K8S_CATEGORY = K8sCategories.CONTAINERS;
|
||||
|
||||
const dotMap = {
|
||||
[InfraMonitoringEntity.HOSTS]:
|
||||
INFRA_MONITORING_ATTR_KEYS.SYSTEM_CPU_LOAD_AVERAGE_15M,
|
||||
@@ -181,7 +200,7 @@ const dotMap = {
|
||||
[InfraMonitoringEntity.DAEMONSETS]:
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
|
||||
[InfraMonitoringEntity.CONTAINERS]:
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
|
||||
INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
|
||||
[InfraMonitoringEntity.JOBS]:
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_JOB_DESIRED_SUCCESSFUL_PODS,
|
||||
[InfraMonitoringEntity.VOLUMES]:
|
||||
@@ -321,6 +340,161 @@ export function GetPodsQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
];
|
||||
}
|
||||
|
||||
export function GetContainersQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
return [
|
||||
{
|
||||
type: FiltersType.CHECKBOX,
|
||||
title: 'Container',
|
||||
attributeKey: {
|
||||
key: INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME,
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
id: `${INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME}--string--resource--false`,
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
type: FiltersType.CHECKBOX,
|
||||
title: 'Pod',
|
||||
attributeKey: {
|
||||
key: INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME,
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
id: `${INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME}--string--resource--false`,
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
type: FiltersType.CHECKBOX,
|
||||
title: 'Namespace',
|
||||
attributeKey: {
|
||||
key: INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
id: `${INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME}--string--resource--false`,
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: false,
|
||||
},
|
||||
{
|
||||
type: FiltersType.CHECKBOX,
|
||||
title: 'Node',
|
||||
attributeKey: {
|
||||
key: INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME,
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
id: `${INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME}--string--resource--false`,
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: false,
|
||||
},
|
||||
{
|
||||
type: FiltersType.CHECKBOX,
|
||||
title: 'Cluster',
|
||||
attributeKey: {
|
||||
key: INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
id: `${INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME}--string--resource--false`,
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: false,
|
||||
},
|
||||
{
|
||||
type: FiltersType.CHECKBOX,
|
||||
title: 'Image',
|
||||
attributeKey: {
|
||||
key: INFRA_MONITORING_ATTR_KEYS.CONTAINER_IMAGE_NAME,
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
id: `${INFRA_MONITORING_ATTR_KEYS.CONTAINER_IMAGE_NAME}--string--resource--false`,
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: false,
|
||||
},
|
||||
{
|
||||
type: FiltersType.CHECKBOX,
|
||||
title: 'Deployment',
|
||||
attributeKey: {
|
||||
key: INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME,
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
id: `${INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME}--string--resource--false`,
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: false,
|
||||
},
|
||||
{
|
||||
type: FiltersType.CHECKBOX,
|
||||
title: 'Statefulset',
|
||||
attributeKey: {
|
||||
key: INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
id: `${INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME}--string--resource--false`,
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: false,
|
||||
},
|
||||
{
|
||||
type: FiltersType.CHECKBOX,
|
||||
title: 'DaemonSet',
|
||||
attributeKey: {
|
||||
key: INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME,
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
id: `${INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME}--string--resource--false`,
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: false,
|
||||
},
|
||||
{
|
||||
type: FiltersType.CHECKBOX,
|
||||
title: 'Job',
|
||||
attributeKey: {
|
||||
key: INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME,
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
id: `${INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME}--string--resource--false`,
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: false,
|
||||
},
|
||||
{
|
||||
type: FiltersType.CHECKBOX,
|
||||
title: 'Environment',
|
||||
attributeKey: {
|
||||
key: INFRA_MONITORING_ATTR_KEYS.DEPLOYMENT_ENVIRONMENT,
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
defaultOpen: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function GetNodesQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
return [
|
||||
{
|
||||
@@ -767,6 +941,7 @@ export const INFRA_MONITORING_K8S_PARAMS_KEYS = {
|
||||
SELECTED_ITEM: 'selectedItem',
|
||||
SELECTED_ITEM_CLUSTER_NAME: 'selectedItemClusterName',
|
||||
SELECTED_ITEM_NAMESPACE_NAME: 'selectedItemNamespaceName',
|
||||
SELECTED_ITEM_CONTAINER_NAME: 'selectedItemContainerName',
|
||||
DETAIL_RELATIVE_TIME: 'detailRelativeTime',
|
||||
DETAIL_START_TIME: 'detailStartTime',
|
||||
DETAIL_END_TIME: 'detailEndTime',
|
||||
@@ -783,7 +958,7 @@ export const METRIC_NAMESPACE_BY_ENTITY: Record<InfraMonitoringEntity, string> =
|
||||
[InfraMonitoringEntity.DEPLOYMENTS]: 'k8s.',
|
||||
[InfraMonitoringEntity.STATEFULSETS]: 'k8s.',
|
||||
[InfraMonitoringEntity.DAEMONSETS]: 'k8s.',
|
||||
[InfraMonitoringEntity.CONTAINERS]: 'k8s.pod.',
|
||||
[InfraMonitoringEntity.CONTAINERS]: 'k8s.container.',
|
||||
[InfraMonitoringEntity.JOBS]: 'k8s.',
|
||||
[InfraMonitoringEntity.VOLUMES]: 'k8s.volume.',
|
||||
};
|
||||
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
|
||||
|
||||
import {
|
||||
DEFAULT_K8S_CATEGORY,
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS,
|
||||
K8sCategories,
|
||||
VIEWS,
|
||||
} from './constants';
|
||||
import { orderBySchema, OrderBySchemaType } from './schemas';
|
||||
@@ -130,19 +130,23 @@ export const useInfraMonitoringCategory = (): UseQueryStateReturn<
|
||||
> =>
|
||||
useQueryState(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY,
|
||||
parseAsString.withDefault(K8sCategories.PODS).withOptions(defaultNuqsOptions),
|
||||
parseAsString
|
||||
.withDefault(DEFAULT_K8S_CATEGORY)
|
||||
.withOptions({ ...defaultNuqsOptions, clearOnDefault: false }),
|
||||
);
|
||||
|
||||
export interface SelectedItemParams {
|
||||
selectedItem: string | null;
|
||||
clusterName?: string | null;
|
||||
namespaceName?: string | null;
|
||||
containerName?: string | null;
|
||||
}
|
||||
|
||||
const selectedItemParamsParsers = {
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]: parseAsString,
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME]: parseAsString,
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME]: parseAsString,
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME]: parseAsString,
|
||||
};
|
||||
|
||||
export type UseSelectedItemParamsReturn = [
|
||||
@@ -167,6 +171,9 @@ export const useInfraMonitoringSelectedItemParams =
|
||||
namespaceName:
|
||||
rawParams[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME] ??
|
||||
null,
|
||||
containerName:
|
||||
rawParams[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME] ??
|
||||
null,
|
||||
}),
|
||||
[rawParams],
|
||||
);
|
||||
@@ -178,6 +185,7 @@ export const useInfraMonitoringSelectedItemParams =
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]: null,
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME]: null,
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME]: null,
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME]: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -189,6 +197,8 @@ export const useInfraMonitoringSelectedItemParams =
|
||||
newParams.clusterName ?? null,
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME]:
|
||||
newParams.namespaceName ?? null,
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME]:
|
||||
newParams.containerName ?? null,
|
||||
});
|
||||
},
|
||||
[setRawParams],
|
||||
|
||||
@@ -38,19 +38,20 @@ import {
|
||||
TEST_ENDPOINT,
|
||||
} from '../../__tests__/fixtures';
|
||||
|
||||
const SAMPLE_SPAN = JSON.parse(SAMPLE_SPAN_JSON) as {
|
||||
attributes: Record<string, unknown>;
|
||||
resource: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const MAPPED_ATTRIBUTE_KEY = 'gen_ai.content.prompt';
|
||||
|
||||
// Deriving from the sample keeps exactly one key added, so the single `populated` badge assertion below stays exact.
|
||||
const RESULT_SPAN = {
|
||||
attributes: {
|
||||
'my_company.llm.input': 'What is quantum computing?',
|
||||
'llm.input_messages': 'What is quantum computing?',
|
||||
'gen_ai.request.model': 'gpt-4',
|
||||
'gen_ai.usage.total_tokens': 1250,
|
||||
'gen_ai.content.completion': 'Quantum computing leverages...',
|
||||
'gen_ai.content.prompt': 'What is quantum computing?',
|
||||
},
|
||||
resource: {
|
||||
'service.name': 'llm-gateway',
|
||||
'deployment.environment': 'production',
|
||||
...SAMPLE_SPAN.attributes,
|
||||
[MAPPED_ATTRIBUTE_KEY]: SAMPLE_SPAN.attributes['input.value'],
|
||||
},
|
||||
resource: SAMPLE_SPAN.resource,
|
||||
};
|
||||
|
||||
const EDITED_SPAN_JSON = `{
|
||||
@@ -97,7 +98,7 @@ describe('TestTab — sample-span flow', () => {
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.getByTestId('test-result-0')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('test-result-0-attributes')).toHaveTextContent(
|
||||
'gen_ai.content.prompt',
|
||||
MAPPED_ATTRIBUTE_KEY,
|
||||
);
|
||||
expect(screen.getByText('populated')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('test-error')).not.toBeInTheDocument();
|
||||
|
||||
@@ -7,11 +7,14 @@ import { parseSpanInput } from './testPayload';
|
||||
|
||||
export const SAMPLE_SPAN_JSON = `{
|
||||
"attributes": {
|
||||
"my_company.llm.input": "What is quantum computing?",
|
||||
"llm.input_messages": "What is quantum computing?",
|
||||
"gen_ai.request.model": "gpt-4",
|
||||
"gen_ai.usage.total_tokens": 1250,
|
||||
"gen_ai.content.completion": "Quantum computing leverages..."
|
||||
"llm.model_name": "gpt-4o",
|
||||
"llm.provider": "openai",
|
||||
"llm.token_count.prompt": 1024,
|
||||
"llm.token_count.completion": 226,
|
||||
"llm.token_count.prompt_details.cache_read": 512,
|
||||
"input.value": "What is quantum computing?",
|
||||
"output.value": "Quantum computing leverages superposition and entanglement...",
|
||||
"session.id": "chat-8f2e41"
|
||||
},
|
||||
"resource": {
|
||||
"service.name": "llm-gateway",
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
.explorer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-2) var(--spacing-0);
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
.trace-explorer-header {
|
||||
.trace-explorer-run-query {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
align-items: center;
|
||||
margin: 8px 16px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-outlined-btn {
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
border-right: 1px solid var(--l1-border);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.trace-explorer-header.single-child {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.traces-explorer-views {
|
||||
padding: 8px;
|
||||
padding-bottom: 60px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.ant-tabs-tabpane {
|
||||
padding: 0 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.qb-search-view-container {
|
||||
padding: 8px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.ant-select-selector {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border) !important;
|
||||
background: var(--l2-background) !important;
|
||||
height: 34px !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
}
|
||||
|
||||
.trace-explorer-list-view {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.trace-explorer-traces-view {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.trace-explorer-table-view {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.trace-explorer-time-series-view {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.trace-explorer-page {
|
||||
display: flex;
|
||||
|
||||
// Meant to fix the query builder colors
|
||||
--input-background: var(--l2-background);
|
||||
--input-hover-background: var(--l2-background);
|
||||
--input-focus-background: var(--l2-background);
|
||||
--input-border-color: var(--l2-border);
|
||||
--input-hover-border-color: var(--internal-ant-border-color-hover);
|
||||
--input-focus-border-color: var(--internal-ant-border-color-hover);
|
||||
|
||||
.filter {
|
||||
width: 260px;
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
|
||||
border-right: 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background-color: var(--l1-background);
|
||||
|
||||
> .ant-card-body {
|
||||
padding: 0;
|
||||
width: 258px;
|
||||
}
|
||||
}
|
||||
|
||||
.trace-explorer {
|
||||
width: 100%;
|
||||
background: var(--l1-background);
|
||||
|
||||
> .ant-card-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
border-color: var(--l1-border);
|
||||
}
|
||||
.trace-explorer.filters-expanded {
|
||||
width: calc(100% - 260px);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,373 @@
|
||||
import styles from './Explorer.module.scss';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Card } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
|
||||
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import LeftToolbarActions from 'container/QueryBuilder/components/ToolbarActions/LeftToolbarActions';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
import Toolbar from 'container/Toolbar/Toolbar';
|
||||
import {
|
||||
getExportQueryData,
|
||||
getQueryByPanelType,
|
||||
} from 'container/TracesExplorer/explorerUtils';
|
||||
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
|
||||
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import {
|
||||
ICurrentQueryData,
|
||||
useHandleExplorerTabChange,
|
||||
} from 'hooks/useHandleExplorerTabChange';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
import {
|
||||
tracesAddFilterAction,
|
||||
tracesChangeViewAction,
|
||||
tracesRunQueryAction,
|
||||
tracesSaveViewAction,
|
||||
} from 'pages/TracesExplorer/aiActions';
|
||||
import { Warning } from 'types/api';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import {
|
||||
explorerViewToPanelType,
|
||||
getExplorerViewFromUrl,
|
||||
} from 'utils/explorerUtils';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { TOOLBAR_VIEWS } from './constants';
|
||||
import ListView from './ListView/ListView';
|
||||
import { defaultSelectedColumns } from './ListView/configs';
|
||||
import QuerySection from './QuerySection/QuerySection';
|
||||
import TableView from './TableView/TableView';
|
||||
import TimeSeriesView from './TimeSeriesView/TimeSeriesView';
|
||||
import TracesView from './TracesView/TracesView';
|
||||
|
||||
import './Explorer.styles.scss';
|
||||
|
||||
// Shell for the AI Observability Explorer tab. Owns the
|
||||
// /ai-observability/explorer route and is intentionally empty for now: the
|
||||
// query builder + results surface land in a follow-up.
|
||||
function Explorer(): JSX.Element {
|
||||
const {
|
||||
panelType,
|
||||
updateAllQueriesOperators,
|
||||
handleRunQuery,
|
||||
stagedQuery,
|
||||
handleSetConfig,
|
||||
currentQuery,
|
||||
handleSetQueryData,
|
||||
redirectWithQueryBuilderData,
|
||||
} = useQueryBuilder();
|
||||
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
|
||||
const { options } = useOptionsMenu({
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateOperator: 'noop',
|
||||
initialOptions: {
|
||||
selectColumns: defaultSelectedColumns,
|
||||
},
|
||||
});
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const listQueryKeyRef = useRef<any>();
|
||||
|
||||
// Get panel type from URL
|
||||
const panelTypesFromUrl = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
|
||||
const [isLoadingQueries, setIsLoadingQueries] = useState<boolean>(false);
|
||||
const [isCancelled, setIsCancelled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoadingQueries) {
|
||||
setIsCancelled(false);
|
||||
}
|
||||
}, [isLoadingQueries]);
|
||||
|
||||
const handleCancelQuery = useCallback(() => {
|
||||
if (listQueryKeyRef.current) {
|
||||
queryClient.cancelQueries(listQueryKeyRef.current);
|
||||
}
|
||||
setIsCancelled(true);
|
||||
// Reset loading state — the active view unmounts when cancelled, so no
|
||||
// child will call setIsLoadingQueries(false) otherwise.
|
||||
setIsLoadingQueries(false);
|
||||
}, [queryClient]);
|
||||
|
||||
const [selectedView, setSelectedView] = useState<ExplorerViews>(() =>
|
||||
getExplorerViewFromUrl(searchParams, panelTypesFromUrl),
|
||||
);
|
||||
|
||||
const [warning, setWarning] = useState<Warning | undefined>();
|
||||
const [isOpen, setOpen] = useState<boolean>(true);
|
||||
|
||||
const defaultQuery = useMemo(
|
||||
(): Query =>
|
||||
updateAllQueriesOperators(
|
||||
initialQueriesMap.traces,
|
||||
PANEL_TYPES.LIST,
|
||||
DataSource.TRACES,
|
||||
),
|
||||
[updateAllQueriesOperators],
|
||||
);
|
||||
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const getExportToDashboardLink = useGetExportToDashboardLink();
|
||||
|
||||
const handleChangeSelectedView = useCallback(
|
||||
(view: ExplorerViews, querySearchParameters?: ICurrentQueryData): void => {
|
||||
handleSetConfig(explorerViewToPanelType[view], DataSource.TRACES);
|
||||
|
||||
setSelectedView(view);
|
||||
|
||||
handleExplorerTabChange(
|
||||
explorerViewToPanelType[view],
|
||||
querySearchParameters,
|
||||
);
|
||||
},
|
||||
[handleExplorerTabChange, handleSetConfig],
|
||||
);
|
||||
|
||||
// ─── AI Assistant page actions (only when license feature is on) ───────────
|
||||
const aiActions = useMemo(
|
||||
() =>
|
||||
isAIAssistantEnabled
|
||||
? [
|
||||
tracesRunQueryAction({
|
||||
currentQuery,
|
||||
handleSetQueryData,
|
||||
redirectWithQueryBuilderData,
|
||||
}),
|
||||
tracesAddFilterAction({
|
||||
currentQuery,
|
||||
handleSetQueryData,
|
||||
redirectWithQueryBuilderData,
|
||||
}),
|
||||
tracesChangeViewAction({
|
||||
onChangeView: (view) => handleChangeSelectedView(view as ExplorerViews),
|
||||
}),
|
||||
tracesSaveViewAction({
|
||||
// POC stub — logs a save request; wire to real API when available
|
||||
onSaveView: async (name) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.info('[AI Assistant] Save view requested:', name);
|
||||
},
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
isAIAssistantEnabled,
|
||||
currentQuery,
|
||||
handleSetQueryData,
|
||||
redirectWithQueryBuilderData,
|
||||
handleChangeSelectedView,
|
||||
],
|
||||
);
|
||||
usePageActions('traces-explorer', aiActions);
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const exportDefaultQuery = useMemo(
|
||||
() =>
|
||||
getQueryByPanelType(
|
||||
stagedQuery || initialQueriesMap.traces,
|
||||
panelType || PANEL_TYPES.LIST,
|
||||
),
|
||||
[stagedQuery, panelType],
|
||||
);
|
||||
|
||||
const handleExport = useCallback(
|
||||
(dashboard: ExportDashboard | null, isNewDashboard?: boolean): void => {
|
||||
if (!dashboard || !panelType) {
|
||||
return;
|
||||
}
|
||||
|
||||
const panelTypeParam = AVAILABLE_EXPORT_PANEL_TYPES.includes(panelType)
|
||||
? panelType
|
||||
: PANEL_TYPES.TIME_SERIES;
|
||||
|
||||
const widgetId = v4();
|
||||
|
||||
const query = getExportQueryData(
|
||||
exportDefaultQuery,
|
||||
panelTypeParam,
|
||||
options,
|
||||
);
|
||||
|
||||
logEvent('Traces Explorer: Add to dashboard successful', {
|
||||
panelType,
|
||||
isNewDashboard,
|
||||
dashboardName: dashboard?.title,
|
||||
});
|
||||
|
||||
const dashboardEditView = getExportToDashboardLink({
|
||||
query,
|
||||
panelType: panelTypeParam,
|
||||
dashboardId: dashboard.id,
|
||||
widgetId,
|
||||
});
|
||||
|
||||
if (dashboardEditView) {
|
||||
safeNavigate(dashboardEditView);
|
||||
}
|
||||
},
|
||||
[
|
||||
exportDefaultQuery,
|
||||
panelType,
|
||||
safeNavigate,
|
||||
options,
|
||||
getExportToDashboardLink,
|
||||
],
|
||||
);
|
||||
|
||||
useShareBuilderUrl({ defaultValue: defaultQuery });
|
||||
|
||||
const logEventCalledRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!logEventCalledRef.current) {
|
||||
logEvent('Traces Explorer: Page visited', {});
|
||||
logEventCalledRef.current = true;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const isFilterApplied = useMemo(() => {
|
||||
// if any of the non-disabled queries has filters applied, return true
|
||||
const result = stagedQuery?.builder?.queryData?.filter(
|
||||
(item) => !isEmpty(item.filters?.items) && !item.disabled,
|
||||
);
|
||||
return !!result?.length;
|
||||
}, [stagedQuery]);
|
||||
|
||||
return (
|
||||
<div className={styles.explorer} data-testid="llm-observability-explorer">
|
||||
<div className={styles.placeholder}>Explorer coming soon.</div>
|
||||
</div>
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div
|
||||
className="trace-explorer-page"
|
||||
data-testid="llm-observability-explorer"
|
||||
>
|
||||
<Card className="filter" hidden={!isOpen}>
|
||||
<QuickFilters
|
||||
className="qf-traces-explorer"
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
signal={SignalType.TRACES}
|
||||
handleFilterVisibilityChange={(): void => {
|
||||
setOpen(!isOpen);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
<div
|
||||
className={cx('trace-explorer', {
|
||||
'filters-expanded': isOpen,
|
||||
})}
|
||||
>
|
||||
<div className="trace-explorer-header">
|
||||
<Toolbar
|
||||
showAutoRefresh
|
||||
leftActions={
|
||||
<LeftToolbarActions
|
||||
showFilter={isOpen}
|
||||
handleFilterVisibilityChange={(): void => setOpen(!isOpen)}
|
||||
items={TOOLBAR_VIEWS}
|
||||
selectedView={selectedView}
|
||||
onChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
}
|
||||
warningElement={
|
||||
!isEmpty(warning) ? <WarningPopover warningData={warning} /> : <div />
|
||||
}
|
||||
rightActions={
|
||||
<RightToolbarActions
|
||||
onStageRunQuery={(): void => {
|
||||
setIsCancelled(false);
|
||||
handleRunQuery();
|
||||
}}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<ExplorerCard sourcepage={DataSource.TRACES}>
|
||||
<div className="query-section-container">
|
||||
<QuerySection />
|
||||
</div>
|
||||
</ExplorerCard>
|
||||
|
||||
<div className="traces-explorer-views">
|
||||
{isCancelled && (
|
||||
<QueryCancelledPlaceholder subText='Click "Run Query" to load traces.' />
|
||||
)}
|
||||
|
||||
{!isCancelled && selectedView === ExplorerViews.LIST && (
|
||||
<div className="trace-explorer-list-view">
|
||||
<ListView
|
||||
isFilterApplied={isFilterApplied}
|
||||
setWarning={setWarning}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
queryKeyRef={listQueryKeyRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCancelled && selectedView === ExplorerViews.TRACE && (
|
||||
<div className="trace-explorer-traces-view">
|
||||
<TracesView
|
||||
isFilterApplied={isFilterApplied}
|
||||
setWarning={setWarning}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
queryKeyRef={listQueryKeyRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCancelled && selectedView === ExplorerViews.TIMESERIES && (
|
||||
<div className="trace-explorer-time-series-view">
|
||||
<TimeSeriesView
|
||||
dataSource={DataSource.TRACES}
|
||||
isFilterApplied={isFilterApplied}
|
||||
setWarning={setWarning}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
queryKeyRef={listQueryKeyRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCancelled && selectedView === ExplorerViews.TABLE && (
|
||||
<div className="trace-explorer-table-view">
|
||||
<TableView
|
||||
setWarning={setWarning}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
queryKeyRef={listQueryKeyRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ExplorerOptionWrapper
|
||||
disabled={!stagedQuery}
|
||||
query={exportDefaultQuery}
|
||||
sourcepage={DataSource.TRACES}
|
||||
onExport={handleExport}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: calc(100vh - 240px);
|
||||
min-height: 400px;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
.trace-explorer-controls {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.order-by-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.order-by-label {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 16px; /* 133.333% */
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.order-by-select {
|
||||
width: 100px;
|
||||
|
||||
.ant-select-selector {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import {
|
||||
Dispatch,
|
||||
memo,
|
||||
MutableRefObject,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { QueryKey } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
|
||||
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import TraceExplorerControls from 'container/TracesExplorer/Controls';
|
||||
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
|
||||
import {
|
||||
getTraceLink,
|
||||
transformSpanRows,
|
||||
} from 'container/TracesExplorer/ListView/utils';
|
||||
import {
|
||||
getFieldColumn,
|
||||
TracesTableRow,
|
||||
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Pagination } from 'hooks/queryPagination';
|
||||
import { getDefaultPaginationConfig } from 'hooks/queryPagination/utils';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import { ArrowUp10, Minus } from '@signozhq/icons';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import {
|
||||
defaultSelectedColumns,
|
||||
PER_PAGE_OPTIONS,
|
||||
TIMESTAMP_FIELD,
|
||||
} from './configs';
|
||||
import './ListView.styles.scss';
|
||||
|
||||
import styles from './ListView.module.scss';
|
||||
|
||||
interface ListViewProps {
|
||||
isFilterApplied: boolean;
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
|
||||
queryKeyRef?: MutableRefObject<QueryKey | undefined>;
|
||||
}
|
||||
|
||||
function ListView({
|
||||
isFilterApplied,
|
||||
setWarning,
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
}: ListViewProps): JSX.Element {
|
||||
const { stagedQuery, panelType: panelTypeFromQueryBuilder } =
|
||||
useQueryBuilder();
|
||||
|
||||
const panelType = panelTypeFromQueryBuilder || PANEL_TYPES.LIST;
|
||||
|
||||
const [orderBy, setOrderBy] = useState<string>('timestamp:desc');
|
||||
|
||||
const {
|
||||
selectedTime: globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
loading: timeRangeUpdateLoading,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
const { options, config } = useOptionsMenu({
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateOperator: 'count',
|
||||
initialOptions: {
|
||||
selectColumns: defaultSelectedColumns,
|
||||
},
|
||||
});
|
||||
|
||||
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
|
||||
QueryParams.pagination,
|
||||
);
|
||||
const paginationConfig =
|
||||
paginationQueryData ?? getDefaultPaginationConfig(PER_PAGE_OPTIONS);
|
||||
|
||||
const requestQuery = useMemo(
|
||||
() => getListViewQuery(stagedQuery || initialQueriesMap.traces, orderBy),
|
||||
[stagedQuery, orderBy],
|
||||
);
|
||||
|
||||
// Stable sorted-name signature for the queryKey.
|
||||
// - Drag updates selectColumns; raw queryKey would churn on reorder.
|
||||
// - Trace API fetches only listed columns → add/remove must refetch.
|
||||
// - Sorted-name signature: stable on reorder, changes on add/remove.
|
||||
const selectColumnsSignature = useMemo(
|
||||
() =>
|
||||
(options?.selectColumns ?? [])
|
||||
.map((c) => c.name)
|
||||
.sort()
|
||||
.join(','),
|
||||
[options?.selectColumns],
|
||||
);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => [
|
||||
REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationConfig,
|
||||
selectColumnsSignature,
|
||||
orderBy,
|
||||
],
|
||||
[
|
||||
stagedQuery,
|
||||
panelType,
|
||||
globalSelectedTime,
|
||||
paginationConfig,
|
||||
selectColumnsSignature,
|
||||
maxTime,
|
||||
minTime,
|
||||
orderBy,
|
||||
],
|
||||
);
|
||||
|
||||
if (queryKeyRef) {
|
||||
queryKeyRef.current = queryKey;
|
||||
}
|
||||
|
||||
const { data, isFetching, isLoading, isError, error } = useGetQueryRange(
|
||||
{
|
||||
query: requestQuery,
|
||||
graphType: panelType,
|
||||
selectedTime: 'GLOBAL_TIME' as const,
|
||||
globalSelectedInterval: globalSelectedTime as CustomTimeType,
|
||||
params: {
|
||||
dataSource: 'traces',
|
||||
},
|
||||
tableParams: {
|
||||
pagination: paginationConfig,
|
||||
selectColumns: options?.selectColumns,
|
||||
},
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
queryKey,
|
||||
enabled:
|
||||
// don't make api call while the time range state in redux is loading
|
||||
!timeRangeUpdateLoading &&
|
||||
!!stagedQuery &&
|
||||
panelType === PANEL_TYPES.LIST &&
|
||||
!!options?.selectColumns?.length,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.payload) {
|
||||
setWarning(data?.warning);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data?.payload, data?.warning]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isFetching) {
|
||||
setIsLoadingQueries(true);
|
||||
} else {
|
||||
setIsLoadingQueries(false);
|
||||
}
|
||||
}, [isLoading, isFetching, setIsLoadingQueries]);
|
||||
|
||||
const queryTableDataResult = data?.payload?.data?.newResult?.data?.result;
|
||||
const queryTableData = useMemo(
|
||||
() => queryTableDataResult || [],
|
||||
[queryTableDataResult],
|
||||
);
|
||||
|
||||
const columns = useMemo<TableColumnDef<TracesTableRow>[]>(() => {
|
||||
const fields = [
|
||||
TIMESTAMP_FIELD,
|
||||
...(options?.selectColumns ?? []).filter(
|
||||
(field) => field.name !== TIMESTAMP_FIELD.name,
|
||||
),
|
||||
];
|
||||
return fields.map((field) => getFieldColumn(field));
|
||||
}, [options?.selectColumns]);
|
||||
|
||||
const rows = useMemo(
|
||||
() => transformSpanRows(queryTableData),
|
||||
[queryTableData],
|
||||
);
|
||||
|
||||
const handleColumnOrderChange = useCallback(
|
||||
(reordered: TableColumnDef<TracesTableRow>[]): void => {
|
||||
config?.addColumn?.onReorder(reordered.map((column) => column.id));
|
||||
},
|
||||
[config],
|
||||
);
|
||||
|
||||
const handleOrderChange = useCallback((value: string) => {
|
||||
setOrderBy(value);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isFetching && !isError && rows.length !== 0) {
|
||||
void logEvent('Traces Explorer: Data present', {
|
||||
panelType,
|
||||
});
|
||||
}
|
||||
}, [isLoading, isFetching, isError, rows, panelType]);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className="trace-explorer-controls">
|
||||
<div className="order-by-container">
|
||||
<div className="order-by-label">
|
||||
Order by <Minus size={14} /> <ArrowUp10 size={14} />
|
||||
</div>
|
||||
|
||||
<ListViewOrderBy
|
||||
value={orderBy}
|
||||
onChange={handleOrderChange}
|
||||
dataSource={DataSource.TRACES}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DownloadOptionsMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
selectedColumns={options?.selectColumns}
|
||||
/>
|
||||
|
||||
<TraceExplorerControls
|
||||
isLoading={isFetching}
|
||||
totalCount={rows.length}
|
||||
config={config}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TracesTable
|
||||
data={rows}
|
||||
columns={columns}
|
||||
panelType="LIST"
|
||||
getRowHref={getTraceLink}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
isError={isError}
|
||||
error={error}
|
||||
isFilterApplied={isFilterApplied}
|
||||
onColumnOrderChange={handleColumnOrderChange}
|
||||
onColumnRemove={config?.addColumn?.onRemove}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ListView.defaultProps = {
|
||||
queryKeyRef: undefined,
|
||||
};
|
||||
|
||||
export default memo(ListView);
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
|
||||
|
||||
export const defaultSelectedColumns: string[] = [
|
||||
'service.name',
|
||||
'name',
|
||||
'duration_nano',
|
||||
'http_method',
|
||||
'response_status_code',
|
||||
'timestamp',
|
||||
];
|
||||
|
||||
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
|
||||
|
||||
// Pinned timestamp column
|
||||
export const TIMESTAMP_FIELD = {
|
||||
name: 'timestamp',
|
||||
fieldContext: 'span',
|
||||
} as TelemetryFieldKey;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ExplorerOrderBy from 'container/ExplorerOrderBy';
|
||||
import { OrderByFilterProps } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
function QuerySection(): JSX.Element {
|
||||
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
|
||||
|
||||
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(() => {
|
||||
const isList = panelTypes === PANEL_TYPES.LIST;
|
||||
const config: QueryBuilderProps['filterConfigs'] = {
|
||||
stepInterval: { isHidden: false, isDisabled: false },
|
||||
limit: { isHidden: isList, isDisabled: true },
|
||||
having: { isHidden: isList, isDisabled: true },
|
||||
};
|
||||
|
||||
return config;
|
||||
}, [panelTypes]);
|
||||
|
||||
const renderOrderBy = useCallback(
|
||||
({ query, onChange }: OrderByFilterProps) => (
|
||||
<ExplorerOrderBy query={query} onChange={onChange} />
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const queryComponents = useMemo((): QueryBuilderProps['queryComponents'] => {
|
||||
const shouldRenderCustomOrderBy =
|
||||
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE;
|
||||
|
||||
return {
|
||||
...(shouldRenderCustomOrderBy ? { renderOrderBy } : {}),
|
||||
};
|
||||
}, [panelTypes, renderOrderBy]);
|
||||
|
||||
const isListViewPanel = useMemo(
|
||||
() => panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE,
|
||||
[panelTypes],
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryBuilderV2
|
||||
isListViewPanel={isListViewPanel}
|
||||
showTraceOperator
|
||||
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
|
||||
queryComponents={queryComponents}
|
||||
panelType={panelTypes}
|
||||
filterConfigs={filterConfigs}
|
||||
showOnlyWhereClause={
|
||||
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE
|
||||
}
|
||||
version="v3" // setting this to v3 as we this is rendered in logs explorer
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(QuerySection);
|
||||
@@ -0,0 +1,7 @@
|
||||
.traces-table-view-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
Dispatch,
|
||||
memo,
|
||||
MutableRefObject,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Space } from 'antd';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import ExportMenu from 'components/ExportMenu/ExportMenu';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { QueryTable } from 'container/QueryTable';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import './TableView.styles.scss';
|
||||
|
||||
function TableView({
|
||||
setWarning,
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
}: {
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
|
||||
queryKeyRef?: MutableRefObject<any>;
|
||||
}): JSX.Element {
|
||||
const { stagedQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const {
|
||||
selectedTime: globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => [
|
||||
REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
stagedQuery,
|
||||
],
|
||||
[globalSelectedTime, maxTime, minTime, stagedQuery],
|
||||
);
|
||||
|
||||
if (queryKeyRef) {
|
||||
queryKeyRef.current = queryKey;
|
||||
}
|
||||
|
||||
const { data, isLoading, isFetching, isError, error } = useGetQueryRange(
|
||||
{
|
||||
query: stagedQuery || initialQueriesMap.traces,
|
||||
graphType: panelType || PANEL_TYPES.TABLE,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
globalSelectedInterval: globalSelectedTime,
|
||||
params: {
|
||||
dataSource: 'traces',
|
||||
},
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
queryKey,
|
||||
enabled: !!stagedQuery && panelType === PANEL_TYPES.TABLE,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isFetching) {
|
||||
setIsLoadingQueries(true);
|
||||
} else {
|
||||
setIsLoadingQueries(false);
|
||||
}
|
||||
}, [isLoading, isFetching, setIsLoadingQueries]);
|
||||
|
||||
const queryTableData = useMemo(
|
||||
() =>
|
||||
data?.payload?.data?.newResult?.data?.result ||
|
||||
data?.payload.data.result ||
|
||||
[],
|
||||
[data],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.payload) {
|
||||
setWarning(data.warning);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data?.payload, data?.warning]);
|
||||
|
||||
return (
|
||||
<Space.Compact block direction="vertical">
|
||||
{isError && error && <ErrorInPlace error={error as APIError} />}
|
||||
{!isError && data && (
|
||||
<div className="traces-table-view-header">
|
||||
<ExportMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
data={data}
|
||||
query={stagedQuery || initialQueriesMap.traces}
|
||||
fileName="traces-table"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!isError && (
|
||||
<QueryTable
|
||||
query={stagedQuery || initialQueriesMap.traces}
|
||||
queryTableData={queryTableData as QueryDataV3[]}
|
||||
loading={isLoading}
|
||||
sticky
|
||||
/>
|
||||
)}
|
||||
</Space.Compact>
|
||||
);
|
||||
}
|
||||
|
||||
TableView.defaultProps = {
|
||||
queryKeyRef: undefined,
|
||||
};
|
||||
|
||||
export default memo(TableView);
|
||||
@@ -0,0 +1,8 @@
|
||||
.trace-explorer-time-series-view-container {
|
||||
&-header {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
Dispatch,
|
||||
MutableRefObject,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
|
||||
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import useUrlYAxisUnit from 'hooks/useUrlYAxisUnit';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import './TimeSeriesView.styles.scss';
|
||||
|
||||
function TimeSeriesViewContainer({
|
||||
dataSource = DataSource.TRACES,
|
||||
isFilterApplied,
|
||||
setWarning,
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
}: TimeSeriesViewProps): JSX.Element {
|
||||
const { stagedQuery, currentQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const isValidToConvertToMs = useMemo(() => {
|
||||
const isValid: boolean[] = [];
|
||||
|
||||
currentQuery.builder.queryData.forEach(
|
||||
({ aggregateAttribute, aggregateOperator }) => {
|
||||
const isExistDurationNanoAttribute =
|
||||
aggregateAttribute?.key === 'durationNano' ||
|
||||
aggregateAttribute?.key === 'duration_nano';
|
||||
|
||||
const isCountOperator =
|
||||
aggregateOperator === 'count' || aggregateOperator === 'count_distinct';
|
||||
|
||||
isValid.push(!isCountOperator && isExistDurationNanoAttribute);
|
||||
},
|
||||
);
|
||||
|
||||
return isValid.every(Boolean);
|
||||
}, [currentQuery]);
|
||||
|
||||
const defaultUnit = isValidToConvertToMs ? 'ms' : 'short';
|
||||
const { yAxisUnit, onUnitChange } = useUrlYAxisUnit(defaultUnit);
|
||||
|
||||
const {
|
||||
selectedTime: globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => [
|
||||
REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
stagedQuery,
|
||||
],
|
||||
[globalSelectedTime, maxTime, minTime, stagedQuery],
|
||||
);
|
||||
|
||||
if (queryKeyRef) {
|
||||
queryKeyRef.current = queryKey;
|
||||
}
|
||||
|
||||
const { data, isLoading, isFetching, isError, error } = useGetQueryRange(
|
||||
{
|
||||
query: stagedQuery || initialQueriesMap[dataSource],
|
||||
graphType: panelType || PANEL_TYPES.TIME_SERIES,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
globalSelectedInterval: globalSelectedTime,
|
||||
params: {
|
||||
dataSource,
|
||||
},
|
||||
},
|
||||
// ENTITY_VERSION_V4,
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
queryKey,
|
||||
enabled: !!stagedQuery && panelType === PANEL_TYPES.TIME_SERIES,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.payload) {
|
||||
setWarning(data?.warning);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data?.payload, data?.warning]);
|
||||
|
||||
const responseData = useMemo(
|
||||
() => (isValidToConvertToMs ? convertDataValueToMs(data) : data),
|
||||
[data, isValidToConvertToMs],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isFetching) {
|
||||
setIsLoadingQueries(true);
|
||||
} else {
|
||||
setIsLoadingQueries(false);
|
||||
}
|
||||
}, [isLoading, isFetching, setIsLoadingQueries]);
|
||||
|
||||
return (
|
||||
<div className="trace-explorer-time-series-view-container">
|
||||
<TimeSeriesView
|
||||
isFilterApplied={isFilterApplied}
|
||||
isError={isError}
|
||||
error={error as APIError}
|
||||
isLoading={isLoading || isFetching}
|
||||
data={responseData}
|
||||
yAxisUnit={yAxisUnit}
|
||||
onYAxisUnitChange={onUnitChange}
|
||||
dataSource={dataSource}
|
||||
setWarning={setWarning}
|
||||
allowExport
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TimeSeriesViewProps {
|
||||
dataSource?: DataSource;
|
||||
isFilterApplied: boolean;
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
|
||||
queryKeyRef?: MutableRefObject<any>;
|
||||
}
|
||||
|
||||
TimeSeriesViewContainer.defaultProps = {
|
||||
dataSource: DataSource.TRACES,
|
||||
queryKeyRef: undefined,
|
||||
};
|
||||
|
||||
export default TimeSeriesViewContainer;
|
||||
@@ -0,0 +1,15 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
// Page chain isn't a flex column, so anchor the virtualized table against the viewport.
|
||||
height: calc(100vh - 240px);
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.actionsContainer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import {
|
||||
Dispatch,
|
||||
memo,
|
||||
MutableRefObject,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { QueryKey } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import TraceExplorerControls from 'container/TracesExplorer/Controls';
|
||||
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
|
||||
import { getTraceLink } from 'container/TracesExplorer/ListView/utils';
|
||||
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Pagination } from 'hooks/queryPagination';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import DOCLINKS from 'utils/docLinks';
|
||||
|
||||
import { columns, PER_PAGE_OPTIONS } from './configs';
|
||||
import styles from './TracesView.module.scss';
|
||||
|
||||
interface TracesViewProps {
|
||||
isFilterApplied: boolean;
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
|
||||
queryKeyRef?: MutableRefObject<QueryKey | undefined>;
|
||||
}
|
||||
|
||||
function TracesView({
|
||||
isFilterApplied,
|
||||
setWarning,
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
}: TracesViewProps): JSX.Element {
|
||||
const { stagedQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const {
|
||||
selectedTime: globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
|
||||
QueryParams.pagination,
|
||||
);
|
||||
|
||||
const transformedQuery = useMemo(
|
||||
() => getListViewQuery(stagedQuery || initialQueriesMap.traces),
|
||||
[stagedQuery],
|
||||
);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => [
|
||||
REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationQueryData,
|
||||
],
|
||||
[
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationQueryData,
|
||||
],
|
||||
);
|
||||
|
||||
if (queryKeyRef) {
|
||||
queryKeyRef.current = queryKey;
|
||||
}
|
||||
|
||||
const { data, isLoading, isFetching, isError, error } = useGetQueryRange(
|
||||
{
|
||||
query: transformedQuery,
|
||||
graphType: panelType || PANEL_TYPES.TRACE,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
globalSelectedInterval: globalSelectedTime,
|
||||
params: {
|
||||
dataSource: 'traces',
|
||||
},
|
||||
tableParams: {
|
||||
pagination: paginationQueryData,
|
||||
},
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
queryKey,
|
||||
enabled: !!stagedQuery && panelType === PANEL_TYPES.TRACE,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.payload) {
|
||||
setWarning(data?.warning);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data?.payload, data?.warning]);
|
||||
|
||||
const responseData = data?.payload?.data?.newResult?.data?.result[0]?.list;
|
||||
|
||||
const rows = useMemo<TracesTableRow[]>(
|
||||
() =>
|
||||
(responseData ?? []).map((item) => {
|
||||
const row = item.data;
|
||||
return { ...row, id: row.trace_id };
|
||||
}) as TracesTableRow[],
|
||||
[responseData],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isFetching) {
|
||||
setIsLoadingQueries(true);
|
||||
} else {
|
||||
setIsLoadingQueries(false);
|
||||
}
|
||||
}, [isLoading, isFetching, setIsLoadingQueries]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isFetching && !isError && rows.length !== 0) {
|
||||
void logEvent('Traces Explorer: Data present', {
|
||||
panelType: 'TRACE',
|
||||
});
|
||||
}
|
||||
}, [isLoading, isFetching, isError, rows.length]);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.actionsContainer}>
|
||||
<Typography>
|
||||
This tab only shows Root Spans. More details
|
||||
<Typography.Link href={DOCLINKS.TRACES_DETAILS_LINK} target="_blank">
|
||||
{' '}
|
||||
here
|
||||
</Typography.Link>
|
||||
</Typography>
|
||||
|
||||
<div className="trace-explorer-controls">
|
||||
<DownloadOptionsMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
panelType={PANEL_TYPES.TRACE}
|
||||
/>
|
||||
|
||||
<TraceExplorerControls
|
||||
isLoading={isLoading}
|
||||
totalCount={rows.length}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TracesTable
|
||||
data={rows}
|
||||
columns={columns}
|
||||
respectColumnOrder
|
||||
panelType="TRACE"
|
||||
getRowHref={getTraceLink}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
isError={isError}
|
||||
error={error}
|
||||
isFilterApplied={isFilterApplied}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TracesView.defaultProps = {
|
||||
queryKeyRef: undefined,
|
||||
};
|
||||
|
||||
export default memo(TracesView);
|
||||
@@ -0,0 +1,25 @@
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import {
|
||||
getFieldColumn,
|
||||
TracesTableRow,
|
||||
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
|
||||
|
||||
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
|
||||
|
||||
const TRACE_FIELDS = [
|
||||
{ name: 'service.name', fieldContext: 'resource' },
|
||||
{ name: 'name' },
|
||||
{ name: 'duration_nano' },
|
||||
{ name: 'span_count' },
|
||||
{ name: 'trace_id' },
|
||||
] as TelemetryFieldKey[];
|
||||
|
||||
export const columns: TableColumnDef<TracesTableRow>[] = TRACE_FIELDS.map(
|
||||
(field) => ({
|
||||
...getFieldColumn(field),
|
||||
enableRemove: false,
|
||||
canBeHidden: false,
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,36 @@
|
||||
export const TOOLBAR_VIEWS = {
|
||||
list: {
|
||||
name: 'list',
|
||||
label: 'List',
|
||||
show: true,
|
||||
key: 'list',
|
||||
},
|
||||
timeseries: {
|
||||
name: 'timeseries',
|
||||
label: 'Timeseries',
|
||||
disabled: false,
|
||||
show: true,
|
||||
key: 'timeseries',
|
||||
},
|
||||
trace: {
|
||||
name: 'trace',
|
||||
label: 'Trace',
|
||||
disabled: false,
|
||||
show: true,
|
||||
key: 'trace',
|
||||
},
|
||||
table: {
|
||||
name: 'table',
|
||||
label: 'Table',
|
||||
disabled: false,
|
||||
show: true,
|
||||
key: 'table',
|
||||
},
|
||||
clickhouse: {
|
||||
name: 'clickhouse',
|
||||
label: 'Clickhouse',
|
||||
disabled: false,
|
||||
show: false,
|
||||
key: 'clickhouse',
|
||||
},
|
||||
};
|
||||
@@ -18,6 +18,12 @@ jest.mock('pages/DashboardPageV2/DashboardContainer', () => ({
|
||||
default: (): JSX.Element => <div data-testid="llm-overview-dashboard" />,
|
||||
}));
|
||||
|
||||
// Same data-router gap as the dashboard above: the Explorer toolbar calls useNavigationType.
|
||||
jest.mock('container/LLMObservability/Explorer/Explorer', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="llm-observability-explorer" />,
|
||||
}));
|
||||
|
||||
function setupList(items = mockRules): void {
|
||||
server.use(
|
||||
rest.get(LLM_PRICING_ENDPOINT, (_req, res, ctx) =>
|
||||
|
||||
@@ -4,13 +4,6 @@
|
||||
padding: 0px 8px;
|
||||
|
||||
.logs-frequency-chart {
|
||||
.ant-card-body {
|
||||
height: 140px;
|
||||
min-height: 140px;
|
||||
padding: 0 16px 22px 16px;
|
||||
font-family: 'Geist Mono';
|
||||
}
|
||||
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,9 +15,8 @@ import {
|
||||
QUERY_BUILDER_FUNCTIONS,
|
||||
} from 'constants/antlrQueryConstants';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { useActiveLog } from 'hooks/logs/useActiveLog';
|
||||
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
|
||||
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { ICurrentQueryData } from 'hooks/useHandleExplorerTabChange';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
@@ -50,7 +49,7 @@ function BodyTitleRenderer({
|
||||
const { featureFlags } = useAppContext();
|
||||
const [, setCopy] = useCopyToClipboard();
|
||||
const { notifications } = useNotifications();
|
||||
const viewName = useGetSearchQueryParam(QueryParams.viewName) || '';
|
||||
const { viewName } = useGetSavedViewParams();
|
||||
|
||||
const cleanedNodeKey = removeObjectFromString(nodeKey);
|
||||
const isBodyJsonQueryEnabled =
|
||||
|
||||
@@ -7,13 +7,12 @@ import GroupByIcon from 'assets/CustomIcons/GroupByIcon';
|
||||
import cx from 'classnames';
|
||||
import CopyClipboardHOC from 'components/Logs/CopyClipboardHOC';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
import { RESTRICTED_SELECTED_FIELDS } from 'container/LogsFilters/config';
|
||||
import { MetricsType } from 'container/MetricsApplication/constant';
|
||||
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
|
||||
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { ICurrentQueryData } from 'hooks/useHandleExplorerTabChange';
|
||||
import {
|
||||
@@ -141,7 +140,7 @@ export default function TableViewActions(
|
||||
|
||||
const { pathname } = useLocation();
|
||||
const { stagedQuery, updateQueriesData } = useQueryBuilder();
|
||||
const viewName = useGetSearchQueryParam(QueryParams.viewName) || '';
|
||||
const { viewName } = useGetSavedViewParams();
|
||||
const { dataType, logType: fieldType } = getFieldAttributes(record.field);
|
||||
|
||||
// there is no option for where clause in old logs explorer and live logs page or infra monitoring
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { RESTRICTED_SELECTED_FIELDS } from 'container/LogsFilters/config';
|
||||
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
|
||||
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
|
||||
@@ -88,7 +88,7 @@ jest.mock('react-router-dom', () => ({
|
||||
}));
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder');
|
||||
jest.mock('hooks/queryBuilder/useGetSearchQueryParam');
|
||||
jest.mock('hooks/saveViews/useGetSavedViewParams');
|
||||
|
||||
describe('TableViewActions', () => {
|
||||
const TEST_VALUE = 'test value';
|
||||
@@ -140,8 +140,10 @@ describe('TableViewActions', () => {
|
||||
}),
|
||||
} as any);
|
||||
|
||||
// Default mock for useGetSearchQueryParam
|
||||
jest.mocked(useGetSearchQueryParam).mockReturnValue(null);
|
||||
// Default mock for useGetSavedViewParams
|
||||
jest
|
||||
.mocked(useGetSavedViewParams)
|
||||
.mockReturnValue({ viewName: '', viewKey: '' });
|
||||
});
|
||||
|
||||
it('should render without crashing', () => {
|
||||
@@ -249,7 +251,9 @@ describe('TableViewActions', () => {
|
||||
updateQueriesData: mockUpdateQueriesData,
|
||||
} as any);
|
||||
|
||||
jest.mocked(useGetSearchQueryParam).mockReturnValue(null);
|
||||
jest
|
||||
.mocked(useGetSavedViewParams)
|
||||
.mockReturnValue({ viewName: '', viewKey: '' });
|
||||
|
||||
render(
|
||||
<TableViewActions
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user