mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-15 04:40:43 +00:00
Compare commits
19 Commits
tvats-remo
...
feat/distr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b1c5c8f75 | ||
|
|
c7a5ac6291 | ||
|
|
0434768585 | ||
|
|
643921d4bf | ||
|
|
a1b97a1404 | ||
|
|
1b37430401 | ||
|
|
0d69faf6ae | ||
|
|
4f802cd077 | ||
|
|
5a491bd258 | ||
|
|
51b6570221 | ||
|
|
fd6dca4658 | ||
|
|
d8d64c5c1f | ||
|
|
02ed7d6a9c | ||
|
|
f5b1d71675 | ||
|
|
eb92d8dee0 | ||
|
|
083500731e | ||
|
|
6cc664d954 | ||
|
|
dfb906fe0a | ||
|
|
4b6f040e8a |
@@ -11,7 +11,7 @@ Page stories are written with the `signoz-page-story` skill ([`.claude/skills/si
|
||||
- **New page, new story. New state, new control or variant.** The skill decides which: a control on a response, a variant worth linking to, or a `play` story for a state only a click reaches.
|
||||
- **A shared component is covered through its pages.** A change under `components/` or `container/` with no story of its own updates the stories of the pages that render it.
|
||||
- **Plays live with the mocks.** A `play` is a named function exported from `stories/__story_mockdata__/<page>.play.ts`, which is collapsed for review like the rest of the AI-owned mocks. The story file only names it: `play: openEditDrawer`. A story file that still holds play bodies moves them there the first time you edit it.
|
||||
- **A play passes for every control value.** Branch each check on the args it depends on (panel kind, data state, access, check state and revoked permissions, license, held-open tooltips, a count at 0, a view or tab that drops the target, a write state). A value the page supports must never fail the play. The `signoz-page-story` skill's **Plays** section lists the cases plays have missed.
|
||||
- **A play passes for every control value.** Branch each check on the args it depends on (panel kind, data state, row count, hidden header). A value the page supports must never fail the play.
|
||||
- **Mocks answer every request a reachable state makes.** Selecting a row, opening a view or switching a tab that fetches more hits a handler, never the 501 catch-all.
|
||||
- **Already covered, say so.** When the existing story and its controls already reach the changed state, no story edit is needed; state that in the PR description.
|
||||
- **Opt out only when there is no visual diff.** Skip stories only when the change renders nothing differently: types, api clients, hooks with no UI branch, tests. Deleting a page deletes its stories.
|
||||
|
||||
@@ -106,27 +106,6 @@ await expect(toggle.getAttribute('aria-checked')).toBe(before);
|
||||
|
||||
**Delete a story once its difference disappears.** Say a story checks that a button reads "View" rather than "Edit" when a permission is missing. If the label later becomes the same either way, that story passes no matter which permissions are held, including all of them, and it is testing nothing. When a behaviour stops depending on a permission, delete the story or rewrite it to lock in the new behaviour, and say which one you did in the PR.
|
||||
|
||||
## Plays
|
||||
|
||||
CI runs each play once, with its story's own args. Anyone can flip a control in the panel, and nothing runs the play against that value, so a play that breaks on it goes unnoticed. Write each play so it cannot.
|
||||
|
||||
Before writing the clicks, list what has to be on screen for the play's target to exist, and return early when the args take it away. These are the cases plays have missed:
|
||||
|
||||
- The controls every story has. `State` on loading or error renders no rows and no filter suggestions. `Access`, `Permissions` and `Revoked` remove rows, row actions and gated buttons, and the `editor` and `viewer` presets grant telemetry reads only, so a page gated on its own resource shows them nothing. `Check state` on loading disables every gated control, and on error an `AuthZButton` stays enabled while an `AuthZGuardContent` set to fail closed hides what it wraps. `License` and `Banner` decide the plan, so a cloud-only form is gone on the others. `Hold tooltips open` shows a label a second time, inside its tooltip.
|
||||
- A count at 0. The page can swap its whole list for an empty state, and the header, filters and column picker go with it.
|
||||
- A view, filter or invalid query that leaves no rows, even when the count is above 0.
|
||||
- A tab without the widget. A filter box that one tab has is not on the next.
|
||||
- A second list beside the main one, such as saved views. At 0, loading or failed, its actions are gone.
|
||||
- Text that depends on an arg. A disabled rule offers Enable, not Disable. A write that fails raises a different toast from one that lands, and one left loading raises none. Pick the expected text from the args.
|
||||
- Rows that move. A view, a sort or pinning changes which row is locked, pinned or legacy, so find the row by what it is, not by its index.
|
||||
|
||||
Make those checks with the page's own code instead of restating a condition. Build the rows with the builder the handler uses and the view's own filter, and check a permission with the page's own check through `accessFor(...).allows(...)`, or the role against the constant the page gates on, such as `componentPermission.action`. Export the checks from the mocks module, next to the handlers, so a handler change moves them too. The alert rules page has `listedRules`, `hasRowMenu`, `showsNoRulesPage` and `listsSavedViews`; the dashboards list has `listedDashboards`, `canList`, `canCreate` and `canUpdate`.
|
||||
|
||||
Two habits cover the rest:
|
||||
|
||||
- Use `findAllBy*` and take the first whenever the text can appear twice, such as in a tooltip or a popover title.
|
||||
- Wrap `toBeVisible` in `waitFor` for anything that fades in. A tooltip or toast found the moment it mounts is not visible yet.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Default is the loaded page.** `export const Default: Story = {}` with no args, every widget carrying data. Empty, loading and failed are variants or control values, never the default.
|
||||
@@ -137,7 +116,7 @@ Two habits cover the rest:
|
||||
- **Endpoints the page owns go through `response.json`**, so the Data control covers loaded, loading and failed in one declaration. Endpoints the page cannot render without (ingestion detection, preferences, feature payloads) take a plain resolver so the shell survives the loading and error states.
|
||||
- **Query-param state starts from `route`** (`/logs?tab=explorer`). In-page param navigation works inside a story; a different pathname is blocked and reported by the overlay. A control for a param is worth it only when the param is a page mode someone would want to flip.
|
||||
- **File layout**: every story file for a page lives under `src/pages/<Page>/stories/`: `<Page>.stories.tsx`, `<Page>.stories.mocks.tsx`, payload builders in `stories/__story_mockdata__/<page>.ts`, play functions in `stories/__story_mockdata__/<page>.play.ts`. Nothing page-specific in `src/storybook/controls/`. A page that is a tab strip over several routes gets one story file per tab, in its own folder under the module page (`LogsModulePage/Pipelines/stories/Pipelines.stories.tsx`), each with its own mocks and `__story_mockdata__/`; the builders more than one tab needs stay in the module page's own `stories/__story_mockdata__/` (`AlertList/stories/__story_mockdata__/alerts.ts`), which a tab reaches as `../../stories/__story_mockdata__/alerts`. Every one of them renders the module page, so the tab strip is there, and the `route` its mocks return decides which tab is open. A page's permission stories go one level further down, in `stories/authz/<Page>.authz.stories.tsx`, on the page's own mocks: see **Permission stories**.
|
||||
- **A state only a click reaches is a story with a `play` function**, not a control: a drawer, a modal, an edit mode the page holds in component state. Drive it with `userEvent` and the queries from `storybook/test`, take the first of a repeated row action, and wait on the state's own text. The page fetches before it renders a row, so the finder needs a timeout past the 1s default. The play is a named function exported from `__story_mockdata__/<page>.play.ts` and the story file only names it (`play: openEditDrawer`), so the reviewer reads states, not clicks; a story file that still holds play bodies moves them there the first time you edit it. It branches every check on the args it depends on: a control value the page supports never fails it. See **Plays**. A state the app drops again on its own, such as one keyed on an array identity that a refetch replaces, does not get a story: it would not survive being looked at. A _sequence_ of such states, a wizard's steps or a questionnaire's pages, is still a control: declare the steps in the mocks module and walk them from a `play` on the meta that destructures `mount`, which is what makes Storybook replay it on an arg change. See [references/controls.md](references/controls.md).
|
||||
- **A state only a click reaches is a story with a `play` function**, not a control: a drawer, a modal, an edit mode the page holds in component state. Drive it with `userEvent` and the queries from `storybook/test`, take the first of a repeated row action, and wait on the state's own text. The page fetches before it renders a row, so the finder needs a timeout past the 1s default. The play is a named function exported from `__story_mockdata__/<page>.play.ts` and the story file only names it (`play: openEditDrawer`), so the reviewer reads states, not clicks; a story file that still holds play bodies moves them there the first time you edit it. It branches every check on the args it depends on: a control value the page supports never fails it. A state the app drops again on its own, such as one keyed on an array identity that a refetch replaces, does not get a story: it would not survive being looked at. A _sequence_ of such states, a wizard's steps or a questionnaire's pages, is still a control: declare the steps in the mocks module and walk them from a `play` on the meta that destructures `mount`, which is what makes Storybook replay it on an arg change. See [references/controls.md](references/controls.md).
|
||||
- **The mocks are AI-owned and say so.** `<Page>.stories.mocks.tsx` and every file under a `__story_mockdata__/` open with this banner, above the imports:
|
||||
|
||||
```ts
|
||||
@@ -164,7 +143,7 @@ Two habits cover the rest:
|
||||
- [ ] a page tagged `authz` has its `Authz` folder: one story per permission it reads, each reached by `revoked`, none of them a role preset, and each one checked by the `disabled` state of what the permission gates
|
||||
- [ ] the mocks module and every `__story_mockdata__` file carry the AI-owned banner
|
||||
- [ ] every control flipped once, its effect seen on screen
|
||||
- [ ] every play passes with each control value its story exposes, checked against the cases in **Plays**, and the story file holds no play body
|
||||
- [ ] every play passes with each control value its story exposes, and the story file holds no play body
|
||||
- [ ] every request a reachable state makes (a selected row, an opened view, a switched tab) has a handler
|
||||
- [ ] console clean: no `[storybook] no msw handler`, no 501, no msw unhandled request, no React warning
|
||||
- [ ] no navigation overlay on mount
|
||||
|
||||
@@ -67,7 +67,7 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
telemetrystore-migrator:
|
||||
image: signoz/signoz-otel-collector:v0.144.15
|
||||
image: signoz/signoz-otel-collector:v0.144.6
|
||||
container_name: telemetrystore-migrator
|
||||
environment:
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
signoz-otel-collector:
|
||||
image: signoz/signoz-otel-collector:v0.144.15
|
||||
image: signoz/signoz-otel-collector:v0.142.0
|
||||
container_name: signoz-otel-collector-dev
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
|
||||
54
.github/workflows/build-staging.yaml
vendored
54
.github/workflows/build-staging.yaml
vendored
@@ -18,25 +18,25 @@ env:
|
||||
jobs:
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name == 'push' || startsWith(github.event.label.name, 'staging:') }}
|
||||
if: ${{ contains(github.event.label.name, 'staging:') || github.event.ref == 'refs/heads/main' }}
|
||||
outputs:
|
||||
version: ${{ steps.build-info.outputs.version }}
|
||||
hash: ${{ steps.build-info.outputs.hash }}
|
||||
time: ${{ steps.build-info.outputs.time }}
|
||||
branch: ${{ steps.build-info.outputs.branch }}
|
||||
path: ${{ steps.build-info.outputs.path }}
|
||||
deployment: ${{ steps.build-info.outputs.deployment }}
|
||||
steps:
|
||||
- name: self-checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@v4
|
||||
- id: token
|
||||
name: github-token-gen
|
||||
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.PRIMUS_APP_ID }}
|
||||
private-key: ${{ secrets.PRIMUS_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
- name: primus-checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: signoz/primus
|
||||
ref: main
|
||||
@@ -44,29 +44,22 @@ jobs:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: build-info
|
||||
id: build-info
|
||||
env:
|
||||
LABEL: ${{ github.event.label.name }}
|
||||
run: |
|
||||
# merges to main release the base every overlay inherits, a staging:<deployment> label releases that overlay
|
||||
if [[ "${GITHUB_EVENT_NAME}" == push ]]; then
|
||||
path="overlays/staging/*/signoz/base"
|
||||
else
|
||||
# the deployment is spliced into the release path glob and the JSON payload
|
||||
deployment="${LABEL#staging:}"
|
||||
if [[ ! "${deployment}" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]]; then
|
||||
echo "::error::label ${LABEL} must be staging:<deployment>, where the deployment is lowercase letters, digits and dashes"
|
||||
exit 1
|
||||
fi
|
||||
path="overlays/staging/*/signoz/overlays/${deployment}"
|
||||
fi
|
||||
echo "version=$($MAKE info-version)" >> $GITHUB_OUTPUT
|
||||
echo "hash=$($MAKE info-commit-short)" >> $GITHUB_OUTPUT
|
||||
echo "time=$($MAKE info-timestamp)" >> $GITHUB_OUTPUT
|
||||
echo "branch=$($MAKE info-branch)" >> $GITHUB_OUTPUT
|
||||
|
||||
{
|
||||
echo "version=$($MAKE info-version)"
|
||||
echo "hash=$($MAKE info-commit-short)"
|
||||
echo "time=$($MAKE info-timestamp)"
|
||||
echo "branch=$($MAKE info-branch)"
|
||||
echo "path=${path}"
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
staging_label="${{ github.event.label.name }}"
|
||||
if [[ "${staging_label}" == "staging:"* ]]; then
|
||||
deployment=${staging_label#"staging:"}
|
||||
elif [[ "${{ github.event.ref }}" == "refs/heads/main" ]]; then
|
||||
deployment="staging"
|
||||
else
|
||||
echo "error: not able to determine deployment - please verify the PR label or the branch"
|
||||
exit 1
|
||||
fi
|
||||
echo "deployment=${deployment}" >> $GITHUB_OUTPUT
|
||||
- name: create-dotenv
|
||||
run: |
|
||||
mkdir -p frontend
|
||||
@@ -78,7 +71,7 @@ jobs:
|
||||
echo 'VITE_ENVIRONMENT="staging"' >> frontend/.env
|
||||
echo 'VITE_VERSION="${{ steps.build-info.outputs.version }}"' >> frontend/.env
|
||||
- name: cache-dotenv
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: frontend/.env
|
||||
key: staging-dotenv-${{ github.sha }}
|
||||
@@ -122,6 +115,7 @@ jobs:
|
||||
DOCKER_MANIFEST: true
|
||||
DOCKER_PROVIDERS: gcp
|
||||
staging:
|
||||
if: ${{ contains(github.event.label.name, 'staging:') || github.event.ref == 'refs/heads/main' }}
|
||||
uses: signoz/primus.workflows/.github/workflows/github-trigger.yaml@main
|
||||
secrets: inherit
|
||||
needs: [prepare, go-build]
|
||||
@@ -129,6 +123,6 @@ jobs:
|
||||
PRIMUS_REF: main
|
||||
GITHUB_ENVIRONMENT: staging
|
||||
GITHUB_SILENT: true
|
||||
GITHUB_REPOSITORY_NAME: signoz.staging.cl.gcp.deployments
|
||||
GITHUB_EVENT_NAME: release
|
||||
GITHUB_EVENT_PAYLOAD: '{"path": "${{ needs.prepare.outputs.path }}", "image": "us-central1-docker.pkg.dev/signoz-registry/signoz/signoz", "tag": "${{ needs.prepare.outputs.version }}"}'
|
||||
GITHUB_REPOSITORY_NAME: charts-saas-v3-staging
|
||||
GITHUB_EVENT_NAME: releaser
|
||||
GITHUB_EVENT_PAYLOAD: '{"deployment": "${{ needs.prepare.outputs.deployment }}", "signoz_version": "${{ needs.prepare.outputs.version }}"}'
|
||||
|
||||
2
.github/workflows/e2eci.yaml
vendored
2
.github/workflows/e2eci.yaml
vendored
@@ -88,7 +88,7 @@ jobs:
|
||||
postgres:15 \
|
||||
clickhouse/clickhouse-server:25.12.5 \
|
||||
clickhouse/clickhouse-keeper:25.12.5 \
|
||||
signoz/signoz-schema-migrator:v0.144.15 \
|
||||
signoz/signoz-schema-migrator:v0.144.9 \
|
||||
wiremock/wiremock:2.35.1-1 \
|
||||
| xargs -P 0 -n 1 docker pull -q
|
||||
- name: python
|
||||
|
||||
2
.github/workflows/integrationci.yaml
vendored
2
.github/workflows/integrationci.yaml
vendored
@@ -84,7 +84,7 @@ jobs:
|
||||
clickhouse-version:
|
||||
- 25.12.5
|
||||
schema-migrator-version:
|
||||
- v0.144.15
|
||||
- v0.144.6
|
||||
postgres-version:
|
||||
- 15
|
||||
if: |
|
||||
|
||||
@@ -4517,6 +4517,99 @@ components:
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
DashboardtypesDistributionAxes:
|
||||
properties:
|
||||
x:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionXAxis'
|
||||
description: The bucket axis.
|
||||
"y":
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionYAxis'
|
||||
description: The count axis.
|
||||
type: object
|
||||
DashboardtypesDistributionChartAppearance:
|
||||
properties:
|
||||
percentiles:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionPercentiles'
|
||||
description: Percentile markers drawn across the bucket axis.
|
||||
stack:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesStackMode'
|
||||
description: 'How groups combine in a bucket: normal (default) stacks them,
|
||||
none draws them side by side, percent stacks them as shares of the bucket.'
|
||||
required:
|
||||
- stack
|
||||
type: object
|
||||
DashboardtypesDistributionPanelSpec:
|
||||
properties:
|
||||
axes:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionAxes'
|
||||
description: The X (bucket) axis range and Y (count) axis scale.
|
||||
chartAppearance:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionChartAppearance'
|
||||
description: Stacking and percentile markers.
|
||||
formatting:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelFormatting'
|
||||
description: Unit and decimal precision of the bucket bounds; counts take
|
||||
no unit.
|
||||
legend:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesLegend'
|
||||
description: Legend placement and series colors.
|
||||
visualization:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesBasicVisualization'
|
||||
description: The time range the bucket counts are summed over.
|
||||
type: object
|
||||
DashboardtypesDistributionPercentiles:
|
||||
properties:
|
||||
enabled:
|
||||
description: Whether percentile markers are drawn. Defaults to true.
|
||||
type: boolean
|
||||
values:
|
||||
description: The percentiles to mark, each in (0, 100), at most 5, no duplicates.
|
||||
Defaults to [50, 90, 99].
|
||||
items:
|
||||
format: double
|
||||
type: number
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- enabled
|
||||
- values
|
||||
type: object
|
||||
DashboardtypesDistributionXAxis:
|
||||
properties:
|
||||
max:
|
||||
description: Buckets wholly above this value are hidden. Null shows them
|
||||
all.
|
||||
nullable: true
|
||||
type: number
|
||||
min:
|
||||
description: Buckets wholly below this value are hidden. Null shows them
|
||||
all.
|
||||
nullable: true
|
||||
type: number
|
||||
type: object
|
||||
DashboardtypesDistributionYAxis:
|
||||
properties:
|
||||
scale:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionYScale'
|
||||
description: 'How counts are spaced: linear (default) or log.'
|
||||
required:
|
||||
- scale
|
||||
type: object
|
||||
DashboardtypesDistributionYScale:
|
||||
enum:
|
||||
- linear
|
||||
- log
|
||||
type: string
|
||||
DashboardtypesDynamicVariableSignal:
|
||||
enum:
|
||||
- traces
|
||||
@@ -5287,6 +5380,7 @@ components:
|
||||
mapping:
|
||||
signoz/AreaChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec'
|
||||
signoz/BarChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
|
||||
signoz/DistributionPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpec'
|
||||
signoz/HeatmapPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
signoz/HistogramPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
|
||||
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
@@ -5309,6 +5403,7 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpec'
|
||||
type: object
|
||||
DashboardtypesPanelPluginKind:
|
||||
enum:
|
||||
@@ -5323,6 +5418,7 @@ components:
|
||||
- signoz/TextPanel
|
||||
- signoz/HeatmapPanel
|
||||
- signoz/ScatterPlotPanel
|
||||
- signoz/DistributionPanel
|
||||
type: string
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
|
||||
properties:
|
||||
@@ -5354,6 +5450,21 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
description: The panel kind.
|
||||
enum:
|
||||
- signoz/DistributionPanel
|
||||
type: string
|
||||
spec:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionPanelSpec'
|
||||
description: The options for this panel kind. {} accepts every default.
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
@@ -5507,9 +5618,9 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPlugin'
|
||||
description: 'The visualization: kind is one of signoz/TimeSeriesPanel,
|
||||
signoz/BarChartPanel, signoz/AreaChartPanel, signoz/NumberPanel, signoz/PieChartPanel,
|
||||
signoz/TablePanel, signoz/HistogramPanel, signoz/ListPanel, signoz/TextPanel
|
||||
or signoz/HeatmapPanel, and spec holds that kind''s options ({} accepts
|
||||
every default).'
|
||||
signoz/TablePanel, signoz/HistogramPanel, signoz/ListPanel, signoz/TextPanel,
|
||||
signoz/HeatmapPanel or signoz/DistributionPanel, and spec holds that kind''s
|
||||
options ({} accepts every default).'
|
||||
queries:
|
||||
description: The queries the panel runs. Use exactly one entry for a data
|
||||
panel and [] for signoz/TextPanel. To run several named queries or formulas,
|
||||
@@ -5645,8 +5756,8 @@ components:
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5RequestType'
|
||||
description: 'The result shape the panel requests: time_series for time
|
||||
series, bar, area and histogram panels, scalar for number, table and pie
|
||||
panels, raw for list panels, heatmap for heatmap panels, trace for trace
|
||||
panels.'
|
||||
panels, raw for list panels, heatmap for heatmap panels, distribution
|
||||
for distribution panels, trace for trace panels.'
|
||||
spec:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesQuerySpec'
|
||||
@@ -5803,9 +5914,9 @@ components:
|
||||
(builder query over AI observability traces), signoz/CompositeQuery (several
|
||||
named queries and formulas), signoz/Formula, signoz/PromQLQuery, signoz/ClickHouseSQL
|
||||
or signoz/TraceOperator, and spec holds that kind''s query. Pie and table
|
||||
panels reject signoz/PromQLQuery; heatmap panels reject signoz/AIBuilderQuery
|
||||
and signoz/TraceOperator; list panels accept only signoz/BuilderQuery
|
||||
and signoz/AIBuilderQuery.'
|
||||
panels reject signoz/PromQLQuery; heatmap and distribution panels reject
|
||||
signoz/AIBuilderQuery and signoz/TraceOperator; list panels accept only
|
||||
signoz/BuilderQuery and signoz/AIBuilderQuery.'
|
||||
required:
|
||||
- plugin
|
||||
type: object
|
||||
@@ -25613,6 +25724,7 @@ paths:
|
||||
low (z-score {{$value}}).
|
||||
summary: Span ingestion anomaly
|
||||
condition:
|
||||
algorithm: standard
|
||||
compositeQuery:
|
||||
panelType: graph
|
||||
queries:
|
||||
@@ -26279,23 +26391,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- rule:create
|
||||
- rule:attach
|
||||
- notification-channel:attach
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- rule:create
|
||||
- rule:attach
|
||||
- notification-channel:attach
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- EDITOR
|
||||
summary: Create alert rule
|
||||
tags:
|
||||
- rules
|
||||
@@ -26340,9 +26438,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- rule:delete
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- rule:delete
|
||||
- EDITOR
|
||||
summary: Delete alert rule
|
||||
tags:
|
||||
- rules
|
||||
@@ -26398,9 +26496,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- rule:read
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- rule:read
|
||||
- VIEWER
|
||||
summary: Get alert rule by ID
|
||||
tags:
|
||||
- rules
|
||||
@@ -26582,6 +26680,7 @@ paths:
|
||||
low (z-score {{$value}}).
|
||||
summary: Span ingestion anomaly
|
||||
condition:
|
||||
algorithm: standard
|
||||
compositeQuery:
|
||||
panelType: graph
|
||||
queries:
|
||||
@@ -27254,27 +27353,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- rule:update
|
||||
- rule:attach
|
||||
- rule:detach
|
||||
- notification-channel:attach
|
||||
- notification-channel:detach
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- rule:update
|
||||
- rule:attach
|
||||
- rule:detach
|
||||
- notification-channel:attach
|
||||
- notification-channel:detach
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- EDITOR
|
||||
summary: Patch alert rule
|
||||
tags:
|
||||
- rules
|
||||
@@ -27456,6 +27537,7 @@ paths:
|
||||
low (z-score {{$value}}).
|
||||
summary: Span ingestion anomaly
|
||||
condition:
|
||||
algorithm: standard
|
||||
compositeQuery:
|
||||
panelType: graph
|
||||
queries:
|
||||
@@ -28116,27 +28198,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- rule:update
|
||||
- rule:attach
|
||||
- rule:detach
|
||||
- notification-channel:attach
|
||||
- notification-channel:detach
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- rule:update
|
||||
- rule:attach
|
||||
- rule:detach
|
||||
- notification-channel:attach
|
||||
- notification-channel:detach
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- EDITOR
|
||||
summary: Update alert rule
|
||||
tags:
|
||||
- rules
|
||||
@@ -28203,25 +28267,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- rule:read
|
||||
- rule:create
|
||||
- rule:attach
|
||||
- notification-channel:attach
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- rule:read
|
||||
- rule:create
|
||||
- rule:attach
|
||||
- notification-channel:attach
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- EDITOR
|
||||
summary: Clone alert rule
|
||||
tags:
|
||||
- rules
|
||||
@@ -29108,6 +29156,7 @@ paths:
|
||||
low (z-score {{$value}}).
|
||||
summary: Span ingestion anomaly
|
||||
condition:
|
||||
algorithm: standard
|
||||
compositeQuery:
|
||||
panelType: graph
|
||||
queries:
|
||||
@@ -29774,23 +29823,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- rule:create
|
||||
- rule:attach
|
||||
- notification-channel:attach
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- rule:create
|
||||
- rule:attach
|
||||
- notification-channel:attach
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- EDITOR
|
||||
summary: Test alert rule
|
||||
tags:
|
||||
- rules
|
||||
@@ -31885,9 +31920,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- rule:list
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- rule:list
|
||||
- VIEWER
|
||||
summary: List alert rules (v3)
|
||||
tags:
|
||||
- rules
|
||||
|
||||
@@ -256,7 +256,7 @@ Tests can be configured using pytest options:
|
||||
- `--sqlite-mode` — SQLite journal mode: `delete` or `wal` (default: `delete`). Only relevant when `--sqlstore-provider=sqlite`.
|
||||
- `--postgres-version` — PostgreSQL version (default: `15`)
|
||||
- `--clickhouse-version` — ClickHouse version, also used for ClickHouse Keeper (default: `25.12.5`)
|
||||
- `--schema-migrator-version` — SigNoz schema migrator version (default: `v0.144.15`)
|
||||
- `--schema-migrator-version` — SigNoz schema migrator version (default: `v0.144.9`)
|
||||
- `--with-web` — Build the frontend into the SigNoz image (required for e2e)
|
||||
|
||||
Example:
|
||||
|
||||
@@ -56,6 +56,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
signoz.SQLStore,
|
||||
integrationsController.GetPipelinesForInstalledIntegrations,
|
||||
reader,
|
||||
signoz.Flagger,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -11,7 +11,7 @@ const config: Config.InitialOptions = {
|
||||
coverageReporters: ['text', 'cobertura', 'html', 'json-summary'],
|
||||
collectCoverageFrom: ['src/**/*.{ts,tsx}'],
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'json'],
|
||||
modulePathIgnorePatterns: ['dist'],
|
||||
modulePathIgnorePatterns: ['<rootDir>/dist/'],
|
||||
moduleNameMapper: {
|
||||
'\\.(png|jpg|jpeg|gif|svg|webp|avif|ico|bmp|tiff)$':
|
||||
'<rootDir>/__mocks__/fileMock.ts',
|
||||
|
||||
@@ -6943,6 +6943,107 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
|
||||
spec: DashboardtypesScatterPlotPanelSpecDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel kind.
|
||||
*/
|
||||
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpecDTOKind {
|
||||
'signoz/DistributionPanel' = 'signoz/DistributionPanel',
|
||||
}
|
||||
export interface DashboardtypesDistributionXAxisDTO {
|
||||
/**
|
||||
* @type number,null
|
||||
* @description Buckets wholly above this value are hidden. Null shows them all.
|
||||
*/
|
||||
max?: number | null;
|
||||
/**
|
||||
* @type number,null
|
||||
* @description Buckets wholly below this value are hidden. Null shows them all.
|
||||
*/
|
||||
min?: number | null;
|
||||
}
|
||||
|
||||
export enum DashboardtypesDistributionYScaleDTO {
|
||||
linear = 'linear',
|
||||
log = 'log',
|
||||
}
|
||||
export interface DashboardtypesDistributionYAxisDTO {
|
||||
/**
|
||||
* @description How counts are spaced: linear (default) or log.
|
||||
*/
|
||||
scale: DashboardtypesDistributionYScaleDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesDistributionAxesDTO {
|
||||
/**
|
||||
* @description The bucket axis.
|
||||
*/
|
||||
x?: DashboardtypesDistributionXAxisDTO;
|
||||
/**
|
||||
* @description The count axis.
|
||||
*/
|
||||
y?: DashboardtypesDistributionYAxisDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesDistributionPercentilesDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
* @description Whether percentile markers are drawn. Defaults to true.
|
||||
*/
|
||||
enabled: boolean;
|
||||
/**
|
||||
* @type array,null
|
||||
* @description The percentiles to mark, each in (0, 100), at most 5, no duplicates. Defaults to [50, 90, 99].
|
||||
*/
|
||||
values: number[] | null;
|
||||
}
|
||||
|
||||
export interface DashboardtypesDistributionChartAppearanceDTO {
|
||||
/**
|
||||
* @description Percentile markers drawn across the bucket axis.
|
||||
*/
|
||||
percentiles?: DashboardtypesDistributionPercentilesDTO;
|
||||
/**
|
||||
* @description How groups combine in a bucket: normal (default) stacks them, none draws them side by side, percent stacks them as shares of the bucket.
|
||||
*/
|
||||
stack: DashboardtypesStackModeDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesDistributionPanelSpecDTO {
|
||||
/**
|
||||
* @description The X (bucket) axis range and Y (count) axis scale.
|
||||
*/
|
||||
axes?: DashboardtypesDistributionAxesDTO;
|
||||
/**
|
||||
* @description Stacking and percentile markers.
|
||||
*/
|
||||
chartAppearance?: DashboardtypesDistributionChartAppearanceDTO;
|
||||
/**
|
||||
* @description Unit and decimal precision of the bucket bounds; counts take no unit.
|
||||
*/
|
||||
formatting?: DashboardtypesPanelFormattingDTO;
|
||||
/**
|
||||
* @description Legend placement and series colors.
|
||||
*/
|
||||
legend?: DashboardtypesLegendDTO;
|
||||
/**
|
||||
* @description The time range the bucket counts are summed over.
|
||||
*/
|
||||
visualization?: DashboardtypesBasicVisualizationDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpecDTO {
|
||||
/**
|
||||
* @enum signoz/DistributionPanel
|
||||
* @type string
|
||||
* @description The panel kind.
|
||||
*/
|
||||
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpecDTOKind;
|
||||
/**
|
||||
* @description The options for this panel kind. {} accepts every default.
|
||||
*/
|
||||
spec: DashboardtypesDistributionPanelSpecDTO;
|
||||
}
|
||||
|
||||
export type DashboardtypesPanelPluginDTO =
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
|
||||
@@ -6954,7 +7055,8 @@ export type DashboardtypesPanelPluginDTO =
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO;
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpecDTO;
|
||||
|
||||
export enum Querybuildertypesv5RequestTypeDTO {
|
||||
scalar = 'scalar',
|
||||
@@ -7443,14 +7545,14 @@ export interface DashboardtypesQuerySpecDTO {
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @description The query: kind is one of signoz/BuilderQuery, signoz/AIBuilderQuery (builder query over AI observability traces), signoz/CompositeQuery (several named queries and formulas), signoz/Formula, signoz/PromQLQuery, signoz/ClickHouseSQL or signoz/TraceOperator, and spec holds that kind's query. Pie and table panels reject signoz/PromQLQuery; heatmap panels reject signoz/AIBuilderQuery and signoz/TraceOperator; list panels accept only signoz/BuilderQuery and signoz/AIBuilderQuery.
|
||||
* @description The query: kind is one of signoz/BuilderQuery, signoz/AIBuilderQuery (builder query over AI observability traces), signoz/CompositeQuery (several named queries and formulas), signoz/Formula, signoz/PromQLQuery, signoz/ClickHouseSQL or signoz/TraceOperator, and spec holds that kind's query. Pie and table panels reject signoz/PromQLQuery; heatmap and distribution panels reject signoz/AIBuilderQuery and signoz/TraceOperator; list panels accept only signoz/BuilderQuery and signoz/AIBuilderQuery.
|
||||
*/
|
||||
plugin: DashboardtypesQueryPluginDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesQueryDTO {
|
||||
/**
|
||||
* @description The result shape the panel requests: time_series for time series, bar, area and histogram panels, scalar for number, table and pie panels, raw for list panels, heatmap for heatmap panels, trace for trace panels.
|
||||
* @description The result shape the panel requests: time_series for time series, bar, area and histogram panels, scalar for number, table and pie panels, raw for list panels, heatmap for heatmap panels, distribution for distribution panels, trace for trace panels.
|
||||
*/
|
||||
kind: Querybuildertypesv5RequestTypeDTO;
|
||||
/**
|
||||
@@ -7470,7 +7572,7 @@ export interface DashboardtypesPanelSpecDTO {
|
||||
*/
|
||||
links?: DashboardtypesLinkDTO[] | null;
|
||||
/**
|
||||
* @description The visualization: kind is one of signoz/TimeSeriesPanel, signoz/BarChartPanel, signoz/AreaChartPanel, signoz/NumberPanel, signoz/PieChartPanel, signoz/TablePanel, signoz/HistogramPanel, signoz/ListPanel, signoz/TextPanel or signoz/HeatmapPanel, and spec holds that kind's options ({} accepts every default).
|
||||
* @description The visualization: kind is one of signoz/TimeSeriesPanel, signoz/BarChartPanel, signoz/AreaChartPanel, signoz/NumberPanel, signoz/PieChartPanel, signoz/TablePanel, signoz/HistogramPanel, signoz/ListPanel, signoz/TextPanel, signoz/HeatmapPanel or signoz/DistributionPanel, and spec holds that kind's options ({} accepts every default).
|
||||
*/
|
||||
plugin: DashboardtypesPanelPluginDTO;
|
||||
/**
|
||||
@@ -8241,6 +8343,7 @@ export enum DashboardtypesPanelPluginKindDTO {
|
||||
'signoz/TextPanel' = 'signoz/TextPanel',
|
||||
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
|
||||
'signoz/ScatterPlotPanel' = 'signoz/ScatterPlotPanel',
|
||||
'signoz/DistributionPanel' = 'signoz/DistributionPanel',
|
||||
}
|
||||
/**
|
||||
* The RFC 6902 operations, applied in order against the dashboard's postable shape.
|
||||
|
||||
@@ -11,8 +11,6 @@ import axios, { AxiosError, AxiosRequestConfig } from 'axios';
|
||||
// generated API Instance
|
||||
const generatedAPIAxiosInstance = axios.create({
|
||||
baseURL: ENVIRONMENT.baseURL,
|
||||
// The backend binds repeated keys (`states=a&states=b`); axios' default sends `states[]=`.
|
||||
paramsSerializer: { indexes: null },
|
||||
});
|
||||
|
||||
let generatedAPIQueryKeyHeaderContext: Record<string, unknown> | undefined;
|
||||
|
||||
@@ -630,6 +630,7 @@ describe('Footer utils', () => {
|
||||
expect(props.ruleType).toBe('anomaly_rule');
|
||||
expect(props.alertType).toBe(AlertTypes.METRICS_BASED_ALERT);
|
||||
expect(props.schemaVersion).toBe('v2alpha1');
|
||||
expect(props.condition.algorithm).toBe('standard');
|
||||
expect(props.condition.seasonality).toBe('daily');
|
||||
expect(props.condition.thresholds?.spec[0]).toMatchObject({
|
||||
target: 3,
|
||||
|
||||
@@ -324,6 +324,7 @@ export function buildCreateAnomalyAlertRulePayload(
|
||||
condition: {
|
||||
...payload.condition,
|
||||
thresholds: { kind: 'basic', spec: thresholds },
|
||||
algorithm: thresholdState.algorithm,
|
||||
seasonality: thresholdState.seasonality,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -323,6 +323,7 @@ describe('CreateAlert utils', () => {
|
||||
ruleType: 'anomaly_rule',
|
||||
condition: {
|
||||
...defaultPostableAlertRuleV2.condition,
|
||||
algorithm: 'standard',
|
||||
seasonality: 'weekly',
|
||||
thresholds: {
|
||||
kind: 'basic',
|
||||
@@ -348,6 +349,7 @@ describe('CreateAlert utils', () => {
|
||||
expect(getThresholdStateFromAlertDef(anomalyDef)).toMatchObject({
|
||||
operator: AlertThresholdOperator.IS_BELOW,
|
||||
thresholds: [{ thresholdValue: 3 }],
|
||||
algorithm: 'standard',
|
||||
seasonality: 'weekly',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -274,6 +274,8 @@ export function getThresholdStateFromAlertDef(
|
||||
matchType:
|
||||
alertDef.condition.thresholds?.spec[0].matchType ||
|
||||
AlertThresholdMatchType.AT_LEAST_ONCE,
|
||||
algorithm:
|
||||
alertDef.condition.algorithm || INITIAL_ALERT_THRESHOLD_STATE.algorithm,
|
||||
seasonality:
|
||||
alertDef.condition.seasonality || INITIAL_ALERT_THRESHOLD_STATE.seasonality,
|
||||
};
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useListRulesV3 } from 'api/generated/services/rules';
|
||||
import {
|
||||
type ListRulesV3200,
|
||||
type ListRulesV3Params,
|
||||
RuletypesAlertStateDTO,
|
||||
type RuletypesListableRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { GeneratedAPIInstance } from 'api/generatedAPIInstance';
|
||||
|
||||
const HOME_ALERT_RULES_LIMIT = 5;
|
||||
|
||||
@@ -21,7 +23,18 @@ interface HomeAlertRules {
|
||||
|
||||
export function useHomeAlertRules(): HomeAlertRules {
|
||||
const firing = useListRulesV3(FIRING_RULES_PARAMS, {
|
||||
query: { cacheTime: 0 },
|
||||
query: {
|
||||
cacheTime: 0,
|
||||
// The backend binds repeated `states=` keys; axios' default sends `states[]=`.
|
||||
queryFn: ({ signal }) =>
|
||||
GeneratedAPIInstance<ListRulesV3200>({
|
||||
url: '/api/v3/rules',
|
||||
method: 'GET',
|
||||
params: FIRING_RULES_PARAMS,
|
||||
paramsSerializer: { indexes: null },
|
||||
signal,
|
||||
}),
|
||||
},
|
||||
});
|
||||
const firingRules = firing.data?.data.rules;
|
||||
const latest = useListRulesV3(
|
||||
|
||||
@@ -104,5 +104,5 @@ describe('AI observability Explorer', () => {
|
||||
...defaultOptionsQuery,
|
||||
selectColumns: [],
|
||||
});
|
||||
}, 15_000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,8 +31,6 @@ jest.mock('@floating-ui/dom', () => ({
|
||||
Promise.resolve({ x: 0, y: 0, placement, strategy, middlewareData: {} }),
|
||||
}));
|
||||
|
||||
jest.setTimeout(15_000);
|
||||
|
||||
const VIEW_1_ORDER_BY = JSON.stringify({ columnName: 'name', order: 'asc' });
|
||||
const VIEW_1_QUERY = "severity = 'critical'";
|
||||
|
||||
|
||||
@@ -3,11 +3,13 @@ import logEvent from 'api/common/logEvent';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { useListRulesV3 } from 'api/generated/services/rules';
|
||||
import type {
|
||||
ListRulesV3200,
|
||||
ListRulesV3Params,
|
||||
RenderErrorResponseDTO,
|
||||
RuletypesLabelPairDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { AxiosError } from 'axios';
|
||||
import { GeneratedAPIInstance } from 'api/generatedAPIInstance';
|
||||
import { isUndefined } from 'lodash-es';
|
||||
|
||||
import type { AlertRule } from './types';
|
||||
@@ -33,7 +35,18 @@ export function useAlertRulesData(
|
||||
const hasLoggedEvent = useRef(false);
|
||||
|
||||
const rulesResponse = useListRulesV3(params, {
|
||||
query: { keepPreviousData: true },
|
||||
query: {
|
||||
keepPreviousData: true,
|
||||
// The backend binds repeated `states=` keys; axios' default sends `states[]=`.
|
||||
queryFn: ({ signal }) =>
|
||||
GeneratedAPIInstance<ListRulesV3200>({
|
||||
url: '/api/v3/rules',
|
||||
method: 'GET',
|
||||
params,
|
||||
paramsSerializer: { indexes: null },
|
||||
signal,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const rules = useMemo(
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { back, getCurrentLocation } from 'lib/router/navigation';
|
||||
import { act, AllTheProviders, renderHook, waitFor } from 'tests/test-utils';
|
||||
|
||||
// jest.config.ts maps `hooks/useSafeNavigate` to a mock; the `.ts` path loads the real hook.
|
||||
const { useSafeNavigate } = jest.requireActual<
|
||||
typeof import('hooks/useSafeNavigate')
|
||||
>('hooks/useSafeNavigate.ts');
|
||||
|
||||
describe('useSafeNavigate', () => {
|
||||
it('adds a history entry for replace: false even when the target adds params', async () => {
|
||||
const initialSearch = `?${QueryParams.relativeTime}=15m`;
|
||||
const { result } = renderHook(() => useSafeNavigate(), {
|
||||
wrapper: ({ children }): JSX.Element => (
|
||||
<AllTheProviders initialRoute={`${ROUTES.LOGS_EXPLORER}${initialSearch}`}>
|
||||
{children}
|
||||
</AllTheProviders>
|
||||
),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.safeNavigate(
|
||||
`${ROUTES.LOGS_EXPLORER}?${QueryParams.startTime}=1000`,
|
||||
{ replace: false },
|
||||
);
|
||||
});
|
||||
act(() => back());
|
||||
|
||||
await waitFor(() => expect(getCurrentLocation().search).toBe(initialSearch));
|
||||
});
|
||||
});
|
||||
@@ -140,7 +140,7 @@ export const useSafeNavigate = (
|
||||
|
||||
const navigationOptions = {
|
||||
...options,
|
||||
replace: options?.replace ?? isDefaultParamsNavigation,
|
||||
replace: isDefaultParamsNavigation || options?.replace,
|
||||
};
|
||||
|
||||
if (typeof to === 'string') {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { createVariableWidthBarsPathBuilder } from '../variableWidthBars';
|
||||
|
||||
class FakePath2D {
|
||||
rects: number[][] = [];
|
||||
|
||||
rect(x: number, y: number, width: number, height: number): void {
|
||||
this.rects.push([x, y, width, height]);
|
||||
}
|
||||
}
|
||||
|
||||
/** 10 px per unit on both axes; y grows downward from 100. */
|
||||
const fakePlot: Partial<uPlot> = {
|
||||
valToPos: (value: number, scaleKey: string): number =>
|
||||
scaleKey === 'x' ? value * 10 : 100 - value * 10,
|
||||
ctx: {} as CanvasRenderingContext2D,
|
||||
};
|
||||
const plot = fakePlot as uPlot;
|
||||
|
||||
type Fills = Map<CanvasRenderingContext2D['fillStyle'], Path2D>;
|
||||
|
||||
function fillsOf(paths: ReturnType<uPlot.Series.PathBuilder>): Fills {
|
||||
const fill = paths?.fill;
|
||||
if (!(fill instanceof Map)) {
|
||||
throw new Error('expected a fill per style');
|
||||
}
|
||||
return fill;
|
||||
}
|
||||
|
||||
function rectsOf(path: Path2D | undefined): number[][] {
|
||||
if (!(path instanceof FakePath2D)) {
|
||||
throw new Error('expected a FakePath2D');
|
||||
}
|
||||
return path.rects;
|
||||
}
|
||||
|
||||
describe('createVariableWidthBarsPathBuilder', () => {
|
||||
const originalPath2D = globalThis.Path2D;
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'Path2D', {
|
||||
value: FakePath2D,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(globalThis, 'Path2D', {
|
||||
value: originalPath2D,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
function build(
|
||||
pattern: CanvasPattern | null = null,
|
||||
): ReturnType<uPlot.Series.PathBuilder> {
|
||||
return createVariableWidthBarsPathBuilder({
|
||||
getBars: () => [
|
||||
{ x0: 0, x1: 1, y0: 0, y1: 2, hatched: false },
|
||||
{ x0: 1, x1: 4, y0: 0, y1: 5, hatched: false },
|
||||
{ x0: 4, x1: 6, y0: 1, y1: 3, hatched: true },
|
||||
],
|
||||
fill: '#ff0000',
|
||||
hatchedFill: '#ff000066',
|
||||
getHatchPattern: () => pattern,
|
||||
gapPx: 2,
|
||||
})(plot, 1, 0, 0);
|
||||
}
|
||||
|
||||
it('draws each bar at its own width, inset by half the gap', () => {
|
||||
const fills = fillsOf(build());
|
||||
|
||||
expect(rectsOf(fills.get('#ff0000'))).toStrictEqual([
|
||||
[1, 80, 8, 20],
|
||||
[11, 50, 28, 50],
|
||||
]);
|
||||
});
|
||||
|
||||
it('draws hatched bars in their own fill, with the pattern on top', () => {
|
||||
const pattern = {} as CanvasPattern;
|
||||
const fills = fillsOf(build(pattern));
|
||||
|
||||
expect([...fills.keys()]).toStrictEqual(['#ff0000', '#ff000066', pattern]);
|
||||
expect(rectsOf(fills.get(pattern))).toStrictEqual([[41, 70, 18, 20]]);
|
||||
});
|
||||
it('reads a fill callback on every draw', () => {
|
||||
let fill = '#ff0000';
|
||||
const builder = createVariableWidthBarsPathBuilder({
|
||||
getBars: () => [{ x0: 0, x1: 1, y0: 0, y1: 1, hatched: false }],
|
||||
fill: () => fill,
|
||||
hatchedFill: '#00ff00',
|
||||
getHatchPattern: () => null,
|
||||
gapPx: 0,
|
||||
});
|
||||
|
||||
const first = fillsOf(builder(plot, 1, 0, 0));
|
||||
fill = '#0000ff';
|
||||
const second = fillsOf(builder(plot, 1, 0, 0));
|
||||
|
||||
expect([...first.keys()]).toStrictEqual(['#ff0000']);
|
||||
expect([...second.keys()]).toStrictEqual(['#0000ff']);
|
||||
});
|
||||
|
||||
it('keeps solid bars when the hatched fill is the same colour', () => {
|
||||
const fills = fillsOf(
|
||||
createVariableWidthBarsPathBuilder({
|
||||
getBars: () => [
|
||||
{ x0: 0, x1: 1, y0: 0, y1: 1, hatched: false },
|
||||
{ x0: 1, x1: 2, y0: 0, y1: 1, hatched: true },
|
||||
],
|
||||
fill: 'red',
|
||||
hatchedFill: 'red',
|
||||
getHatchPattern: () => null,
|
||||
gapPx: 0,
|
||||
})(plot, 1, 0, 0),
|
||||
);
|
||||
|
||||
expect(rectsOf(fills.get('red'))).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('hatches only overflow bars when both fills are the same colour', () => {
|
||||
const pattern = {} as CanvasPattern;
|
||||
const fills = fillsOf(
|
||||
createVariableWidthBarsPathBuilder({
|
||||
getBars: () => [
|
||||
{ x0: 0, x1: 1, y0: 0, y1: 1, hatched: false },
|
||||
{ x0: 1, x1: 2, y0: 0, y1: 1, hatched: true },
|
||||
],
|
||||
fill: 'red',
|
||||
hatchedFill: 'red',
|
||||
getHatchPattern: () => pattern,
|
||||
gapPx: 0,
|
||||
})(plot, 1, 0, 0),
|
||||
);
|
||||
|
||||
expect(rectsOf(fills.get(pattern))).toStrictEqual([[10, 90, 10, 10]]);
|
||||
});
|
||||
});
|
||||
88
frontend/src/lib/uPlotV2/paths/variableWidthBars.ts
Normal file
88
frontend/src/lib/uPlotV2/paths/variableWidthBars.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import uPlot from 'uplot';
|
||||
|
||||
/** A bar in scale space. Unlike uPlot's own bars, each one carries its own width. */
|
||||
export interface VariableWidthBar {
|
||||
x0: number;
|
||||
x1: number;
|
||||
y0: number;
|
||||
y1: number;
|
||||
/** Drawn in `hatchedFill` with the hatch pattern on top. */
|
||||
hatched: boolean;
|
||||
}
|
||||
|
||||
/** A fill, or one read on every draw (e.g. to dim a series on hover). */
|
||||
type BarFill = string | (() => string);
|
||||
|
||||
/** Bars narrower than this, in CSS pixels, keep no gap between them. */
|
||||
const MIN_WIDTH_FOR_GAP_PX = 3;
|
||||
|
||||
/**
|
||||
* Draws `[x0, x1] × [y0, y1]` rects on the `x` and `y` scales. uPlot's bar builder
|
||||
* assumes one width for every bar, which a bucket axis with uneven bounds breaks.
|
||||
*
|
||||
* `getBars` runs on every redraw, so a caller can derive the bars from the plot's
|
||||
* current state (e.g. which series are shown).
|
||||
*/
|
||||
export function createVariableWidthBarsPathBuilder({
|
||||
getBars,
|
||||
fill,
|
||||
hatchedFill,
|
||||
getHatchPattern,
|
||||
gapPx,
|
||||
}: {
|
||||
getBars: (u: uPlot, seriesIdx: number) => VariableWidthBar[];
|
||||
fill: BarFill;
|
||||
hatchedFill: BarFill;
|
||||
getHatchPattern: (ctx: CanvasRenderingContext2D) => CanvasPattern | null;
|
||||
/** Space between neighbouring bars, in CSS pixels. */
|
||||
gapPx: number;
|
||||
}): uPlot.Series.PathBuilder {
|
||||
return (u, seriesIdx): uPlot.Series.Paths => {
|
||||
const solidFill = resolveFill(fill);
|
||||
const overflowFill = resolveFill(hatchedFill);
|
||||
const solidPath = new Path2D();
|
||||
// The hatch overlays only overflow bars, so they keep a path of their own even
|
||||
// when one colour fill covers both (a Map keyed by fill holds one path a fill).
|
||||
const hatchedPath = new Path2D();
|
||||
const isSharedFill = overflowFill === solidFill;
|
||||
const inset = (gapPx * uPlot.pxRatio) / 2;
|
||||
const minWidthForGap = MIN_WIDTH_FOR_GAP_PX * uPlot.pxRatio;
|
||||
let hasHatched = false;
|
||||
|
||||
getBars(u, seriesIdx).forEach((bar) => {
|
||||
const left = u.valToPos(bar.x0, 'x', true);
|
||||
const right = u.valToPos(bar.x1, 'x', true);
|
||||
const top = u.valToPos(bar.y1, 'y', true);
|
||||
const bottom = u.valToPos(bar.y0, 'y', true);
|
||||
const width = right - left;
|
||||
const gap = width > minWidthForGap ? inset : 0;
|
||||
const rect = [left + gap, top, width - 2 * gap, bottom - top] as const;
|
||||
if (!bar.hatched || isSharedFill) {
|
||||
solidPath.rect(...rect);
|
||||
}
|
||||
if (bar.hatched) {
|
||||
hatchedPath.rect(...rect);
|
||||
hasHatched = true;
|
||||
}
|
||||
});
|
||||
|
||||
const fills = new Map<CanvasRenderingContext2D['fillStyle'], Path2D>([
|
||||
[solidFill, solidPath],
|
||||
]);
|
||||
if (hasHatched) {
|
||||
if (!isSharedFill) {
|
||||
fills.set(overflowFill, hatchedPath);
|
||||
}
|
||||
const pattern = getHatchPattern(u.ctx);
|
||||
if (pattern) {
|
||||
fills.set(pattern, hatchedPath);
|
||||
}
|
||||
}
|
||||
|
||||
return { stroke: null, fill: fills, clip: null, band: null, flags: 0 };
|
||||
};
|
||||
}
|
||||
|
||||
function resolveFill(fill: BarFill): string {
|
||||
return typeof fill === 'function' ? fill() : fill;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.notice {
|
||||
padding: var(--spacing-2) var(--spacing-4);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import Legend from 'lib/uPlotV2/components/Legend/Legend';
|
||||
import {
|
||||
LegendPosition,
|
||||
type TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import type { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import ChartWrapper from 'lib/visualization/charts/ChartWrapper/ChartWrapper';
|
||||
import { resolveBoundaryPrecision } from 'lib/visualization/charts/Heatmap/utils';
|
||||
import { DistributionChartProps } from 'lib/visualization/charts/types';
|
||||
import { useLegendVisibility } from 'lib/visualization/hooks/useLegendVisibility';
|
||||
import type { LegendLayout } from 'lib/visualization/layout/ChartLayout/ChartLayout';
|
||||
|
||||
import { resolveOtherColor, resolveSeriesColor } from './colors';
|
||||
import DistributionTooltip from './components/DistributionTooltip/DistributionTooltip';
|
||||
import { buildDistributionConfig } from './config';
|
||||
import {
|
||||
clampDistributionXAxis,
|
||||
cropDistributionXAxis,
|
||||
resolveDistributionBars,
|
||||
resolveDistributionXAxis,
|
||||
sumVisibleCounts,
|
||||
} from './layout';
|
||||
import { computeDistributionPercentiles } from './percentiles';
|
||||
import { groupIntoOther, resolveMaxDrawnGroups } from './topGroups';
|
||||
import { DistributionYScale } from './types';
|
||||
import { useDistributionBarClick } from './useDistributionBarClick';
|
||||
|
||||
import styles from './Distribution.module.scss';
|
||||
import { createBoundFormatter, prepareDistributionChartData } from './utils';
|
||||
|
||||
/** Rough share of the panel width the plot gets, for fitting side-by-side bars. */
|
||||
const PLOT_SHARE = 0.9;
|
||||
const PLOT_SHARE_WITH_RIGHT_LEGEND = 0.65;
|
||||
/** Vertical space the Other notice takes out of the container. */
|
||||
const NOTICE_HEIGHT = 20;
|
||||
const NO_PERCENTILES: readonly number[] = Object.freeze([]);
|
||||
|
||||
export default function Distribution({
|
||||
id,
|
||||
buckets,
|
||||
series,
|
||||
width,
|
||||
height,
|
||||
isDarkMode,
|
||||
xMin,
|
||||
xMax,
|
||||
yScale = DistributionYScale.Linear,
|
||||
stack = StackMode.Normal,
|
||||
xAxisUnit,
|
||||
decimalPrecision,
|
||||
customColors,
|
||||
percentiles = NO_PERCENTILES,
|
||||
onBarClick,
|
||||
showLegend = true,
|
||||
legendPosition = LegendPosition.BOTTOM,
|
||||
showTooltip = true,
|
||||
canPinTooltip = false,
|
||||
renderTooltipFooter,
|
||||
tooltipPortalRoot,
|
||||
layoutChildren,
|
||||
'data-testid': testId,
|
||||
}: DistributionChartProps): JSX.Element {
|
||||
const labels = useMemo(() => series.map((entry) => entry.label), [series]);
|
||||
const counts = useMemo(() => series.map((entry) => entry.counts), [series]);
|
||||
|
||||
const { visibleKeys, focusedSeriesIndex, onLegendAction } =
|
||||
useLegendVisibility({ keys: labels, indexOffset: 1, id });
|
||||
|
||||
const visible = useMemo(
|
||||
() => labels.map((label) => visibleKeys.includes(label)),
|
||||
[labels, visibleKeys],
|
||||
);
|
||||
|
||||
const colors = useMemo(
|
||||
() =>
|
||||
labels.map((label) => resolveSeriesColor(label, isDarkMode, customColors)),
|
||||
[labels, isDarkMode, customColors],
|
||||
);
|
||||
|
||||
const { xAxis, totals } = useMemo(() => {
|
||||
const full = resolveDistributionXAxis(buckets);
|
||||
const visibleTotals = sumVisibleCounts(counts, visible, full.rows.length);
|
||||
// An explicit max may reach past the last occupied bucket; only auto-crop without one.
|
||||
const fitted =
|
||||
xMax === undefined ? cropDistributionXAxis(full, visibleTotals) : full;
|
||||
return {
|
||||
xAxis: clampDistributionXAxis(fitted, xMin, xMax),
|
||||
totals: visibleTotals,
|
||||
};
|
||||
}, [buckets, xMin, xMax, counts, visible]);
|
||||
|
||||
// Over the visible groups combined, so a legend toggle moves them without a refetch.
|
||||
const markers = useMemo(
|
||||
() => computeDistributionPercentiles({ xAxis, totals, percentiles }),
|
||||
[xAxis, totals, percentiles],
|
||||
);
|
||||
|
||||
const isLogY =
|
||||
yScale === DistributionYScale.Log && stack !== StackMode.Percent;
|
||||
|
||||
// The legend lists every group; the plot draws the largest and sums the rest into
|
||||
// Other, so a toggle re-ranks without a refetch.
|
||||
const drawn = useMemo(
|
||||
() =>
|
||||
groupIntoOther({
|
||||
labels,
|
||||
counts,
|
||||
colors,
|
||||
visible,
|
||||
maxGroups: resolveMaxDrawnGroups({
|
||||
stack,
|
||||
xAxis,
|
||||
plotWidth:
|
||||
width *
|
||||
(legendPosition === LegendPosition.RIGHT
|
||||
? PLOT_SHARE_WITH_RIGHT_LEGEND
|
||||
: PLOT_SHARE),
|
||||
}),
|
||||
otherColor: resolveOtherColor(isDarkMode),
|
||||
}),
|
||||
[
|
||||
labels,
|
||||
counts,
|
||||
colors,
|
||||
visible,
|
||||
stack,
|
||||
xAxis,
|
||||
width,
|
||||
legendPosition,
|
||||
isDarkMode,
|
||||
],
|
||||
);
|
||||
|
||||
const layout = useMemo(
|
||||
() =>
|
||||
resolveDistributionBars({
|
||||
xAxis,
|
||||
counts: drawn.counts,
|
||||
stack,
|
||||
isLogY,
|
||||
}),
|
||||
[xAxis, drawn, stack, isLogY],
|
||||
);
|
||||
|
||||
const boundaryPrecision = useMemo(
|
||||
() =>
|
||||
resolveBoundaryPrecision({
|
||||
yAxis: xAxis,
|
||||
yAxisUnit: xAxisUnit,
|
||||
decimalPrecision,
|
||||
}),
|
||||
[xAxis, xAxisUnit, decimalPrecision],
|
||||
);
|
||||
|
||||
const formatBound = useMemo(
|
||||
() => createBoundFormatter(xAxisUnit, boundaryPrecision),
|
||||
[xAxisUnit, boundaryPrecision],
|
||||
);
|
||||
|
||||
const config = useMemo(
|
||||
() =>
|
||||
buildDistributionConfig({
|
||||
id,
|
||||
xAxis,
|
||||
layout,
|
||||
labels: drawn.labels,
|
||||
colors: drawn.colors,
|
||||
stack,
|
||||
isLogY,
|
||||
isDarkMode,
|
||||
markers,
|
||||
xAxisUnit,
|
||||
decimalPrecision: boundaryPrecision,
|
||||
}),
|
||||
[
|
||||
id,
|
||||
xAxis,
|
||||
layout,
|
||||
drawn,
|
||||
stack,
|
||||
isLogY,
|
||||
isDarkMode,
|
||||
markers,
|
||||
xAxisUnit,
|
||||
boundaryPrecision,
|
||||
],
|
||||
);
|
||||
|
||||
const renderTooltip = useCallback(
|
||||
(args: TooltipRenderArgs): React.ReactNode => (
|
||||
<DistributionTooltip
|
||||
uPlotInstance={args.uPlotInstance}
|
||||
isPinned={args.isPinned}
|
||||
dismiss={args.dismiss}
|
||||
xAxis={xAxis}
|
||||
layout={layout}
|
||||
labels={drawn.labels}
|
||||
colors={drawn.colors}
|
||||
counts={drawn.counts}
|
||||
formatBound={formatBound}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
/>
|
||||
),
|
||||
[xAxis, layout, drawn, formatBound, renderTooltipFooter],
|
||||
);
|
||||
|
||||
const handleClick = useDistributionBarClick({
|
||||
xAxis,
|
||||
layout,
|
||||
drawn,
|
||||
series,
|
||||
onBarClick,
|
||||
});
|
||||
|
||||
const data = useMemo(
|
||||
() => prepareDistributionChartData(xAxis, drawn.counts),
|
||||
[xAxis, drawn],
|
||||
);
|
||||
|
||||
const legendItems = useMemo<LegendItem[]>(
|
||||
() =>
|
||||
labels.map((label, index) => ({
|
||||
seriesIndex: index + 1,
|
||||
label,
|
||||
color: colors[index],
|
||||
show: visible[index],
|
||||
})),
|
||||
[labels, colors, visible],
|
||||
);
|
||||
|
||||
const groupLegend = useCallback(
|
||||
({ averageLegendWidth, showSearch }: LegendLayout): React.ReactNode => (
|
||||
<Legend
|
||||
items={legendItems}
|
||||
position={legendPosition}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
showSearch={showSearch}
|
||||
itemLabel="groups"
|
||||
focusedSeriesIndex={focusedSeriesIndex}
|
||||
onAction={onLegendAction}
|
||||
/>
|
||||
),
|
||||
[legendItems, legendPosition, focusedSeriesIndex, onLegendAction],
|
||||
);
|
||||
|
||||
return (
|
||||
<ChartWrapper
|
||||
config={config}
|
||||
data={data}
|
||||
width={width}
|
||||
height={
|
||||
drawn.otherGroupCount > 0 ? Math.max(0, height - NOTICE_HEIGHT) : height
|
||||
}
|
||||
legendConfig={{ position: legendPosition }}
|
||||
showLegend={showLegend}
|
||||
customLegend={groupLegend}
|
||||
legendLabels={labels}
|
||||
showTooltip={showTooltip}
|
||||
canPinTooltip={canPinTooltip}
|
||||
decimalPrecision={decimalPrecision}
|
||||
customTooltip={renderTooltip}
|
||||
onClick={handleClick}
|
||||
contentFooter={
|
||||
drawn.otherGroupCount > 0 && (
|
||||
<div className={styles.notice} data-testid="distribution-other-notice">
|
||||
Showing top {drawn.labels.length - 1} of {drawn.visibleGroupCount} groups;
|
||||
the rest are summed into Other.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
tooltipPortalRoot={tooltipPortalRoot}
|
||||
layoutChildren={layoutChildren}
|
||||
data-testid={testId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type React from 'react';
|
||||
import type { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import Distribution from '../Distribution';
|
||||
import type { DistributionSeries } from '../types';
|
||||
|
||||
// The shared Legend virtualises its items; render them all so they are queryable.
|
||||
jest.mock('react-virtuoso', () => ({
|
||||
VirtuosoGrid: ({
|
||||
data,
|
||||
itemContent,
|
||||
}: {
|
||||
data: LegendItem[];
|
||||
itemContent: (index: number, item: LegendItem) => React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<div>
|
||||
{data.map((item, index) => (
|
||||
<div key={item.seriesIndex}>{itemContent(index, item)}</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const BUCKETS = [5, 10, 100, 1000];
|
||||
|
||||
const SERIES: DistributionSeries[] = [
|
||||
{ label: 'service.name=cart', counts: [3, 40, 12, 1, 0] },
|
||||
{ label: 'service.name=checkout', counts: [1, 10, 30, 6, 2] },
|
||||
];
|
||||
|
||||
function renderDistribution(
|
||||
props: Partial<React.ComponentProps<typeof Distribution>> = {},
|
||||
): ReturnType<typeof render> {
|
||||
return render(
|
||||
<Distribution
|
||||
id="panel-1"
|
||||
buckets={BUCKETS}
|
||||
series={SERIES}
|
||||
width={800}
|
||||
height={400}
|
||||
isDarkMode
|
||||
data-testid="distribution"
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('Distribution', () => {
|
||||
// Legend items count from 1, as uPlot's data series do.
|
||||
const CART = 1;
|
||||
const CHECKOUT = 2;
|
||||
|
||||
// The selection persists under the panel id, which every case here shares.
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
function legendItem(seriesIndex: number): HTMLElement {
|
||||
return screen.getByTestId(`legend-item-${seriesIndex}`);
|
||||
}
|
||||
|
||||
it('renders the plot container', () => {
|
||||
renderDistribution();
|
||||
|
||||
expect(screen.getByTestId('distribution')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lists every group in the legend, all shown', () => {
|
||||
renderDistribution();
|
||||
|
||||
expect(legendItem(CART)).toHaveAttribute('aria-checked', 'true');
|
||||
expect(legendItem(CHECKOUT)).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
|
||||
it('isolates a group when its row is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderDistribution();
|
||||
|
||||
await user.click(legendItem(CART));
|
||||
|
||||
expect(legendItem(CART)).toHaveAttribute('aria-checked', 'true');
|
||||
expect(legendItem(CHECKOUT)).toHaveAttribute('aria-checked', 'false');
|
||||
});
|
||||
|
||||
it('hides only that group when its marker is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderDistribution();
|
||||
|
||||
await user.click(screen.getByTestId(`legend-marker-${CHECKOUT}`));
|
||||
|
||||
expect(legendItem(CHECKOUT)).toHaveAttribute('aria-checked', 'false');
|
||||
expect(legendItem(CART)).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
it('sums groups past the stacked cap into Other, with a notice', () => {
|
||||
renderDistribution({
|
||||
series: Array.from({ length: 12 }, (_, index) => ({
|
||||
label: `group-${index}`,
|
||||
counts: [index, 1, 1, 0, 0],
|
||||
})),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('distribution-other-notice')).toHaveTextContent(
|
||||
'Showing top 9 of 12 groups; the rest are summed into Other.',
|
||||
);
|
||||
// The legend still lists every group, so any can be isolated.
|
||||
expect(legendItem(1)).toHaveAttribute('aria-label', 'group-0');
|
||||
});
|
||||
|
||||
it('shows no notice when every group fits', () => {
|
||||
renderDistribution();
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('distribution-other-notice'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { resolveDistributionBars, resolveDistributionXAxis } from '../layout';
|
||||
import { buildDistributionConfig } from '../config';
|
||||
|
||||
// Buckets ≤ 10, (10, 20], (20, 40], > 40 in slots [0, 1] … [3, 4]. Only (10, 20] is occupied.
|
||||
const xAxis = resolveDistributionXAxis([10, 20, 40]);
|
||||
const counts = [
|
||||
[0, 4, 0, 0],
|
||||
[0, 6, 0, 0],
|
||||
];
|
||||
|
||||
function setCursorHook(drawnCounts = counts): (u: uPlot) => void {
|
||||
const config = buildDistributionConfig({
|
||||
id: 'panel-1',
|
||||
xAxis,
|
||||
layout: resolveDistributionBars({
|
||||
xAxis,
|
||||
counts: drawnCounts,
|
||||
stack: StackMode.Normal,
|
||||
isLogY: false,
|
||||
}),
|
||||
labels: drawnCounts.map((_, index) => `group-${index}`),
|
||||
colors: drawnCounts.map(() => '#ff0000'),
|
||||
stack: StackMode.Normal,
|
||||
isLogY: false,
|
||||
isDarkMode: true,
|
||||
markers: [],
|
||||
}).getConfig();
|
||||
const [hook] = config.hooks?.setCursor ?? [];
|
||||
return hook as (u: uPlot) => void;
|
||||
}
|
||||
|
||||
/** Pixels are values, so the cursor sits at `(left, top)` in scale space. */
|
||||
function plotAt(
|
||||
left: number,
|
||||
top: number,
|
||||
): { u: uPlot; setSeries: jest.Mock; redraw: jest.Mock } {
|
||||
const setSeries = jest.fn();
|
||||
const redraw = jest.fn();
|
||||
const plot: Partial<uPlot> = {
|
||||
cursor: { left, top },
|
||||
posToVal: (position: number) => position,
|
||||
setSeries,
|
||||
redraw,
|
||||
};
|
||||
return { u: plot as uPlot, setSeries, redraw };
|
||||
}
|
||||
|
||||
describe('buildDistributionConfig cursor', () => {
|
||||
it('turns off drag-to-zoom', () => {
|
||||
const config = buildDistributionConfig({
|
||||
id: 'panel-1',
|
||||
xAxis,
|
||||
layout: resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
stack: StackMode.Normal,
|
||||
isLogY: false,
|
||||
}),
|
||||
labels: ['group-0', 'group-1'],
|
||||
colors: ['#ff0000', '#ff0000'],
|
||||
stack: StackMode.Normal,
|
||||
isLogY: false,
|
||||
isDarkMode: true,
|
||||
markers: [],
|
||||
}).getConfig();
|
||||
|
||||
expect(config.cursor?.drag).toMatchObject({ x: false, y: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDistributionConfig setCursor', () => {
|
||||
// TooltipPlugin only shows a tooltip for a focused series.
|
||||
it('focuses the group under the cursor, 1-based as uPlot counts series', () => {
|
||||
const { u, setSeries, redraw } = plotAt(1.5, 8);
|
||||
|
||||
setCursorHook()(u);
|
||||
|
||||
expect(setSeries).toHaveBeenCalledWith(2, { focus: true });
|
||||
expect(redraw).toHaveBeenCalledWith(true, false);
|
||||
});
|
||||
|
||||
it('focuses the nearest group above the stack, dimming the rest', () => {
|
||||
const { u, setSeries, redraw } = plotAt(1.5, 50);
|
||||
|
||||
setCursorHook()(u);
|
||||
|
||||
expect(setSeries).toHaveBeenCalledWith(2, { focus: true });
|
||||
expect(redraw).toHaveBeenCalledWith(true, false);
|
||||
});
|
||||
|
||||
it('keeps a tooltip over an empty bucket without dimming any group', () => {
|
||||
const hook = setCursorHook();
|
||||
const { u, setSeries, redraw } = plotAt(1.5, 8);
|
||||
hook(u);
|
||||
redraw.mockClear();
|
||||
|
||||
u.cursor.left = 2.5;
|
||||
u.cursor.top = 1;
|
||||
hook(u);
|
||||
|
||||
expect(setSeries).toHaveBeenLastCalledWith(1, { focus: true });
|
||||
expect(redraw).toHaveBeenCalledWith(true, false);
|
||||
});
|
||||
|
||||
it('focuses nothing off the plot', () => {
|
||||
const hook = setCursorHook();
|
||||
const { u, setSeries } = plotAt(1.5, 8);
|
||||
hook(u);
|
||||
|
||||
u.cursor.left = -1;
|
||||
hook(u);
|
||||
|
||||
expect(setSeries).toHaveBeenLastCalledWith(null, { focus: true });
|
||||
});
|
||||
|
||||
it('focuses nothing over an empty bucket when every group is hidden', () => {
|
||||
const { u, setSeries } = plotAt(2.5, 1);
|
||||
|
||||
setCursorHook([])(u);
|
||||
|
||||
expect(setSeries).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
layoutMarkerLabels,
|
||||
resolveHoveredMarker,
|
||||
} from '../drawPercentileMarkers';
|
||||
|
||||
describe('resolveHoveredMarker', () => {
|
||||
const areas = [
|
||||
{
|
||||
x: 100,
|
||||
badges: [
|
||||
{ left: 92, right: 108, top: 0, bottom: 16 },
|
||||
{ left: 92, right: 108, top: 184, bottom: 200 },
|
||||
],
|
||||
},
|
||||
{
|
||||
x: 120,
|
||||
badges: [{ left: 112, right: 128, top: 18, bottom: 34 }],
|
||||
},
|
||||
];
|
||||
|
||||
it('picks the line nearest x within the tolerance', () => {
|
||||
expect(resolveHoveredMarker(areas, 103, 100, 4)).toBe(0);
|
||||
expect(resolveHoveredMarker(areas, 117, 100, 4)).toBe(1);
|
||||
expect(resolveHoveredMarker(areas, 110, 100, 4)).toBeNull();
|
||||
});
|
||||
|
||||
it('picks the marker whose badge is under the cursor', () => {
|
||||
expect(resolveHoveredMarker(areas, 107, 190, 4)).toBe(0);
|
||||
expect(resolveHoveredMarker(areas, 126, 20, 4)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('layoutMarkerLabels', () => {
|
||||
it('keeps labels that fit on the first row', () => {
|
||||
expect(
|
||||
layoutMarkerLabels(
|
||||
[
|
||||
{ x: 0, width: 20 },
|
||||
{ x: 40, width: 20 },
|
||||
],
|
||||
4,
|
||||
),
|
||||
).toStrictEqual([0, 0]);
|
||||
});
|
||||
|
||||
it('drops a label that would overlap to the next free row', () => {
|
||||
expect(
|
||||
layoutMarkerLabels(
|
||||
[
|
||||
{ x: 30, width: 20 },
|
||||
{ x: 0, width: 20 },
|
||||
{ x: 10, width: 20 },
|
||||
],
|
||||
4,
|
||||
),
|
||||
).toStrictEqual([0, 0, 1]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import {
|
||||
clampDistributionXAxis,
|
||||
cropDistributionXAxis,
|
||||
LOG_Y_BASE,
|
||||
resolveDistributionBars,
|
||||
resolveDistributionXAxis,
|
||||
resolveNearestSeries,
|
||||
sumVisibleCounts,
|
||||
thinLabelledSplits,
|
||||
} from '../layout';
|
||||
|
||||
function widths(edges: number[]): number[] {
|
||||
return edges.slice(1).map((edge, index) => edge - edges[index]);
|
||||
}
|
||||
|
||||
describe('resolveDistributionXAxis', () => {
|
||||
it('lays N bounds out as N + 1 buckets, overflow last', () => {
|
||||
const axis = resolveDistributionXAxis([10, 20, 30]);
|
||||
|
||||
expect(axis.rows).toHaveLength(4);
|
||||
expect(axis.rows[0].isUnderflow).toBe(true);
|
||||
expect(axis.rows[3]).toMatchObject({ lower: 30, isOverflow: true });
|
||||
expect(axis.edges).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('gives every bucket the same width, ticks on the bounds', () => {
|
||||
const axis = resolveDistributionXAxis([0, 5, 10, 25, 1000]);
|
||||
|
||||
expect(widths(axis.edges)).toStrictEqual([1, 1, 1, 1, 1, 1]);
|
||||
expect(axis.splits).toStrictEqual([1, 2, 3, 4, 5]);
|
||||
expect(axis.overflowSplit).toBe(6);
|
||||
expect(axis.toBucketValue(3.5)).toBe(17.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cropDistributionXAxis', () => {
|
||||
it('stops one bucket past the last occupied one', () => {
|
||||
const axis = resolveDistributionXAxis([1, 2, 3, 4]);
|
||||
|
||||
const cropped = cropDistributionXAxis(axis, [1, 5, 0, 0, 0]);
|
||||
|
||||
expect(cropped.max).toBe(3);
|
||||
expect(cropped.overflowSplit).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clampDistributionXAxis', () => {
|
||||
// Buckets ≤ 10, (10, 20], (20, 40], (40, 80], > 80 in slots [0, 1] … [4, 5].
|
||||
const axis = resolveDistributionXAxis([10, 20, 40, 80]);
|
||||
|
||||
it('keeps every bucket without a range', () => {
|
||||
expect(clampDistributionXAxis(axis)).toStrictEqual(axis);
|
||||
});
|
||||
|
||||
it('hides buckets wholly outside the range, keeping straddling ones whole', () => {
|
||||
const clamped = clampDistributionXAxis(axis, 20, 50);
|
||||
|
||||
expect([clamped.min, clamped.max]).toStrictEqual([2, 4]);
|
||||
expect(clamped.overflowSplit).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the overflow tick while the overflow bucket shows', () => {
|
||||
const clamped = clampDistributionXAxis(axis, 15);
|
||||
|
||||
expect([clamped.min, clamped.max]).toStrictEqual([1, 5]);
|
||||
expect(clamped.overflowSplit).toBe(5);
|
||||
});
|
||||
|
||||
it('keeps one bucket when the range misses them all', () => {
|
||||
const clamped = clampDistributionXAxis(axis, 1000);
|
||||
|
||||
expect([clamped.min, clamped.max]).toStrictEqual([4, 5]);
|
||||
});
|
||||
|
||||
it('narrows an already cropped axis, never widening it', () => {
|
||||
const cropped = cropDistributionXAxis(axis, [1, 5, 0, 0, 0]);
|
||||
|
||||
expect(clampDistributionXAxis(cropped, 15).max).toBe(cropped.max);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sumVisibleCounts', () => {
|
||||
it('adds only the visible series', () => {
|
||||
expect(
|
||||
sumVisibleCounts(
|
||||
[
|
||||
[1, 2],
|
||||
[10, 20],
|
||||
[100, 200],
|
||||
],
|
||||
[true, false, true],
|
||||
2,
|
||||
),
|
||||
).toStrictEqual([101, 202]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDistributionBars', () => {
|
||||
const xAxis = resolveDistributionXAxis([1, 2]);
|
||||
const counts = [
|
||||
[4, 6, 0],
|
||||
[1, 3, 2],
|
||||
];
|
||||
|
||||
it('sizes the count axis by the buckets left in the window', () => {
|
||||
// Buckets ≤ 1, (1, 2], > 2; a max of 1 keeps only the first.
|
||||
const { yMax } = resolveDistributionBars({
|
||||
xAxis: clampDistributionXAxis(xAxis, undefined, 1),
|
||||
counts: [[4, 1000, 0]],
|
||||
stack: StackMode.Normal,
|
||||
isLogY: false,
|
||||
});
|
||||
|
||||
expect(yMax).toBe(4);
|
||||
});
|
||||
|
||||
it('stacks series in each bucket under normal', () => {
|
||||
const { rects, yMax } = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
stack: StackMode.Normal,
|
||||
isLogY: false,
|
||||
});
|
||||
|
||||
expect(rects[0].map(({ y0, y1 }) => [y0, y1])).toStrictEqual([
|
||||
[0, 4],
|
||||
[0, 6],
|
||||
]);
|
||||
expect(rects[1].map(({ y0, y1 }) => [y0, y1])).toStrictEqual([
|
||||
[4, 5],
|
||||
[6, 9],
|
||||
[0, 2],
|
||||
]);
|
||||
expect(yMax).toBe(9);
|
||||
});
|
||||
|
||||
it('marks the overflow bucket hatched', () => {
|
||||
const { rects } = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
stack: StackMode.Normal,
|
||||
isLogY: false,
|
||||
});
|
||||
|
||||
expect(rects[1].map(({ hatched }) => hatched)).toStrictEqual([
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
]);
|
||||
});
|
||||
|
||||
it('stacks shares of each bucket under percent', () => {
|
||||
const { rects, yMax } = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
stack: StackMode.Percent,
|
||||
isLogY: false,
|
||||
});
|
||||
|
||||
expect(rects[0][0]).toMatchObject({ y0: 0, y1: 80 });
|
||||
expect(rects[1][0]).toMatchObject({ y0: 80, y1: 100 });
|
||||
expect(yMax).toBe(100);
|
||||
});
|
||||
|
||||
it('splits the bucket width between series under none', () => {
|
||||
const { rects, yMax } = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
stack: StackMode.None,
|
||||
isLogY: false,
|
||||
});
|
||||
|
||||
expect(rects[0][0]).toMatchObject({ x0: 0, x1: 0.5, y0: 0, y1: 4 });
|
||||
expect(rects[1][0]).toMatchObject({ x0: 0.5, x1: 1, y0: 0, y1: 1 });
|
||||
expect(yMax).toBe(6);
|
||||
});
|
||||
|
||||
it('starts bars at the log base and skips zero counts', () => {
|
||||
const { rects } = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
stack: StackMode.Normal,
|
||||
isLogY: true,
|
||||
});
|
||||
|
||||
expect(rects[0].map(({ y0 }) => y0)).toStrictEqual([LOG_Y_BASE, LOG_Y_BASE]);
|
||||
expect(rects[0].map(({ bucketIndex }) => bucketIndex)).toStrictEqual([0, 1]);
|
||||
});
|
||||
|
||||
it('skips bars whose top stays under the log base, never inverting one', () => {
|
||||
const fractional = [
|
||||
[0.5, 3, 0],
|
||||
[0.25, 0.5, 2],
|
||||
];
|
||||
const stacked = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts: fractional,
|
||||
stack: StackMode.Normal,
|
||||
isLogY: true,
|
||||
});
|
||||
const sideBySide = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts: fractional,
|
||||
stack: StackMode.None,
|
||||
isLogY: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
stacked.rects.map((rects) => rects.map(({ y0, y1 }) => [y0, y1])),
|
||||
).toStrictEqual([
|
||||
[[LOG_Y_BASE, 3]],
|
||||
[
|
||||
[3, 3.5],
|
||||
[LOG_Y_BASE, 2],
|
||||
],
|
||||
]);
|
||||
expect(
|
||||
sideBySide.rects.map((rects) => rects.map(({ bucketIndex }) => bucketIndex)),
|
||||
).toStrictEqual([[1], [2]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('thinLabelledSplits', () => {
|
||||
it('keeps labels that clear each other by the gap', () => {
|
||||
expect(
|
||||
thinLabelledSplits({
|
||||
positions: [0, 50, 100],
|
||||
widths: [20, 20, 20],
|
||||
gap: 10,
|
||||
}),
|
||||
).toStrictEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it('drops a wide label that would touch its neighbour, keeping the rightmost', () => {
|
||||
// 16.67 mins-wide labels 60 px apart: each needs 70 px of centre distance.
|
||||
expect(
|
||||
thinLabelledSplits({
|
||||
positions: [0, 60, 120, 180],
|
||||
widths: [60, 60, 60, 60],
|
||||
gap: 10,
|
||||
}),
|
||||
).toStrictEqual([1, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveNearestSeries', () => {
|
||||
// Buckets ≤ 10, (10, 20], (20, 40], > 40 in slots [0, 1] … [3, 4].
|
||||
const xAxis = resolveDistributionXAxis([10, 20, 40]);
|
||||
const counts = [
|
||||
[0, 4, 0, 0],
|
||||
[0, 6, 0, 0],
|
||||
];
|
||||
const stacked = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
stack: StackMode.Normal,
|
||||
isLogY: false,
|
||||
});
|
||||
|
||||
it('names the group whose bar is under the point', () => {
|
||||
expect(resolveNearestSeries(stacked, 1, 1.5, 2)).toBe(0);
|
||||
expect(resolveNearestSeries(stacked, 1, 1.5, 8)).toBe(1);
|
||||
});
|
||||
|
||||
it('takes the vertically nearest group off the bars, as a bar chart does', () => {
|
||||
expect(resolveNearestSeries(stacked, 1, 1.5, 50)).toBe(1);
|
||||
});
|
||||
|
||||
it('is null over an empty bucket', () => {
|
||||
expect(resolveNearestSeries(stacked, 2, 2.5, 1)).toBeNull();
|
||||
});
|
||||
|
||||
it('picks the sub-bar under the cursor side by side', () => {
|
||||
const sideBySide = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
stack: StackMode.None,
|
||||
isLogY: false,
|
||||
});
|
||||
|
||||
expect(resolveNearestSeries(sideBySide, 1, 1.25, 50)).toBe(0);
|
||||
expect(resolveNearestSeries(sideBySide, 1, 1.75, 1)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { resolveDistributionXAxis } from '../layout';
|
||||
import {
|
||||
computeDistributionPercentiles,
|
||||
formatPercentileBadge,
|
||||
formatPercentileName,
|
||||
formatPercentileValue,
|
||||
groupPercentileMarkers,
|
||||
} from '../percentiles';
|
||||
|
||||
// Buckets ≤ 10, (10, 20], (20, 40], > 40 in slots [0, 1] … [3, 4].
|
||||
const xAxis = resolveDistributionXAxis([10, 20, 40]);
|
||||
|
||||
describe('computeDistributionPercentiles', () => {
|
||||
it('interpolates inside the bucket holding the rank, as histogram_quantile does', () => {
|
||||
const markers = computeDistributionPercentiles({
|
||||
xAxis,
|
||||
totals: [10, 20, 10, 0],
|
||||
percentiles: [50, 90],
|
||||
});
|
||||
|
||||
expect(markers.map(({ value }) => value)).toStrictEqual([15, 32]);
|
||||
expect(markers.every(({ isOverflow }) => !isOverflow)).toBe(true);
|
||||
});
|
||||
|
||||
it('places the marker the same fraction across the bucket on the axis', () => {
|
||||
const [p50] = computeDistributionPercentiles({
|
||||
xAxis,
|
||||
totals: [10, 20, 10, 0],
|
||||
percentiles: [50],
|
||||
});
|
||||
|
||||
expect(p50.axisValue).toBe(1.5);
|
||||
});
|
||||
|
||||
it('starts the lowest bucket at zero', () => {
|
||||
const [p10] = computeDistributionPercentiles({
|
||||
xAxis,
|
||||
totals: [10, 20, 10, 0],
|
||||
percentiles: [10],
|
||||
});
|
||||
|
||||
expect(p10.value).toBe(4);
|
||||
});
|
||||
|
||||
it('reports a percentile in overflow as above the last bound, at its left edge', () => {
|
||||
const [p99] = computeDistributionPercentiles({
|
||||
xAxis,
|
||||
totals: [0, 0, 10, 10],
|
||||
percentiles: [99],
|
||||
});
|
||||
|
||||
expect(p99).toMatchObject({ value: 40, axisValue: 3, isOverflow: true });
|
||||
});
|
||||
|
||||
it('marks nothing when every count is zero', () => {
|
||||
expect(
|
||||
computeDistributionPercentiles({
|
||||
xAxis,
|
||||
totals: [0, 0, 0, 0],
|
||||
percentiles: [50],
|
||||
}),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatPercentileName', () => {
|
||||
it('keeps fractional percentile names short', () => {
|
||||
expect(formatPercentileName(99.9)).toBe('p99.9');
|
||||
expect(formatPercentileName(50)).toBe('p50');
|
||||
});
|
||||
});
|
||||
|
||||
describe('percentile marker groups', () => {
|
||||
const formatBound = (value: number): string => `${value} ms`;
|
||||
const p50 = { percentile: 50, value: 15, axisValue: 1.5, isOverflow: false };
|
||||
const p99 = { percentile: 99, value: 40, axisValue: 3, isOverflow: true };
|
||||
const p90 = { percentile: 90, value: 40, axisValue: 3, isOverflow: true };
|
||||
|
||||
it('groups markers on the same spot, lowest percentile first', () => {
|
||||
expect(groupPercentileMarkers([p50, p99, p90])).toStrictEqual([
|
||||
[p50],
|
||||
[p90, p99],
|
||||
]);
|
||||
});
|
||||
|
||||
it('names a group by its lowest percentile', () => {
|
||||
expect(formatPercentileBadge([p50])).toBe('p50');
|
||||
expect(formatPercentileBadge([p90, p99])).toBe('p90+');
|
||||
});
|
||||
|
||||
it('shows the estimate, or the overflow bound once for the group', () => {
|
||||
expect(formatPercentileValue([p50], formatBound)).toBe('p50 ≈ 15 ms');
|
||||
expect(formatPercentileValue([p90, p99], formatBound)).toBe(
|
||||
'p90, p99 > 40 ms',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import { resolveDistributionXAxis } from '../layout';
|
||||
import {
|
||||
groupIntoOther,
|
||||
resolveClickedGroup,
|
||||
MAX_STACKED_GROUPS,
|
||||
OTHER_GROUP_LABEL,
|
||||
resolveMaxDrawnGroups,
|
||||
} from '../topGroups';
|
||||
|
||||
describe('resolveMaxDrawnGroups', () => {
|
||||
// Four equal buckets plus underflow and overflow: six slots of one unit each.
|
||||
const xAxis = resolveDistributionXAxis([1, 2, 3, 4, 5]);
|
||||
|
||||
it('caps stacked and percent at the fixed limit', () => {
|
||||
expect(
|
||||
resolveMaxDrawnGroups({ stack: StackMode.Normal, xAxis, plotWidth: 60 }),
|
||||
).toBe(MAX_STACKED_GROUPS);
|
||||
expect(
|
||||
resolveMaxDrawnGroups({ stack: StackMode.Percent, xAxis, plotWidth: 60 }),
|
||||
).toBe(MAX_STACKED_GROUPS);
|
||||
});
|
||||
|
||||
it('fits side by side so the narrowest bar stays 3 px wide', () => {
|
||||
// 120 px over six slots: 20 px a bucket, six 3 px bars.
|
||||
expect(
|
||||
resolveMaxDrawnGroups({ stack: StackMode.None, xAxis, plotWidth: 120 }),
|
||||
).toBe(6);
|
||||
expect(
|
||||
resolveMaxDrawnGroups({ stack: StackMode.None, xAxis, plotWidth: 6 }),
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupIntoOther', () => {
|
||||
const args = {
|
||||
labels: ['a', 'b', 'c', 'd'],
|
||||
counts: [
|
||||
[1, 1],
|
||||
[5, 5],
|
||||
[2, 0],
|
||||
[3, 3],
|
||||
],
|
||||
colors: ['#a', '#b', '#c', '#d'],
|
||||
visible: [true, true, true, true],
|
||||
otherColor: '#999',
|
||||
};
|
||||
|
||||
it('draws every visible group when they fit', () => {
|
||||
const drawn = groupIntoOther({ ...args, maxGroups: 4 });
|
||||
|
||||
expect(drawn.labels).toStrictEqual(['a', 'b', 'c', 'd']);
|
||||
expect(drawn.otherGroupCount).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps the largest, in legend order, and sums the rest exactly', () => {
|
||||
const drawn = groupIntoOther({ ...args, maxGroups: 3 });
|
||||
|
||||
expect(drawn.labels).toStrictEqual(['b', 'd', OTHER_GROUP_LABEL]);
|
||||
expect(drawn.sourceIndexes).toStrictEqual([1, 3, null]);
|
||||
expect(drawn.counts[2]).toStrictEqual([3, 1]);
|
||||
expect(drawn.colors[2]).toBe('#999');
|
||||
expect(drawn).toMatchObject({ otherGroupCount: 2, visibleGroupCount: 4 });
|
||||
});
|
||||
|
||||
it('re-ranks without hidden groups, which never reach Other', () => {
|
||||
const drawn = groupIntoOther({
|
||||
...args,
|
||||
visible: [true, false, true, true],
|
||||
maxGroups: 2,
|
||||
});
|
||||
|
||||
expect(drawn.labels).toStrictEqual(['d', OTHER_GROUP_LABEL]);
|
||||
expect(drawn.sourceIndexes).toStrictEqual([3, null]);
|
||||
expect(drawn.counts[1]).toStrictEqual([3, 1]);
|
||||
expect(drawn.visibleGroupCount).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveClickedGroup', () => {
|
||||
const series = ['a', 'b', 'c'].map((label) => ({
|
||||
label,
|
||||
counts: [label === 'a' ? 5 : 1],
|
||||
}));
|
||||
const drawn = groupIntoOther({
|
||||
labels: ['a', 'b', 'c'],
|
||||
counts: series.map(({ counts }) => counts),
|
||||
colors: ['#a', '#b', '#c'],
|
||||
visible: [true, true, true],
|
||||
maxGroups: 2,
|
||||
otherColor: '#999',
|
||||
});
|
||||
|
||||
it('maps a drawn bar back to its source group and colour', () => {
|
||||
expect(resolveClickedGroup(drawn, 0, series)).toStrictEqual({
|
||||
series: series[0],
|
||||
color: '#a',
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses Other and empty space', () => {
|
||||
expect(resolveClickedGroup(drawn, 1, series)).toBeNull();
|
||||
expect(resolveClickedGroup(drawn, null, series)).toBeNull();
|
||||
});
|
||||
|
||||
it('maps by source index, not label, when groups share a legend', () => {
|
||||
const twins = [
|
||||
{ label: 'Other', groupLabels: { pod: 'a' }, counts: [1] },
|
||||
{ label: 'Other', groupLabels: { pod: 'b' }, counts: [2] },
|
||||
];
|
||||
const drawnTwins = groupIntoOther({
|
||||
labels: twins.map(({ label }) => label),
|
||||
counts: twins.map(({ counts }) => counts),
|
||||
colors: ['#a', '#b'],
|
||||
visible: [true, true],
|
||||
maxGroups: 2,
|
||||
otherColor: '#999',
|
||||
});
|
||||
|
||||
expect(resolveClickedGroup(drawnTwins, 1, twins)).toStrictEqual({
|
||||
series: twins[1],
|
||||
color: '#b',
|
||||
});
|
||||
});
|
||||
});
|
||||
29
frontend/src/lib/visualization/charts/Distribution/colors.ts
Normal file
29
frontend/src/lib/visualization/charts/Distribution/colors.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { toAlphaHex } from 'lib/uPlotV2/utils/fillOpacity';
|
||||
|
||||
const HEX_COLOR = /^#[0-9a-f]{6}$/i;
|
||||
|
||||
export function withOpacity(color: string, opacity: number): string {
|
||||
return HEX_COLOR.test(color) ? `${color}${toAlphaHex(opacity)}` : color;
|
||||
}
|
||||
|
||||
/** Neutral, so the summed remainder never reads as one more group. */
|
||||
export function resolveOtherColor(isDarkMode: boolean): string {
|
||||
return isDarkMode ? Color.BG_VANILLA_400 : Color.BG_SLATE_50;
|
||||
}
|
||||
|
||||
export function resolveSeriesColor(
|
||||
label: string,
|
||||
isDarkMode: boolean,
|
||||
customColors?: Record<string, string>,
|
||||
): string {
|
||||
return (
|
||||
customColors?.[label] ??
|
||||
generateColor(
|
||||
label,
|
||||
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
@use '../../../../../../styles/scrollbar' as *;
|
||||
|
||||
// Same surface as the shared and heatmap tooltips.
|
||||
.container {
|
||||
--gutter: 14px;
|
||||
--row-inset: 6px;
|
||||
|
||||
font-family: 'Inter';
|
||||
font-size: 12px;
|
||||
background: var(--l2-background);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color: var(--l2-foreground);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--l2-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 240px;
|
||||
|
||||
&.pinned {
|
||||
border-color: var(--ring);
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: var(--l2-border);
|
||||
}
|
||||
|
||||
// Same as the heatmap tooltip's bucket row.
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-6) var(--gutter);
|
||||
}
|
||||
|
||||
.titleBucket,
|
||||
.titleCount {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--popover-foreground);
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.titleSeparator {
|
||||
flex: 1;
|
||||
border-width: 0.5px;
|
||||
border-style: dashed;
|
||||
border-color: currentColor;
|
||||
min-width: 24px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
// One grid for the heading and every row, so their columns line up.
|
||||
.rows {
|
||||
display: grid;
|
||||
grid-template-columns: 12px minmax(0, 1fr) repeat(3, auto);
|
||||
column-gap: var(--spacing-6);
|
||||
row-gap: var(--spacing-1);
|
||||
align-content: start;
|
||||
padding: var(--spacing-4) var(--spacing-4) var(--spacing-6);
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
.columnHeader,
|
||||
.rows .row {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: subgrid;
|
||||
// The shared item's own `gap` would otherwise shift the subgrid off the parent.
|
||||
column-gap: var(--spacing-6);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.columnHeader {
|
||||
padding: 0 6px var(--spacing-2);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted-foreground);
|
||||
|
||||
> span:nth-child(n + 2) {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.columnLabel {
|
||||
grid-column: 1 / 3;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rows .row {
|
||||
border-radius: var(--radius);
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
&[data-is-active='true'] {
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
> :nth-child(n + 3) {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
formatRowLabel,
|
||||
resolveRowIndex,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import TooltipPinnedBadge from 'lib/uPlotV2/components/Tooltip/components/TooltipPinnedBadge/TooltipPinnedBadge';
|
||||
import ItemStyles from 'lib/uPlotV2/components/Tooltip/components/TooltipItem/TooltipItem.module.scss';
|
||||
|
||||
import { resolveNearestSeries } from '../../layout';
|
||||
import {
|
||||
buildDistributionTooltipRows,
|
||||
formatPercent,
|
||||
resolveGroupColumn,
|
||||
} from './distributionTooltipContent';
|
||||
import { DistributionTooltipProps } from './types';
|
||||
|
||||
import Styles from './DistributionTooltip.module.scss';
|
||||
|
||||
/**
|
||||
* The bucket comes from the live cursor, not uPlot's `cursor.idx`: that snaps to the
|
||||
* nearest bucket centre, which on uneven widths is often the neighbour.
|
||||
*/
|
||||
export default function DistributionTooltip({
|
||||
uPlotInstance,
|
||||
xAxis,
|
||||
layout,
|
||||
labels,
|
||||
colors,
|
||||
counts,
|
||||
formatBound,
|
||||
isPinned,
|
||||
dismiss,
|
||||
renderTooltipFooter,
|
||||
}: DistributionTooltipProps): JSX.Element | null {
|
||||
// Read outside the memo: uPlot mutates the same instance on every move.
|
||||
const { left = -10, top = -10 } = uPlotInstance.cursor;
|
||||
|
||||
const hover = useMemo(() => {
|
||||
if (left < 0 || top < 0) {
|
||||
return null;
|
||||
}
|
||||
const x = uPlotInstance.posToVal(left, 'x');
|
||||
const bucket = resolveRowIndex(xAxis.edges, x);
|
||||
if (bucket === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
bucket,
|
||||
series: resolveNearestSeries(
|
||||
layout,
|
||||
bucket,
|
||||
x,
|
||||
uPlotInstance.posToVal(top, 'y'),
|
||||
),
|
||||
};
|
||||
}, [left, top, uPlotInstance, xAxis, layout]);
|
||||
|
||||
const groupColumn = useMemo(() => resolveGroupColumn(labels), [labels]);
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
hover
|
||||
? buildDistributionTooltipRows({
|
||||
labels,
|
||||
colors,
|
||||
counts,
|
||||
bucket: hover.bucket,
|
||||
hoveredSeries: hover.series,
|
||||
formatLabel: groupColumn.format,
|
||||
})
|
||||
: [],
|
||||
[hover, labels, colors, counts, groupColumn],
|
||||
);
|
||||
|
||||
if (!hover) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bucket = xAxis.rows[hover.bucket];
|
||||
const bucketTotal = rows.reduce((sum, row) => sum + row.count, 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(Styles.container, { [Styles.pinned]: isPinned })}
|
||||
data-pinned={isPinned}
|
||||
data-testid="distribution-tooltip"
|
||||
>
|
||||
<div className={Styles.title}>
|
||||
<span
|
||||
className={Styles.titleBucket}
|
||||
data-testid="distribution-tooltip-range"
|
||||
>
|
||||
{formatRowLabel(bucket, formatBound)}
|
||||
</span>
|
||||
<span className={Styles.titleSeparator} />
|
||||
<span
|
||||
className={Styles.titleCount}
|
||||
data-testid="distribution-tooltip-total"
|
||||
>
|
||||
{bucketTotal.toLocaleString()}
|
||||
</span>
|
||||
{isPinned && <TooltipPinnedBadge />}
|
||||
</div>
|
||||
|
||||
<span className={Styles.divider} />
|
||||
|
||||
<div className={Styles.rows}>
|
||||
<div className={Styles.columnHeader}>
|
||||
<span
|
||||
className={Styles.columnLabel}
|
||||
title={groupColumn.heading}
|
||||
data-testid="distribution-tooltip-group-heading"
|
||||
>
|
||||
{groupColumn.heading}
|
||||
</span>
|
||||
<span>Count</span>
|
||||
<span>% Total</span>
|
||||
<span data-testid="distribution-tooltip-cumulative-heading">
|
||||
{bucket.isOverflow ? 'All' : `≤ ${formatBound(bucket.upper)}`}
|
||||
</span>
|
||||
</div>
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.seriesIndex}
|
||||
className={cx(ItemStyles.uplotTooltipItem, Styles.row)}
|
||||
data-is-active={row.isHovered}
|
||||
data-testid="distribution-tooltip-row"
|
||||
data-hovered={row.isHovered}
|
||||
>
|
||||
<span
|
||||
className={ItemStyles.uplotTooltipItemMarker}
|
||||
style={{ borderColor: row.color, backgroundColor: row.color }}
|
||||
/>
|
||||
<span className={ItemStyles.uplotTooltipItemLabel} title={row.label}>
|
||||
{row.label}
|
||||
</span>
|
||||
<span className={ItemStyles.uplotTooltipItemValue}>
|
||||
{row.count.toLocaleString()}
|
||||
</span>
|
||||
<span className={ItemStyles.uplotTooltipItemValue}>
|
||||
{formatPercent(row.share)}
|
||||
</span>
|
||||
<span className={ItemStyles.uplotTooltipItemValue}>
|
||||
{formatPercent(row.cumulativeShare)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{renderTooltipFooter?.({ isPinned, dismiss })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import {
|
||||
resolveDistributionBars,
|
||||
resolveDistributionXAxis,
|
||||
} from 'lib/visualization/charts/Distribution/layout';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import DistributionTooltip from '../DistributionTooltip';
|
||||
|
||||
// Buckets ≤ 10, (10, 20], (20, 40], > 40 in slots [0, 1] … [3, 4].
|
||||
const xAxis = resolveDistributionXAxis([10, 20, 40]);
|
||||
const counts = [
|
||||
[1, 4, 2, 0],
|
||||
[0, 6, 1, 1],
|
||||
];
|
||||
const layout = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
stack: StackMode.Normal,
|
||||
isLogY: false,
|
||||
});
|
||||
|
||||
/** Pixels are values: the cursor at (1.5, 2) is over the first group in (10, 20]. */
|
||||
function plotAt(left: number, top: number): uPlot {
|
||||
const plot: Partial<uPlot> = {
|
||||
cursor: { left, top },
|
||||
posToVal: (position: number) => position,
|
||||
};
|
||||
return plot as uPlot;
|
||||
}
|
||||
|
||||
function renderTooltip(uPlotInstance: uPlot): void {
|
||||
render(
|
||||
<DistributionTooltip
|
||||
uPlotInstance={uPlotInstance}
|
||||
isPinned={false}
|
||||
dismiss={jest.fn()}
|
||||
xAxis={xAxis}
|
||||
layout={layout}
|
||||
labels={['{service.name="cart"}', '{service.name="checkout"}']}
|
||||
colors={['#f00', '#0f0']}
|
||||
counts={counts}
|
||||
formatBound={(value): string => `${value} ms`}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('DistributionTooltip', () => {
|
||||
it('names the bucket under the cursor and its total', () => {
|
||||
renderTooltip(plotAt(1.5, 2));
|
||||
|
||||
expect(screen.getByTestId('distribution-tooltip-range')).toHaveTextContent(
|
||||
'10 ms – 20 ms',
|
||||
);
|
||||
expect(screen.getByTestId('distribution-tooltip-total')).toHaveTextContent(
|
||||
'10',
|
||||
);
|
||||
});
|
||||
|
||||
it('heads the columns with the group-by key and the bucket bound', () => {
|
||||
renderTooltip(plotAt(1.5, 2));
|
||||
|
||||
expect(
|
||||
screen.getByTestId('distribution-tooltip-group-heading'),
|
||||
).toHaveTextContent('service.name');
|
||||
expect(
|
||||
screen.getByTestId('distribution-tooltip-cumulative-heading'),
|
||||
).toHaveTextContent('≤ 20 ms');
|
||||
});
|
||||
|
||||
it('lists every group in legend order, the hovered one active', () => {
|
||||
renderTooltip(plotAt(1.5, 2));
|
||||
|
||||
const rows = screen.getAllByTestId('distribution-tooltip-row');
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]).toHaveTextContent('cart457%71%');
|
||||
expect(rows[0]).toHaveAttribute('data-is-active', 'true');
|
||||
expect(rows[1]).toHaveTextContent('checkout675%75%');
|
||||
expect(rows[1]).toHaveAttribute('data-is-active', 'false');
|
||||
});
|
||||
|
||||
it('renders nothing off the plot', () => {
|
||||
renderTooltip(plotAt(-1, -1));
|
||||
|
||||
expect(screen.queryByTestId('distribution-tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
buildDistributionTooltipRows,
|
||||
formatPercent,
|
||||
resolveGroupColumn,
|
||||
} from '../distributionTooltipContent';
|
||||
|
||||
describe('formatPercent', () => {
|
||||
it('rounds, and never shows a real share as 0%', () => {
|
||||
expect(formatPercent(0.256)).toBe('26%');
|
||||
expect(formatPercent(0.004)).toBe('<1%');
|
||||
expect(formatPercent(0)).toBe('0%');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveGroupColumn', () => {
|
||||
it('names the column after a shared single key and shows its values', () => {
|
||||
const column = resolveGroupColumn([
|
||||
'{service.name="cart"}',
|
||||
'{service.name="checkout"}',
|
||||
]);
|
||||
|
||||
expect(column.heading).toBe('service.name');
|
||||
expect(column.format('{service.name="cart"}')).toBe('cart');
|
||||
});
|
||||
|
||||
it('keeps full labels for several keys or mixed labels', () => {
|
||||
const several = resolveGroupColumn(['{env="prod", service.name="cart"}']);
|
||||
const mixed = resolveGroupColumn(['{service.name="cart"}', 'Other']);
|
||||
|
||||
expect(several.heading).toBe('Group');
|
||||
expect(several.format('{env="prod", service.name="cart"}')).toBe(
|
||||
'{env="prod", service.name="cart"}',
|
||||
);
|
||||
expect(mixed.heading).toBe('Group');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDistributionTooltipRows', () => {
|
||||
it('gives each drawn group, in legend order, its count, share and cumulative share', () => {
|
||||
const rows = buildDistributionTooltipRows({
|
||||
labels: ['cart', 'checkout'],
|
||||
colors: ['#f00', '#0f0'],
|
||||
counts: [
|
||||
[2, 6, 2],
|
||||
[5, 0, 5],
|
||||
],
|
||||
bucket: 1,
|
||||
hoveredSeries: 0,
|
||||
formatLabel: (label) => label.toUpperCase(),
|
||||
});
|
||||
|
||||
expect(rows).toStrictEqual([
|
||||
{
|
||||
seriesIndex: 0,
|
||||
label: 'CART',
|
||||
color: '#f00',
|
||||
count: 6,
|
||||
share: 0.6,
|
||||
cumulativeShare: 0.8,
|
||||
isHovered: true,
|
||||
},
|
||||
{
|
||||
seriesIndex: 1,
|
||||
label: 'CHECKOUT',
|
||||
color: '#0f0',
|
||||
count: 0,
|
||||
share: 0,
|
||||
cumulativeShare: 0.5,
|
||||
isHovered: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { DistributionGroupColumn, DistributionTooltipRow } from './types';
|
||||
|
||||
const SINGLE_LABEL = /^\{([^=,]+)="([^"]*)"\}$/;
|
||||
|
||||
/** `<1%` rather than `0%`, so a share that exists never reads as none. */
|
||||
export function formatPercent(share: number): string {
|
||||
if (share > 0 && share < 0.01) {
|
||||
return '<1%';
|
||||
}
|
||||
return `${Math.round(share * 100)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Names the group column after the one group-by key every label shares, showing just
|
||||
* its values; anything else (several keys, Other) keeps the full labels.
|
||||
*/
|
||||
export function resolveGroupColumn(labels: string[]): DistributionGroupColumn {
|
||||
const keys = new Set<string>();
|
||||
labels.forEach((label) => {
|
||||
keys.add(SINGLE_LABEL.exec(label)?.[1] ?? '');
|
||||
});
|
||||
const [key] = [...keys];
|
||||
if (keys.size !== 1 || !key) {
|
||||
return { heading: 'Group', format: (label) => label };
|
||||
}
|
||||
return {
|
||||
heading: key,
|
||||
format: (label) => SINGLE_LABEL.exec(label)?.[2] ?? label,
|
||||
};
|
||||
}
|
||||
|
||||
/** One row per drawn group, in legend order, with its count in `bucket`. */
|
||||
export function buildDistributionTooltipRows({
|
||||
labels,
|
||||
colors,
|
||||
counts,
|
||||
bucket,
|
||||
hoveredSeries,
|
||||
formatLabel,
|
||||
}: {
|
||||
labels: string[];
|
||||
colors: string[];
|
||||
/** `counts[series][bucket]`. */
|
||||
counts: number[][];
|
||||
bucket: number;
|
||||
hoveredSeries: number | null;
|
||||
formatLabel: (label: string) => string;
|
||||
}): DistributionTooltipRow[] {
|
||||
return labels.map((label, series) => {
|
||||
const seriesCounts = counts[series] ?? [];
|
||||
let total = 0;
|
||||
let atOrBelow = 0;
|
||||
seriesCounts.forEach((count, index) => {
|
||||
total += count;
|
||||
if (index <= bucket) {
|
||||
atOrBelow += count;
|
||||
}
|
||||
});
|
||||
const count = seriesCounts[bucket] ?? 0;
|
||||
return {
|
||||
seriesIndex: series,
|
||||
label: formatLabel(label),
|
||||
color: colors[series],
|
||||
count,
|
||||
share: total > 0 ? count / total : 0,
|
||||
cumulativeShare: total > 0 ? atOrBelow / total : 0,
|
||||
isHovered: series === hoveredSeries,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type {
|
||||
IRenderTooltipFooterArgs,
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
|
||||
import type { DistributionBarsLayout } from '../../layout';
|
||||
import type { DistributionXAxis } from '../../types';
|
||||
|
||||
export interface DistributionTooltipRow {
|
||||
/** Index among the drawn series. */
|
||||
seriesIndex: number;
|
||||
label: string;
|
||||
color: string;
|
||||
count: number;
|
||||
/** Of the group's own total over the range. */
|
||||
share: number;
|
||||
/** Of the group's total, at or below this bucket. */
|
||||
cumulativeShare: number;
|
||||
isHovered: boolean;
|
||||
}
|
||||
|
||||
export interface DistributionGroupColumn {
|
||||
heading: string;
|
||||
format: (label: string) => string;
|
||||
}
|
||||
|
||||
export interface DistributionTooltipProps extends Pick<
|
||||
TooltipRenderArgs,
|
||||
'uPlotInstance' | 'isPinned' | 'dismiss'
|
||||
> {
|
||||
xAxis: DistributionXAxis;
|
||||
layout: DistributionBarsLayout;
|
||||
labels: string[];
|
||||
colors: string[];
|
||||
/** `counts[series][bucket]`. */
|
||||
counts: number[][];
|
||||
formatBound: (value: number) => string;
|
||||
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => React.ReactNode;
|
||||
}
|
||||
288
frontend/src/lib/visualization/charts/Distribution/config.ts
Normal file
288
frontend/src/lib/visualization/charts/Distribution/config.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import {
|
||||
DistributionType,
|
||||
DrawStyle,
|
||||
StackMode,
|
||||
} from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { createVariableWidthBarsPathBuilder } from 'lib/uPlotV2/paths/variableWidthBars';
|
||||
import { resolveRowIndex } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import { createHatchPattern } from 'lib/uPlotV2/plugins/HeatmapPlugin/paint';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { withOpacity } from './colors';
|
||||
import {
|
||||
drawPercentileMarkers,
|
||||
MARKER_HIT_PX,
|
||||
PercentileMarkerHitArea,
|
||||
resolveHoveredMarker,
|
||||
} from './drawPercentileMarkers';
|
||||
import {
|
||||
DistributionBarsLayout,
|
||||
LOG_Y_BASE,
|
||||
resolveNearestSeries,
|
||||
thinLabelledSplits,
|
||||
} from './layout';
|
||||
import { DistributionPercentile, groupPercentileMarkers } from './percentiles';
|
||||
import { DistributionXAxis } from './types';
|
||||
import { createBoundFormatter } from './utils';
|
||||
|
||||
/** Minimum space between two x tick labels, in CSS pixels. */
|
||||
const X_LABEL_GAP_PX = 12;
|
||||
const BAR_GAP_PX = 1;
|
||||
const BAR_OPACITY = 0.85;
|
||||
const OVERFLOW_BAR_OPACITY = 0.4;
|
||||
/** Every group but the hovered one fades to this. */
|
||||
const DIMMED_BAR_OPACITY = 0.25;
|
||||
const DIMMED_OVERFLOW_BAR_OPACITY = 0.12;
|
||||
const OVERFLOW_AXIS_LABEL = '∞';
|
||||
/** Headroom above the tallest bar: a fraction on linear, a factor on log. Marker
|
||||
* labels sit at the top of the plot, so they take more. */
|
||||
const LINEAR_HEADROOM = 1.1;
|
||||
const LINEAR_HEADROOM_WITH_MARKERS = 1.3;
|
||||
const LOG_HEADROOM = 2;
|
||||
const LOG_HEADROOM_WITH_MARKERS = 6;
|
||||
|
||||
function resolveYRange({
|
||||
yMax,
|
||||
isLogY,
|
||||
stack,
|
||||
hasMarkers,
|
||||
}: {
|
||||
yMax: number;
|
||||
isLogY: boolean;
|
||||
stack: StackMode;
|
||||
hasMarkers: boolean;
|
||||
}): [number, number] {
|
||||
if (stack === StackMode.Percent) {
|
||||
return [0, hasMarkers ? 100 * LINEAR_HEADROOM_WITH_MARKERS : 100];
|
||||
}
|
||||
if (isLogY) {
|
||||
const headroom = hasMarkers ? LOG_HEADROOM_WITH_MARKERS : LOG_HEADROOM;
|
||||
return [LOG_Y_BASE, Math.max(yMax, LOG_Y_BASE) * headroom];
|
||||
}
|
||||
const headroom = hasMarkers ? LINEAR_HEADROOM_WITH_MARKERS : LINEAR_HEADROOM;
|
||||
return [0, yMax > 0 ? yMax * headroom : 1];
|
||||
}
|
||||
|
||||
export interface BuildDistributionConfigArgs {
|
||||
id: string;
|
||||
xAxis: DistributionXAxis;
|
||||
layout: DistributionBarsLayout;
|
||||
labels: string[];
|
||||
colors: string[];
|
||||
stack: StackMode;
|
||||
isLogY: boolean;
|
||||
isDarkMode: boolean;
|
||||
/** Drawn across the plot, labelled at the top. */
|
||||
markers: DistributionPercentile[];
|
||||
/** Unit of the bucket bounds; counts take none. */
|
||||
xAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
}
|
||||
|
||||
export function buildDistributionConfig({
|
||||
id,
|
||||
xAxis,
|
||||
layout,
|
||||
labels,
|
||||
colors,
|
||||
stack,
|
||||
isLogY,
|
||||
isDarkMode,
|
||||
markers,
|
||||
xAxisUnit,
|
||||
decimalPrecision,
|
||||
}: BuildDistributionConfigArgs): UPlotConfigBuilder {
|
||||
const builder = new UPlotConfigBuilder({ id });
|
||||
// uPlot's focus picks the series nearest in value, meaningless for stacked bars; the
|
||||
// setCursor hook focuses the one under the cursor itself. Alpha 1 leaves the
|
||||
// dimming to the bar fills.
|
||||
builder.setFocus({ alpha: 1 });
|
||||
// The x range is pinned to the buckets, so a drag-zoom would only flash a box.
|
||||
builder.setCursor({
|
||||
focus: { prox: -1 },
|
||||
points: { show: false },
|
||||
drag: { x: false, y: false },
|
||||
});
|
||||
|
||||
const formatBound = createBoundFormatter(xAxisUnit, decimalPrecision);
|
||||
|
||||
builder.addScale({
|
||||
scaleKey: 'x',
|
||||
time: false,
|
||||
auto: false,
|
||||
range: (): [number, number] => [xAxis.min, xAxis.max],
|
||||
});
|
||||
const yRange = resolveYRange({
|
||||
yMax: layout.yMax,
|
||||
isLogY,
|
||||
stack,
|
||||
hasMarkers: markers.length > 0,
|
||||
});
|
||||
builder.addScale({
|
||||
scaleKey: 'y',
|
||||
time: false,
|
||||
auto: false,
|
||||
distribution: isLogY ? DistributionType.Logarithmic : DistributionType.Linear,
|
||||
range: (): [number, number] => yRange,
|
||||
});
|
||||
|
||||
const axisSplits =
|
||||
xAxis.overflowSplit === null
|
||||
? xAxis.splits
|
||||
: [...xAxis.splits, xAxis.overflowSplit];
|
||||
const splitLabels = new Map<number, string>();
|
||||
xAxis.splits.forEach((split, index) => {
|
||||
splitLabels.set(split, formatBound(xAxis.rows[index].upper));
|
||||
});
|
||||
if (xAxis.overflowSplit !== null) {
|
||||
splitLabels.set(xAxis.overflowSplit, OVERFLOW_AXIS_LABEL);
|
||||
}
|
||||
|
||||
const labelOf = (split: number): string =>
|
||||
splitLabels.get(split) ?? formatBound(xAxis.toBucketValue(split));
|
||||
|
||||
builder.addAxis({
|
||||
scaleKey: 'x',
|
||||
side: 2,
|
||||
isDarkMode,
|
||||
// Thinned by each label's own width: bound labels run from `5 s` to
|
||||
// `16.67 mins`, so no fixed spacing keeps them apart.
|
||||
splits: (self, axisIdx): number[] => {
|
||||
const visible = axisSplits.filter(
|
||||
(split) => split >= xAxis.min && split <= xAxis.max,
|
||||
);
|
||||
const font = self.axes[axisIdx]?.font?.[0];
|
||||
if (font) {
|
||||
self.ctx.font = font;
|
||||
}
|
||||
const kept = thinLabelledSplits({
|
||||
positions: visible.map((split) => self.valToPos(split, 'x')),
|
||||
widths: visible.map(
|
||||
(split) => self.ctx.measureText(labelOf(split)).width / uPlot.pxRatio,
|
||||
),
|
||||
gap: X_LABEL_GAP_PX,
|
||||
});
|
||||
return kept.map((index) => visible[index]);
|
||||
},
|
||||
values: (_, splits): string[] => splits.map(labelOf),
|
||||
});
|
||||
builder.addAxis({
|
||||
scaleKey: 'y',
|
||||
side: 3,
|
||||
isDarkMode,
|
||||
yAxisUnit: stack === StackMode.Percent ? 'percent' : 'short',
|
||||
// The marker headroom runs a share axis past 100%, which labels nothing real.
|
||||
...(stack === StackMode.Percent && {
|
||||
filter: (_self: uPlot, splits: number[]): Array<number | null> =>
|
||||
splits.map((split) => (split <= 100 ? split : null)),
|
||||
}),
|
||||
});
|
||||
|
||||
let hatchPattern: CanvasPattern | null | undefined;
|
||||
const getHatchPattern = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
): CanvasPattern | null => {
|
||||
hatchPattern ??= createHatchPattern(ctx, isDarkMode);
|
||||
return hatchPattern;
|
||||
};
|
||||
|
||||
// Read by every series' fill on each draw; a change redraws the paths.
|
||||
const hover: {
|
||||
series: number | null;
|
||||
focused: number | null;
|
||||
marker: number | null;
|
||||
} = { series: null, focused: null, marker: null };
|
||||
const plotMarkers = groupPercentileMarkers(
|
||||
markers.filter(
|
||||
({ axisValue }) => axisValue >= xAxis.min && axisValue <= xAxis.max,
|
||||
),
|
||||
);
|
||||
let markerHitAreas: PercentileMarkerHitArea[] = [];
|
||||
const isDimmed = (seriesIndex: number): boolean =>
|
||||
hover.series !== null && hover.series !== seriesIndex;
|
||||
|
||||
labels.forEach((label, seriesIndex) => {
|
||||
const color = colors[seriesIndex];
|
||||
builder.addSeries({
|
||||
scaleKey: 'y',
|
||||
label,
|
||||
drawStyle: DrawStyle.Bar,
|
||||
pathBuilder: createVariableWidthBarsPathBuilder({
|
||||
getBars: () => layout.rects[seriesIndex] ?? [],
|
||||
fill: () =>
|
||||
withOpacity(
|
||||
color,
|
||||
isDimmed(seriesIndex) ? DIMMED_BAR_OPACITY : BAR_OPACITY,
|
||||
),
|
||||
hatchedFill: () =>
|
||||
withOpacity(
|
||||
color,
|
||||
isDimmed(seriesIndex)
|
||||
? DIMMED_OVERFLOW_BAR_OPACITY
|
||||
: OVERFLOW_BAR_OPACITY,
|
||||
),
|
||||
getHatchPattern,
|
||||
gapPx: BAR_GAP_PX,
|
||||
}),
|
||||
lineColor: color,
|
||||
lineWidth: 0,
|
||||
colorMapping: {},
|
||||
isDarkMode,
|
||||
showPoints: false,
|
||||
spanGaps: false,
|
||||
});
|
||||
});
|
||||
|
||||
builder.addHook('draw', (u: uPlot): void => {
|
||||
markerHitAreas = drawPercentileMarkers(
|
||||
u,
|
||||
plotMarkers,
|
||||
formatBound,
|
||||
isDarkMode,
|
||||
hover.marker,
|
||||
);
|
||||
});
|
||||
|
||||
// Registered as a hook, not a plugin, so it runs before TooltipPlugin's: the tooltip
|
||||
// only shows for a focused series, and must find it focused when it positions.
|
||||
builder.addHook('setCursor', (u: uPlot): void => {
|
||||
const { left = -1, top = -1 } = u.cursor;
|
||||
const isOnPlot = left >= 0 && top >= 0;
|
||||
const x = isOnPlot ? u.posToVal(left, 'x') : Number.NaN;
|
||||
const bucket = isOnPlot ? resolveRowIndex(xAxis.edges, x) : null;
|
||||
const marker = isOnPlot
|
||||
? resolveHoveredMarker(markerHitAreas, left, top, MARKER_HIT_PX)
|
||||
: null;
|
||||
// Over a marker, the bars neither dim nor show their tooltip.
|
||||
const hovered =
|
||||
bucket === null || marker !== null
|
||||
? null
|
||||
: resolveNearestSeries(layout, bucket, x, u.posToVal(top, 'y'));
|
||||
// An empty bucket still gets a tooltip, as a bar chart's zero column does; with no
|
||||
// nearest group to name, the first stands in.
|
||||
const isEmptyBucket = bucket !== null && marker === null && hovered === null;
|
||||
const focused = isEmptyBucket && labels.length > 0 ? 0 : hovered;
|
||||
|
||||
if (focused !== hover.focused) {
|
||||
hover.focused = focused;
|
||||
u.setSeries(focused === null ? null : focused + 1, { focus: true });
|
||||
}
|
||||
if (marker !== hover.marker) {
|
||||
// The crosshair would cut through the marker's badges.
|
||||
u.root
|
||||
.querySelectorAll<HTMLElement>('.u-cursor-x, .u-cursor-y')
|
||||
.forEach((line) => {
|
||||
line.style.visibility = marker === null ? '' : 'hidden';
|
||||
});
|
||||
}
|
||||
if (hovered !== hover.series || marker !== hover.marker) {
|
||||
hover.series = hovered;
|
||||
hover.marker = marker;
|
||||
u.redraw(true, false);
|
||||
}
|
||||
});
|
||||
|
||||
return builder;
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import {
|
||||
DistributionPercentile,
|
||||
formatPercentileBadge,
|
||||
formatPercentileValue,
|
||||
} from './percentiles';
|
||||
|
||||
const MARKER_DASH: [number, number] = [3, 3];
|
||||
const MARKER_FONT_SIZE_PX = 9;
|
||||
/** Space between a badge's name and its diamond's edges. */
|
||||
const MARKER_BADGE_PADDING_PX = 2;
|
||||
const MARKER_BADGE_GAP_PX = 2;
|
||||
const MARKER_VALUE_FONT_SIZE_PX = 11;
|
||||
/** How far from a marker line the cursor still hovers it, in CSS pixels. */
|
||||
export const MARKER_HIT_PX = 4;
|
||||
const MARKER_PILL_PADDING_X_PX = 6;
|
||||
const MARKER_PILL_PADDING_Y_PX = 3;
|
||||
const MARKER_PILL_RADIUS_PX = 4;
|
||||
/** Height of the pill's notch pointing up at the line; also its gap from the axis. */
|
||||
const MARKER_PILL_POINTER_PX = 4;
|
||||
|
||||
interface HitRect {
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
/** A drawn marker's hover targets, in CSS pixels relative to the plot. */
|
||||
export interface PercentileMarkerHitArea {
|
||||
x: number;
|
||||
badges: HitRect[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The marker under `(x, y)`: one whose badge it is over, else the line nearest
|
||||
* `x` within `tolerance`. `null` when none is.
|
||||
*/
|
||||
export function resolveHoveredMarker(
|
||||
areas: PercentileMarkerHitArea[],
|
||||
x: number,
|
||||
y: number,
|
||||
tolerance: number,
|
||||
): number | null {
|
||||
const overBadge = areas.findIndex(({ badges }) =>
|
||||
badges.some(
|
||||
(badge) =>
|
||||
x >= badge.left && x <= badge.right && y >= badge.top && y <= badge.bottom,
|
||||
),
|
||||
);
|
||||
if (overBadge !== -1) {
|
||||
return overBadge;
|
||||
}
|
||||
let nearest: number | null = null;
|
||||
let nearestDistance = tolerance;
|
||||
areas.forEach((area, index) => {
|
||||
const distance = Math.abs(area.x - x);
|
||||
if (distance <= nearestDistance) {
|
||||
nearest = index;
|
||||
nearestDistance = distance;
|
||||
}
|
||||
});
|
||||
return nearest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stacks marker badges into rows so none overlap: each goes in the first row whose
|
||||
* last badge ends before it starts. Input and output are in the same pixel space.
|
||||
*/
|
||||
export function layoutMarkerLabels(
|
||||
labels: Array<{ x: number; width: number }>,
|
||||
gap: number,
|
||||
): number[] {
|
||||
const rowEnds: number[] = [];
|
||||
const order = labels
|
||||
.map((label, index) => ({ ...label, index }))
|
||||
.sort((a, b) => a.x - b.x);
|
||||
const rows = new Array<number>(labels.length).fill(0);
|
||||
|
||||
order.forEach(({ x, width, index }) => {
|
||||
let row = rowEnds.findIndex((end) => x >= end + gap);
|
||||
if (row === -1) {
|
||||
row = rowEnds.length;
|
||||
rowEnds.push(0);
|
||||
}
|
||||
rowEnds[row] = x + width;
|
||||
rows[index] = row;
|
||||
});
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** A square diamond centred on `(x, y)` with half-diagonal `r`, filled then outlined. */
|
||||
function drawDiamond(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
r: number,
|
||||
fill: string,
|
||||
stroke: string,
|
||||
): void {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y - r);
|
||||
ctx.lineTo(x + r, y);
|
||||
ctx.lineTo(x, y + r);
|
||||
ctx.lineTo(x - r, y);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = fill;
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = stroke;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashed lines across the plot with a diamond badge at each end naming the
|
||||
* percentile, one per group on the same spot; badges stack into rows so none overlap. The hovered marker is
|
||||
* emphasised and shows its value on the x axis. Returns each marker's hover targets.
|
||||
*/
|
||||
export function drawPercentileMarkers(
|
||||
u: uPlot,
|
||||
markers: DistributionPercentile[][],
|
||||
formatBound: (value: number) => string,
|
||||
isDarkMode: boolean,
|
||||
hovered: number | null,
|
||||
): PercentileMarkerHitArea[] {
|
||||
if (markers.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const { ctx, bbox } = u;
|
||||
const { pxRatio } = uPlot;
|
||||
const color = isDarkMode ? themeColors.white : themeColors.black;
|
||||
const background = isDarkMode ? Color.BG_INK_400 : Color.BG_VANILLA_100;
|
||||
const bottom = bbox.top + bbox.height;
|
||||
|
||||
ctx.save();
|
||||
ctx.font = `600 ${MARKER_FONT_SIZE_PX * pxRatio}px Inter, sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
const named = markers.map((group) => {
|
||||
const text = formatPercentileBadge(group);
|
||||
return { text, width: ctx.measureText(text).width };
|
||||
});
|
||||
// One square diamond for all, fitting the widest name's box: |dx| + |dy| ≤ r at
|
||||
// its corners. Cap height stands in for the text's height.
|
||||
const r =
|
||||
(Math.max(...named.map(({ width }) => width)) +
|
||||
MARKER_FONT_SIZE_PX * pxRatio * 0.7) /
|
||||
2 +
|
||||
MARKER_BADGE_PADDING_PX * pxRatio;
|
||||
const badges = markers.map((group, index) => ({
|
||||
text: named[index].text,
|
||||
x: u.valToPos(group[0].axisValue, 'x', true),
|
||||
}));
|
||||
const rows = layoutMarkerLabels(
|
||||
badges.map(({ x }) => ({ x: x - r, width: 2 * r })),
|
||||
MARKER_BADGE_GAP_PX * pxRatio,
|
||||
);
|
||||
const rowStep = 2 * r + MARKER_BADGE_GAP_PX * pxRatio;
|
||||
const badgeCentres = (index: number): [number, number] => {
|
||||
const offset = r + rows[index] * rowStep;
|
||||
return [bbox.top + offset, bottom - offset];
|
||||
};
|
||||
|
||||
const drawMarker = (index: number): void => {
|
||||
const { text, x } = badges[index];
|
||||
const isHovered = index === hovered;
|
||||
|
||||
ctx.globalAlpha = isHovered ? 1 : 0.55;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = pxRatio;
|
||||
ctx.setLineDash(MARKER_DASH.map((dash) => dash * pxRatio));
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, bbox.top);
|
||||
ctx.lineTo(x, bottom);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.setLineDash([]);
|
||||
badgeCentres(index).forEach((y) => {
|
||||
drawDiamond(ctx, x, y, r, isHovered ? color : background, color);
|
||||
ctx.fillStyle = isHovered ? background : color;
|
||||
ctx.fillText(text, x, y);
|
||||
});
|
||||
|
||||
if (!isHovered) {
|
||||
return;
|
||||
}
|
||||
// The value sits on the x axis under the line, over the tick labels, in a pill
|
||||
// whose notch points up at the line.
|
||||
ctx.font = `500 ${MARKER_VALUE_FONT_SIZE_PX * pxRatio}px Inter, sans-serif`;
|
||||
const value = formatPercentileValue(markers[index], formatBound);
|
||||
const padX = MARKER_PILL_PADDING_X_PX * pxRatio;
|
||||
const padY = MARKER_PILL_PADDING_Y_PX * pxRatio;
|
||||
const pointer = MARKER_PILL_POINTER_PX * pxRatio;
|
||||
const width = ctx.measureText(value).width + 2 * padX;
|
||||
const height = MARKER_VALUE_FONT_SIZE_PX * pxRatio + 2 * padY;
|
||||
const left = Math.min(Math.max(x - width / 2, 0), ctx.canvas.width - width);
|
||||
const top = bottom + 2 * pointer;
|
||||
const notchX = Math.min(
|
||||
Math.max(x, left + pointer + MARKER_PILL_RADIUS_PX * pxRatio),
|
||||
left + width - pointer - MARKER_PILL_RADIUS_PX * pxRatio,
|
||||
);
|
||||
// A halo in the chart's background hides the tick labels it overlaps.
|
||||
ctx.fillStyle = background;
|
||||
ctx.fillRect(left - padX, top, width + 2 * padX, height + 2 * padY);
|
||||
ctx.fillStyle = isDarkMode ? Color.BG_SLATE_400 : Color.BG_INK_400;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(left, top, width, height, MARKER_PILL_RADIUS_PX * pxRatio);
|
||||
ctx.moveTo(notchX - pointer, top);
|
||||
ctx.lineTo(notchX, top - pointer);
|
||||
ctx.lineTo(notchX + pointer, top);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.fillStyle = Color.BG_VANILLA_100;
|
||||
ctx.fillText(value, left + width / 2, top + height / 2);
|
||||
};
|
||||
|
||||
// The hovered one last, so it draws over its neighbours.
|
||||
badges.forEach((_, index) => index !== hovered && drawMarker(index));
|
||||
if (hovered !== null && hovered < badges.length) {
|
||||
drawMarker(hovered);
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
return badges.map(({ x }, index) => {
|
||||
const toPlot = (value: number, origin: number): number =>
|
||||
(value - origin) / pxRatio;
|
||||
return {
|
||||
x: toPlot(x, bbox.left),
|
||||
badges: badgeCentres(index).map((y) => ({
|
||||
left: toPlot(x - r, bbox.left),
|
||||
right: toPlot(x + r, bbox.left),
|
||||
top: toPlot(y - r, bbox.top),
|
||||
bottom: toPlot(y + r, bbox.top),
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
232
frontend/src/lib/visualization/charts/Distribution/layout.ts
Normal file
232
frontend/src/lib/visualization/charts/Distribution/layout.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import {
|
||||
cropHeatmapYAxis,
|
||||
resolveHeatmapYAxis,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import { HeatmapAxisScale } from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
|
||||
import { DistributionBarRect, DistributionXAxis } from './types';
|
||||
|
||||
/** Bottom of a log count axis: a bar has to start somewhere above zero. */
|
||||
export const LOG_Y_BASE = 1;
|
||||
|
||||
/**
|
||||
* N bounds give N + 1 buckets: `≤ first`, the finite ones, and `> last`. Every bucket
|
||||
* is equally wide: bucket `i` spans `[i, i + 1]`, and ticks still name the bounds.
|
||||
*/
|
||||
export function resolveDistributionXAxis(buckets: number[]): DistributionXAxis {
|
||||
const { rows, overflowSplit } = resolveHeatmapYAxis(
|
||||
buckets,
|
||||
HeatmapAxisScale.Linear,
|
||||
);
|
||||
const edges = rows.map((_, index) => index).concat(rows.length);
|
||||
return {
|
||||
rows,
|
||||
edges,
|
||||
splits: rows.slice(0, -1).map((_, index) => index + 1),
|
||||
overflowSplit: overflowSplit === null ? null : rows.length,
|
||||
toBucketValue: (axisValue: number): number => {
|
||||
const index = Math.min(rows.length - 1, Math.max(0, Math.floor(axisValue)));
|
||||
const row = rows[index];
|
||||
return row.lower + (axisValue - index) * (row.upper - row.lower);
|
||||
},
|
||||
min: 0,
|
||||
max: rows.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Sum of each bucket across the visible series. */
|
||||
export function sumVisibleCounts(
|
||||
counts: number[][],
|
||||
visible: boolean[],
|
||||
bucketCount: number,
|
||||
): number[] {
|
||||
const totals = new Array<number>(bucketCount).fill(0);
|
||||
counts.forEach((seriesCounts, seriesIndex) => {
|
||||
if (!visible[seriesIndex]) {
|
||||
return;
|
||||
}
|
||||
for (let bucket = 0; bucket < bucketCount; bucket += 1) {
|
||||
totals[bucket] += seriesCounts[bucket] ?? 0;
|
||||
}
|
||||
});
|
||||
return totals;
|
||||
}
|
||||
|
||||
/** Drops empty buckets past the last occupied one, as the heatmap does. */
|
||||
export function cropDistributionXAxis(
|
||||
axis: DistributionXAxis,
|
||||
totals: number[],
|
||||
): DistributionXAxis {
|
||||
return cropHeatmapYAxis(
|
||||
axis,
|
||||
totals.map((total) => [total]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrows the axis window to the buckets overlapping `[min, max]`, never below one.
|
||||
* A bucket straddling a bound stays whole: cutting it would misstate its count.
|
||||
*/
|
||||
export function clampDistributionXAxis(
|
||||
axis: DistributionXAxis,
|
||||
min?: number,
|
||||
max?: number,
|
||||
): DistributionXAxis {
|
||||
const { rows, edges } = axis;
|
||||
let first = edges.indexOf(axis.min);
|
||||
let last = edges.indexOf(axis.max) - 1;
|
||||
while (min !== undefined && first < last && rows[first].upper <= min) {
|
||||
first += 1;
|
||||
}
|
||||
while (max !== undefined && last > first && rows[last].lower >= max) {
|
||||
last -= 1;
|
||||
}
|
||||
return {
|
||||
...axis,
|
||||
overflowSplit: rows[last].isOverflow ? axis.overflowSplit : null,
|
||||
min: edges[first],
|
||||
max: edges[last + 1],
|
||||
};
|
||||
}
|
||||
|
||||
export interface DistributionBarsLayout {
|
||||
/** One list per series, in series order. */
|
||||
rects: DistributionBarRect[][];
|
||||
/** Top of the tallest bar inside the axis window, for the count axis range. */
|
||||
yMax: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every bar, in scale space. `normal` stacks the series in each bucket, `percent`
|
||||
* stacks their shares of it, `none` splits the bucket width between them.
|
||||
*/
|
||||
export function resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
stack,
|
||||
isLogY,
|
||||
}: {
|
||||
xAxis: DistributionXAxis;
|
||||
/** `counts[series][bucket]`. */
|
||||
counts: number[][];
|
||||
stack: StackMode;
|
||||
isLogY: boolean;
|
||||
}): DistributionBarsLayout {
|
||||
const { rows, edges } = xAxis;
|
||||
const rects: DistributionBarRect[][] = counts.map(() => []);
|
||||
const base = isLogY ? LOG_Y_BASE : 0;
|
||||
let yMax = 0;
|
||||
|
||||
rows.forEach((row, bucketIndex) => {
|
||||
const x0 = edges[bucketIndex];
|
||||
const x1 = edges[bucketIndex + 1];
|
||||
// Buckets outside the window are off the plot and mustn't set its height.
|
||||
const isInWindow = x0 >= xAxis.min && x1 <= xAxis.max;
|
||||
const total = counts.reduce(
|
||||
(sum, seriesCounts) => sum + (seriesCounts[bucketIndex] ?? 0),
|
||||
0,
|
||||
);
|
||||
let cumulative = 0;
|
||||
|
||||
counts.forEach((seriesCounts, seriesIndex) => {
|
||||
const count = seriesCounts[bucketIndex] ?? 0;
|
||||
if (count <= 0) {
|
||||
return;
|
||||
}
|
||||
const bar = { bucketIndex, hatched: row.isOverflow };
|
||||
|
||||
if (stack === StackMode.None) {
|
||||
// Below the log base a bar would draw downwards from the axis.
|
||||
if (count <= base) {
|
||||
return;
|
||||
}
|
||||
const width = (x1 - x0) / counts.length;
|
||||
rects[seriesIndex].push({
|
||||
...bar,
|
||||
x0: x0 + seriesIndex * width,
|
||||
x1: x0 + (seriesIndex + 1) * width,
|
||||
y0: base,
|
||||
y1: count,
|
||||
});
|
||||
if (isInWindow) {
|
||||
yMax = Math.max(yMax, count);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const scale = stack === StackMode.Percent ? 100 / total : 1;
|
||||
const y0 = cumulative * scale;
|
||||
cumulative += count;
|
||||
const y1 = cumulative * scale;
|
||||
if (y1 <= base) {
|
||||
return;
|
||||
}
|
||||
rects[seriesIndex].push({
|
||||
...bar,
|
||||
x0,
|
||||
x1,
|
||||
y0: Math.max(y0, base),
|
||||
y1,
|
||||
});
|
||||
if (isInWindow) {
|
||||
yMax = Math.max(yMax, y1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return { rects, yMax };
|
||||
}
|
||||
|
||||
/**
|
||||
* The group nearest `(x, y)` vertically in `bucket`, as uPlot focuses bar series;
|
||||
* side by side, only the sub-bar under `x` competes. `null` over an empty bucket.
|
||||
*/
|
||||
export function resolveNearestSeries(
|
||||
layout: DistributionBarsLayout,
|
||||
bucket: number,
|
||||
x: number,
|
||||
y: number,
|
||||
): number | null {
|
||||
const candidates = layout.rects.flatMap((rects, series) =>
|
||||
rects
|
||||
.filter((rect) => rect.bucketIndex === bucket)
|
||||
.map((rect) => ({ rect, series })),
|
||||
);
|
||||
const underX = candidates.filter(({ rect }) => x >= rect.x0 && x < rect.x1);
|
||||
let nearest: number | null = null;
|
||||
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||
(underX.length > 0 ? underX : candidates).forEach(({ rect, series }) => {
|
||||
const distance = Math.max(rect.y0 - y, y - rect.y1, 0);
|
||||
if (distance < nearestDistance) {
|
||||
nearest = series;
|
||||
nearestDistance = distance;
|
||||
}
|
||||
});
|
||||
return nearest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indexes of the ticks whose centred labels fit without touching, walking from the
|
||||
* right so the `∞` edge survives. Positions and widths share one pixel space.
|
||||
*/
|
||||
export function thinLabelledSplits({
|
||||
positions,
|
||||
widths,
|
||||
gap,
|
||||
}: {
|
||||
positions: number[];
|
||||
widths: number[];
|
||||
gap: number;
|
||||
}): number[] {
|
||||
const kept: number[] = [];
|
||||
let leftEdge = Number.POSITIVE_INFINITY;
|
||||
for (let index = positions.length - 1; index >= 0; index -= 1) {
|
||||
const half = widths[index] / 2;
|
||||
if (positions[index] + half + gap <= leftEdge) {
|
||||
kept.push(index);
|
||||
leftEdge = positions[index] - half;
|
||||
}
|
||||
}
|
||||
return kept.reverse();
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { DistributionXAxis } from './types';
|
||||
|
||||
export interface DistributionPercentile {
|
||||
/** 0–100, as configured. */
|
||||
percentile: number;
|
||||
/** The estimate; in overflow, the last finite bound it is known to exceed. */
|
||||
value: number;
|
||||
/** Where the marker is drawn, in x-scale space. */
|
||||
axisValue: number;
|
||||
isOverflow: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Percentiles of the summed counts, by `histogram_quantile`'s method: find the bucket
|
||||
* holding the target rank and interpolate linearly inside it, with the lowest bucket
|
||||
* starting at zero. So the values match a time-series panel's percentiles.
|
||||
*/
|
||||
export function computeDistributionPercentiles({
|
||||
xAxis,
|
||||
totals,
|
||||
percentiles,
|
||||
}: {
|
||||
xAxis: DistributionXAxis;
|
||||
/** Summed visible counts per bucket. */
|
||||
totals: number[];
|
||||
percentiles: readonly number[];
|
||||
}): DistributionPercentile[] {
|
||||
const { rows, edges } = xAxis;
|
||||
const total = totals.reduce((sum, count) => sum + count, 0);
|
||||
if (total <= 0 || rows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return percentiles.flatMap<DistributionPercentile>((percentile) => {
|
||||
const rank = (percentile / 100) * total;
|
||||
let below = 0;
|
||||
for (let index = 0; index < rows.length; index += 1) {
|
||||
const count = totals[index] ?? 0;
|
||||
if (count > 0 && below + count >= rank) {
|
||||
const row = rows[index];
|
||||
if (row.isOverflow) {
|
||||
return [
|
||||
{
|
||||
percentile,
|
||||
value: row.lower,
|
||||
axisValue: edges[index],
|
||||
isOverflow: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
const fraction = (rank - below) / count;
|
||||
const lower = row.isUnderflow ? Math.min(0, row.upper) : row.lower;
|
||||
return [
|
||||
{
|
||||
percentile,
|
||||
value: lower + fraction * (row.upper - lower),
|
||||
axisValue: edges[index] + fraction * (edges[index + 1] - edges[index]),
|
||||
isOverflow: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
below += count;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
export function formatPercentileName(percentile: number): string {
|
||||
return `p${Number(percentile.toFixed(3))}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Markers that fall on the same spot, sharing one badge: the percentiles landing
|
||||
* in overflow all sit on its edge. Each group is in ascending percentile order.
|
||||
*/
|
||||
export function groupPercentileMarkers(
|
||||
markers: DistributionPercentile[],
|
||||
): DistributionPercentile[][] {
|
||||
const groups = new Map<number, DistributionPercentile[]>();
|
||||
markers.forEach((marker) => {
|
||||
groups.set(marker.axisValue, [
|
||||
...(groups.get(marker.axisValue) ?? []),
|
||||
marker,
|
||||
]);
|
||||
});
|
||||
return [...groups.values()].map((group) =>
|
||||
[...group].sort((a, b) => a.percentile - b.percentile),
|
||||
);
|
||||
}
|
||||
|
||||
/** `p90` for one marker; `p90+` for a group, starting at its lowest percentile. */
|
||||
export function formatPercentileBadge(group: DistributionPercentile[]): string {
|
||||
const name = formatPercentileName(group[0].percentile);
|
||||
return group.length > 1 ? `${name}+` : name;
|
||||
}
|
||||
|
||||
/**
|
||||
* `p99 ≈ 412 ms` for an estimate; `p90, p99 > 5 s` where only the overflow bound is
|
||||
* known. A group shares one spot, so one value and overflow flag.
|
||||
*/
|
||||
export function formatPercentileValue(
|
||||
group: DistributionPercentile[],
|
||||
formatBound: (value: number) => string,
|
||||
): string {
|
||||
const names = group
|
||||
.map(({ percentile }) => formatPercentileName(percentile))
|
||||
.join(', ');
|
||||
const [{ value, isOverflow }] = group;
|
||||
return `${names} ${isOverflow ? '>' : '≈'} ${formatBound(value)}`;
|
||||
}
|
||||
133
frontend/src/lib/visualization/charts/Distribution/topGroups.ts
Normal file
133
frontend/src/lib/visualization/charts/Distribution/topGroups.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import { DistributionSeries, DistributionXAxis } from './types';
|
||||
|
||||
/** Colours stop being told apart past this many stacked groups. */
|
||||
export const MAX_STACKED_GROUPS = 10;
|
||||
/** Narrowest a side-by-side bar may get, in CSS pixels. */
|
||||
const MIN_SIDE_BY_SIDE_BAR_PX = 3;
|
||||
export const OTHER_GROUP_LABEL = 'Other';
|
||||
|
||||
/**
|
||||
* How many series fit: a fixed cap when stacked, and side by side as many as keep
|
||||
* every bar in the narrowest bucket at least `MIN_SIDE_BY_SIDE_BAR_PX` wide.
|
||||
*/
|
||||
export function resolveMaxDrawnGroups({
|
||||
stack,
|
||||
xAxis,
|
||||
plotWidth,
|
||||
}: {
|
||||
stack: StackMode;
|
||||
xAxis: DistributionXAxis;
|
||||
/** Approximate width of the plotting area, in CSS pixels. */
|
||||
plotWidth: number;
|
||||
}): number {
|
||||
if (stack !== StackMode.None) {
|
||||
return MAX_STACKED_GROUPS;
|
||||
}
|
||||
const span = xAxis.max - xAxis.min;
|
||||
let narrowest = Number.POSITIVE_INFINITY;
|
||||
for (let index = 0; index + 1 < xAxis.edges.length; index += 1) {
|
||||
if (xAxis.edges[index] < xAxis.max) {
|
||||
narrowest = Math.min(narrowest, xAxis.edges[index + 1] - xAxis.edges[index]);
|
||||
}
|
||||
}
|
||||
if (!(span > 0) || !Number.isFinite(narrowest)) {
|
||||
return MAX_STACKED_GROUPS;
|
||||
}
|
||||
const narrowestPx = (plotWidth * narrowest) / span;
|
||||
return Math.min(
|
||||
MAX_STACKED_GROUPS,
|
||||
Math.max(1, Math.floor(narrowestPx / MIN_SIDE_BY_SIDE_BAR_PX)),
|
||||
);
|
||||
}
|
||||
|
||||
export interface DrawnGroups {
|
||||
labels: string[];
|
||||
counts: number[][];
|
||||
colors: string[];
|
||||
/** Each drawn group's index among the source series; `null` for Other. */
|
||||
sourceIndexes: (number | null)[];
|
||||
/** Visible groups summed into the Other series; 0 when none were. */
|
||||
otherGroupCount: number;
|
||||
/** Visible groups in all, drawn or folded. */
|
||||
visibleGroupCount: number;
|
||||
}
|
||||
|
||||
function sum(counts: number[]): number {
|
||||
return counts.reduce((total, count) => total + count, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The visible groups to draw: all of them when they fit, else the largest by total
|
||||
* and one Other series summing the rest. Exact, because bucket counts add. Drawn
|
||||
* groups keep legend order.
|
||||
*/
|
||||
export function groupIntoOther({
|
||||
labels,
|
||||
counts,
|
||||
colors,
|
||||
visible,
|
||||
maxGroups,
|
||||
otherColor,
|
||||
}: {
|
||||
labels: string[];
|
||||
counts: number[][];
|
||||
colors: string[];
|
||||
visible: boolean[];
|
||||
maxGroups: number;
|
||||
otherColor: string;
|
||||
}): DrawnGroups {
|
||||
const visibleIndexes = labels
|
||||
.map((_, index) => index)
|
||||
.filter((index) => visible[index]);
|
||||
|
||||
if (visibleIndexes.length <= maxGroups) {
|
||||
return {
|
||||
labels: visibleIndexes.map((index) => labels[index]),
|
||||
counts: visibleIndexes.map((index) => counts[index]),
|
||||
colors: visibleIndexes.map((index) => colors[index]),
|
||||
sourceIndexes: visibleIndexes,
|
||||
otherGroupCount: 0,
|
||||
visibleGroupCount: visibleIndexes.length,
|
||||
};
|
||||
}
|
||||
|
||||
const keep = new Set(
|
||||
[...visibleIndexes]
|
||||
.sort((a, b) => sum(counts[b]) - sum(counts[a]) || a - b)
|
||||
.slice(0, Math.max(0, maxGroups - 1)),
|
||||
);
|
||||
const kept = visibleIndexes.filter((index) => keep.has(index));
|
||||
const folded = visibleIndexes.filter((index) => !keep.has(index));
|
||||
const bucketCount = Math.max(...folded.map((index) => counts[index].length));
|
||||
const other = Array.from({ length: bucketCount }, (_, bucket) =>
|
||||
folded.reduce((total, index) => total + (counts[index][bucket] ?? 0), 0),
|
||||
);
|
||||
|
||||
return {
|
||||
labels: [...kept.map((index) => labels[index]), OTHER_GROUP_LABEL],
|
||||
counts: [...kept.map((index) => counts[index]), other],
|
||||
colors: [...kept.map((index) => colors[index]), otherColor],
|
||||
sourceIndexes: [...kept, null],
|
||||
otherGroupCount: folded.length,
|
||||
visibleGroupCount: visibleIndexes.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The drawn group at `drawnIndex` as a source series and its colour; `null` for Other,
|
||||
* which sums several groups no single filter can name.
|
||||
*/
|
||||
export function resolveClickedGroup(
|
||||
drawn: DrawnGroups,
|
||||
drawnIndex: number | null,
|
||||
series: DistributionSeries[],
|
||||
): { series: DistributionSeries; color: string } | null {
|
||||
const source =
|
||||
drawnIndex === null ? null : (drawn.sourceIndexes[drawnIndex] ?? null);
|
||||
if (drawnIndex === null || source === null) {
|
||||
return null;
|
||||
}
|
||||
return { series: series[source], color: drawn.colors[drawnIndex] };
|
||||
}
|
||||
31
frontend/src/lib/visualization/charts/Distribution/types.ts
Normal file
31
frontend/src/lib/visualization/charts/Distribution/types.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { VariableWidthBar } from 'lib/uPlotV2/paths/variableWidthBars';
|
||||
import type { HeatmapYAxis } from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
|
||||
export interface DistributionSeries {
|
||||
label: string;
|
||||
/** Group-by key → value, for drilling into this group. Empty when ungrouped. */
|
||||
groupLabels?: Record<string, string>;
|
||||
/** One count per bucket bound plus the trailing overflow, summed over the range. */
|
||||
counts: number[];
|
||||
}
|
||||
|
||||
/** A click on a group's bar; never on Other, which no filter can name. */
|
||||
export interface DistributionBarClick {
|
||||
series: DistributionSeries;
|
||||
color: string;
|
||||
/** Viewport coordinates of the click. */
|
||||
coordinates: { x: number; y: number };
|
||||
}
|
||||
|
||||
export enum DistributionYScale {
|
||||
Linear = 'linear',
|
||||
Log = 'log',
|
||||
}
|
||||
|
||||
/** The bucket axis in x-scale space. The heatmap's row axis, laid sideways. */
|
||||
export type DistributionXAxis = HeatmapYAxis;
|
||||
|
||||
/** `x` in bucket-axis units, `y` in counts (or percent); the overflow bucket is hatched. */
|
||||
export interface DistributionBarRect extends VariableWidthBar {
|
||||
bucketIndex: number;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { resolveRowIndex } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
import { type DistributionBarsLayout, resolveNearestSeries } from './layout';
|
||||
import { type DrawnGroups, resolveClickedGroup } from './topGroups';
|
||||
import type {
|
||||
DistributionBarClick,
|
||||
DistributionSeries,
|
||||
DistributionXAxis,
|
||||
} from './types';
|
||||
|
||||
/** Resolves a plot click to the group under it; Other and empty buckets report none. */
|
||||
export function useDistributionBarClick({
|
||||
xAxis,
|
||||
layout,
|
||||
drawn,
|
||||
series,
|
||||
onBarClick,
|
||||
}: {
|
||||
xAxis: DistributionXAxis;
|
||||
layout: DistributionBarsLayout;
|
||||
drawn: DrawnGroups;
|
||||
series: DistributionSeries[];
|
||||
onBarClick?: (click: DistributionBarClick) => void;
|
||||
}): ((data: ChartClickData) => void) | undefined {
|
||||
const onBarClickRef = useRef(onBarClick);
|
||||
onBarClickRef.current = onBarClick;
|
||||
|
||||
const handleClick = useCallback(
|
||||
({
|
||||
xValue,
|
||||
yValue,
|
||||
absoluteMouseX,
|
||||
absoluteMouseY,
|
||||
}: ChartClickData): void => {
|
||||
const bucket = resolveRowIndex(xAxis.edges, xValue);
|
||||
const clicked = resolveClickedGroup(
|
||||
drawn,
|
||||
bucket === null
|
||||
? null
|
||||
: resolveNearestSeries(layout, bucket, xValue, yValue),
|
||||
series,
|
||||
);
|
||||
if (clicked) {
|
||||
onBarClickRef.current?.({
|
||||
...clicked,
|
||||
coordinates: { x: absoluteMouseX, y: absoluteMouseY },
|
||||
});
|
||||
}
|
||||
},
|
||||
[xAxis, layout, drawn, series],
|
||||
);
|
||||
|
||||
return onBarClick ? handleClick : undefined;
|
||||
}
|
||||
32
frontend/src/lib/visualization/charts/Distribution/utils.ts
Normal file
32
frontend/src/lib/visualization/charts/Distribution/utils.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import { getToolTipValue } from 'components/Graph/yAxisConfig';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { DistributionXAxis } from './types';
|
||||
|
||||
export function createBoundFormatter(
|
||||
unit?: string,
|
||||
decimalPrecision?: PrecisionOption,
|
||||
): (value: number) => string {
|
||||
return (value: number): string =>
|
||||
getToolTipValue(String(value), unit, decimalPrecision);
|
||||
}
|
||||
|
||||
/**
|
||||
* `[bucket centres, ...counts per series]`. The series draw from the layout, so the
|
||||
* data only feeds uPlot's cursor and tooltip.
|
||||
*/
|
||||
export function prepareDistributionChartData(
|
||||
xAxis: DistributionXAxis,
|
||||
counts: number[][],
|
||||
): uPlot.AlignedData {
|
||||
const centres = xAxis.rows.map(
|
||||
(_, index) => (xAxis.edges[index] + xAxis.edges[index + 1]) / 2,
|
||||
);
|
||||
return [
|
||||
centres,
|
||||
...counts.map((seriesCounts) =>
|
||||
centres.map((_, index) => seriesCounts[index] ?? 0),
|
||||
),
|
||||
] as unknown as uPlot.AlignedData;
|
||||
}
|
||||
@@ -26,6 +26,11 @@ import {
|
||||
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import type { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import type {
|
||||
DistributionBarClick,
|
||||
DistributionSeries,
|
||||
DistributionYScale,
|
||||
} from 'lib/visualization/charts/Distribution/types';
|
||||
|
||||
interface BaseChartProps {
|
||||
width: number;
|
||||
@@ -167,6 +172,48 @@ export interface HeatmapChartProps {
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole-range bucket counts, one series per group, drawn as bars whose widths follow
|
||||
* the bucket bounds. Builds its own config, since `buckets` fully determines the x
|
||||
* axis. `buckets` and `series` must be referentially stable: a new identity rebuilds
|
||||
* the plot.
|
||||
*/
|
||||
export interface DistributionChartProps {
|
||||
/** Widget id; the group selection persists under it. */
|
||||
id: string;
|
||||
/** Ascending. N boundaries describe N+1 buckets, the last one overflow. */
|
||||
buckets: number[];
|
||||
series: DistributionSeries[];
|
||||
width: number;
|
||||
height: number;
|
||||
isDarkMode: boolean;
|
||||
/** Buckets wholly below it are hidden. */
|
||||
xMin?: number;
|
||||
/** Buckets wholly above it are hidden; unset crops trailing empty buckets. */
|
||||
xMax?: number;
|
||||
/** Default linear. Ignored under `percent`, whose axis is a share. */
|
||||
yScale?: DistributionYScale;
|
||||
/** Default normal. `none` draws the groups side by side. */
|
||||
stack?: StackMode;
|
||||
/** Unit of the bucket bounds; counts are always plain numbers. */
|
||||
xAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
/** Hex colours keyed by group label, overriding the automatic palette. */
|
||||
customColors?: Record<string, string>;
|
||||
/** 0–100. Marked across the plot, over the visible groups combined. */
|
||||
percentiles?: readonly number[];
|
||||
/** Fires for a click on a group's bar; not on Other or empty space. */
|
||||
onBarClick?: (click: DistributionBarClick) => void;
|
||||
showLegend?: boolean;
|
||||
legendPosition?: LegendPosition;
|
||||
showTooltip?: boolean;
|
||||
canPinTooltip?: boolean;
|
||||
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => React.ReactNode;
|
||||
tooltipPortalRoot?: HTMLElement | null;
|
||||
layoutChildren?: React.ReactNode;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One resolved pie/donut slice: a display label, its (already parsed) positive
|
||||
* numeric value, and the colour used for the arc + legend swatch.
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
*/
|
||||
|
||||
import { rest, type ResponseComposition, type RestContext } from 'msw';
|
||||
import type { RuletypesListableRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import {
|
||||
@@ -13,10 +12,7 @@ import {
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { accessFor } from '@/storybook/access/access';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
import { useAlertRuleViewsStore } from 'container/ListAlertRules/useAlertRuleViewsStore';
|
||||
import { componentPermission } from 'utils/permission';
|
||||
import { DEFAULT_RULE_LIST_FILTER } from 'container/ListAlertRules/utils';
|
||||
import {
|
||||
DEFAULT_RULE_VIEW,
|
||||
@@ -285,37 +281,3 @@ export const runRowAction = async (
|
||||
await userEvent.click(actions);
|
||||
await userEvent.click(await screen.findByRole('menuitem', { name }));
|
||||
};
|
||||
|
||||
export type AlertRulesArgs = PageStoryArgs<typeof alertRulesMocks>;
|
||||
|
||||
export const listedRules = (
|
||||
args: AlertRulesArgs,
|
||||
): RuletypesListableRuleDTO[] => {
|
||||
const params = new URL(listRoute(args.view), 'http://localhost').searchParams;
|
||||
if (
|
||||
args.dataState !== 'loaded' ||
|
||||
(args.invalidQuery !== 'off' && params.get('query'))
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return alertRulesPage(
|
||||
args.rules,
|
||||
{ severity: args.ruleSeverity, state: args.ruleState },
|
||||
params,
|
||||
).data.rules;
|
||||
};
|
||||
|
||||
export const hasRowMenu = (args: AlertRulesArgs): boolean =>
|
||||
listedRules(args).length > 0 &&
|
||||
componentPermission.action.includes(
|
||||
accessFor(args.access, args.permissions, args.revoked).legacyRole,
|
||||
);
|
||||
|
||||
export const showsNoRulesPage = (args: AlertRulesArgs): boolean =>
|
||||
args.view === DEFAULT_RULE_VIEW &&
|
||||
args.dataState === 'loaded' &&
|
||||
args.rules === 0;
|
||||
|
||||
export const listsSavedViews = (args: AlertRulesArgs): boolean =>
|
||||
args.ruleViews > 0 && args.ruleViewsState === 'success';
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
alertRulesMocks,
|
||||
type AlertRulesArgs,
|
||||
} from './AlertRules.stories.mocks';
|
||||
import { alertRulesMocks } from './AlertRules.stories.mocks';
|
||||
import {
|
||||
cloneAlert,
|
||||
copyLabel,
|
||||
@@ -24,6 +22,8 @@ import {
|
||||
import AlertList from '../../index';
|
||||
import { RULE_MAX } from '../../stories/__story_mockdata__/alerts';
|
||||
|
||||
type AlertRulesArgs = PageStoryArgs<typeof alertRulesMocks>;
|
||||
|
||||
const pageStory = storyMocks(alertRulesMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,22 +3,20 @@
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { RuletypesAlertStateDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
import { DEFAULT_RULE_VIEW } from 'container/ListAlertRules/list.config';
|
||||
import { expect, screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
type AlertRulesArgs,
|
||||
hasRowMenu,
|
||||
listedRules,
|
||||
listsSavedViews,
|
||||
alertRulesMocks,
|
||||
runRowAction,
|
||||
showsNoRulesPage,
|
||||
untilLoaded,
|
||||
} from '../AlertRules.stories.mocks';
|
||||
|
||||
interface PlayContext {
|
||||
canvasElement: HTMLElement;
|
||||
args: AlertRulesArgs;
|
||||
args: PageStoryArgs<typeof alertRulesMocks>;
|
||||
}
|
||||
|
||||
// A row's rename and delete only take the pointer while the row is hovered,
|
||||
@@ -27,15 +25,10 @@ const hoverActions = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
export const openQuerySuggestions = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (listedRules(args).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = within(canvasElement);
|
||||
// The labels come with the rule list, so the box is opened once rows are in.
|
||||
await canvas.findAllByTestId(/^alert-row-.+-name$/, undefined, untilLoaded);
|
||||
await canvas.findAllByTestId('alert-actions', undefined, untilLoaded);
|
||||
const search = canvas.getByTestId('alert-rules-list-search');
|
||||
await userEvent.click(search.querySelector('.cm-content') ?? search);
|
||||
await screen.findByText('labels.team');
|
||||
@@ -56,12 +49,7 @@ export const openSaveViewPopover = async ({
|
||||
|
||||
export const openRenameViewPopover = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!listsSavedViews(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await hoverActions.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'alert-rules-view-rename-view-1',
|
||||
@@ -69,17 +57,12 @@ export const openRenameViewPopover = async ({
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findAllByText('Rename view');
|
||||
await screen.findByText('Rename view');
|
||||
};
|
||||
|
||||
export const openDeleteViewConfirm = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!listsSavedViews(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await hoverActions.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'alert-rules-view-delete-view-1',
|
||||
@@ -92,12 +75,7 @@ export const openDeleteViewConfirm = async ({
|
||||
|
||||
export const openRowActions = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!hasRowMenu(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [actions] = await within(canvasElement).findAllByTestId(
|
||||
'alert-actions',
|
||||
undefined,
|
||||
@@ -110,12 +88,7 @@ export const openRowActions = async ({
|
||||
|
||||
export const openDisabledRuleActions = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!hasRowMenu(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [actions] = await within(canvasElement).findAllByTestId(
|
||||
'alert-actions',
|
||||
undefined,
|
||||
@@ -123,21 +96,12 @@ export const openDisabledRuleActions = async ({
|
||||
);
|
||||
|
||||
await userEvent.click(actions);
|
||||
await screen.findByText(
|
||||
listedRules(args)[0].state === RuletypesAlertStateDTO.disabled
|
||||
? /^enable$/i
|
||||
: /^disable$/i,
|
||||
);
|
||||
await screen.findByText(/^enable$/i);
|
||||
};
|
||||
|
||||
export const openColumnPicker = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (showsNoRulesPage(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'alert-columns-button',
|
||||
@@ -152,7 +116,11 @@ export const openStatesFilter = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (showsNoRulesPage(args)) {
|
||||
if (
|
||||
args.rules === 0 &&
|
||||
args.view === DEFAULT_RULE_VIEW &&
|
||||
args.dataState === 'loaded'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -176,10 +144,6 @@ export const toggleAlert = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!hasRowMenu(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await runRowAction(canvasElement, /^(enable|disable)$/i);
|
||||
if (args.toggleState === 'success') {
|
||||
await expect(
|
||||
@@ -192,10 +156,6 @@ export const cloneAlert = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!hasRowMenu(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await runRowAction(canvasElement, /^clone$/i);
|
||||
if (args.cloneState === 'success') {
|
||||
await expect(
|
||||
@@ -208,10 +168,6 @@ export const deleteAlert = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!hasRowMenu(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await runRowAction(canvasElement, /^delete$/i);
|
||||
if (args.deleteState === 'success') {
|
||||
await expect(
|
||||
@@ -220,11 +176,7 @@ export const deleteAlert = async ({
|
||||
}
|
||||
};
|
||||
|
||||
export const copyLabel = async ({ args }: PlayContext): Promise<void> => {
|
||||
if (!args.tooltipsOpen || listedRules(args).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
export const copyLabel = async (): Promise<void> => {
|
||||
const [copy] = await screen.findAllByRole(
|
||||
'button',
|
||||
{ name: 'Copy to clipboard' },
|
||||
@@ -232,7 +184,5 @@ export const copyLabel = async ({ args }: PlayContext): Promise<void> => {
|
||||
);
|
||||
|
||||
await userEvent.click(copy);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/copied! use in search/i)).toBeVisible(),
|
||||
);
|
||||
await expect(await screen.findByText(/copied! use in search/i)).toBeVisible();
|
||||
};
|
||||
|
||||
@@ -16,12 +16,6 @@ import {
|
||||
type GetDashboardV2200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
buildDashboardDeletePermission,
|
||||
buildDashboardReadPermission,
|
||||
buildDashboardUpdatePermission,
|
||||
DashboardCreatePermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
|
||||
import {
|
||||
expect,
|
||||
mocked,
|
||||
@@ -30,9 +24,7 @@ import {
|
||||
waitFor,
|
||||
within,
|
||||
} from 'storybook/test';
|
||||
import { LicensePlatform } from 'types/api/licensesV3/getActive';
|
||||
|
||||
import { grantsAll } from '@/storybook/access/access';
|
||||
import {
|
||||
choiceControl,
|
||||
countControl,
|
||||
@@ -40,8 +32,6 @@ import {
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { appShellMocks } from '@/storybook/globals/appShellMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
orgUsersResponse,
|
||||
@@ -77,8 +67,6 @@ import {
|
||||
type DashboardViewId,
|
||||
findBuiltinView,
|
||||
} from '../list.config';
|
||||
import { LIST_CHECKS } from '../constants/permissions';
|
||||
import type { DashboardListItem } from '../utils/helpers';
|
||||
|
||||
const LIST = 'Dashboards · list';
|
||||
const VIEWS = 'Dashboards · views';
|
||||
@@ -540,81 +528,6 @@ export const dashboardsListMocks = defineStoryMocks({
|
||||
},
|
||||
});
|
||||
|
||||
export type DashboardsListArgs = PageStoryArgs<typeof dashboardsListMocks>;
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export const canList = (args: DashboardsListArgs): boolean =>
|
||||
grantsAll(args, ...LIST_CHECKS);
|
||||
|
||||
/** `AuthZButton` leaves New dashboard enabled when the check itself fails. */
|
||||
export const canCreate = (args: DashboardsListArgs): boolean =>
|
||||
args.authzState === 'error' || grantsAll(args, DashboardCreatePermission);
|
||||
|
||||
export const canUpdate = (
|
||||
args: DashboardsListArgs,
|
||||
row: DashboardListItem,
|
||||
): boolean =>
|
||||
grantsAll(
|
||||
args,
|
||||
buildDashboardReadPermission(row.id),
|
||||
buildDashboardUpdatePermission(row.id),
|
||||
);
|
||||
|
||||
export const canDuplicate = (
|
||||
args: DashboardsListArgs,
|
||||
row: DashboardListItem,
|
||||
): boolean =>
|
||||
grantsAll(
|
||||
args,
|
||||
buildDashboardReadPermission(row.id),
|
||||
DashboardCreatePermission,
|
||||
);
|
||||
|
||||
export const canDelete = (
|
||||
args: DashboardsListArgs,
|
||||
row: DashboardListItem,
|
||||
): boolean =>
|
||||
!row.locked && grantsAll(args, buildDashboardDeletePermission(row.id));
|
||||
|
||||
export const listedDashboards = (
|
||||
args: DashboardsListArgs,
|
||||
): DashboardListItem[] => {
|
||||
if (args.dataState !== 'loaded' || args.invalidQuery || !canList(args)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const view = findBuiltinView(args.view);
|
||||
const rows = dashboardsListResponse({
|
||||
count: args.dashboards,
|
||||
offset: 0,
|
||||
limit: view?.clientFilter ? args.dashboards : PAGE_SIZE,
|
||||
markers: args.markers,
|
||||
query:
|
||||
new URL(listRoute(args.view), 'http://localhost').searchParams.get(
|
||||
'query',
|
||||
) ?? '',
|
||||
}).data.dashboards;
|
||||
|
||||
return view?.clientFilter
|
||||
? view.clientFilter(rows, {
|
||||
userEmail: STORY_USER_EMAIL,
|
||||
recent: recentDashboardIds(RECENT_COUNT),
|
||||
})
|
||||
: rows;
|
||||
};
|
||||
|
||||
export const showsWorkspaceEmpty = (args: DashboardsListArgs): boolean =>
|
||||
args.view === DEFAULT_DASHBOARD_VIEW &&
|
||||
args.dataState === 'loaded' &&
|
||||
!args.invalidQuery &&
|
||||
canList(args) &&
|
||||
args.dashboards === 0;
|
||||
|
||||
export const isCloudTenant = (args: DashboardsListArgs): boolean =>
|
||||
appShellMocks.config?.(appShellMocks.read(args)).appContext?.activeLicense
|
||||
?.platform === LicensePlatform.CLOUD;
|
||||
|
||||
/** Opens the actions menu of the row at `index`. */
|
||||
export const openRowActions = async (
|
||||
canvasElement: HTMLElement,
|
||||
@@ -628,7 +541,7 @@ export const openRowActions = async (
|
||||
);
|
||||
|
||||
await userEvent.click(triggers[index]);
|
||||
await screen.findByTestId('dashboard-action-delete');
|
||||
await screen.findByText('Rename');
|
||||
};
|
||||
|
||||
/** Picks a row action, retrying while its permission check still disables it. */
|
||||
@@ -661,11 +574,10 @@ export const TOAST_TIMEOUT = 10000;
|
||||
|
||||
export const openLegacyDialog = async (
|
||||
canvasElement: HTMLElement,
|
||||
index: number,
|
||||
): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
`dashboard-title-${index}`,
|
||||
'dashboard-title-4',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
),
|
||||
@@ -675,9 +587,8 @@ export const openLegacyDialog = async (
|
||||
|
||||
export const retryMigration = async (
|
||||
canvasElement: HTMLElement,
|
||||
index: number,
|
||||
): Promise<void> => {
|
||||
await openLegacyDialog(canvasElement, index);
|
||||
await openLegacyDialog(canvasElement);
|
||||
const retry = await screen.findByTestId('legacy-dashboard-retry-migration');
|
||||
|
||||
await waitFor(
|
||||
|
||||
@@ -1,41 +1,47 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
dashboardsListMocks,
|
||||
type DashboardsListArgs,
|
||||
longQueryRoute,
|
||||
overflowingRows,
|
||||
} from './DashboardsListPage.stories.mocks';
|
||||
import {
|
||||
copyLegacyDashboardId,
|
||||
createDashboard,
|
||||
createDashboardFails,
|
||||
deleteDashboard,
|
||||
deleteView,
|
||||
deleteViewFails,
|
||||
duplicateDashboard,
|
||||
importDashboard,
|
||||
importCreateFails,
|
||||
importInvalidImage,
|
||||
importInvalidJson,
|
||||
lockDashboard,
|
||||
migrateDashboard,
|
||||
migrationFails,
|
||||
openCreatedByFilter,
|
||||
openEditTagsDialog,
|
||||
openFirstRowActions,
|
||||
openRenameDialog,
|
||||
openSaveViewPopover,
|
||||
openTemplateTab,
|
||||
pinDashboard,
|
||||
pinFails,
|
||||
pinPastLimit,
|
||||
renameDashboard,
|
||||
renameView,
|
||||
saveView,
|
||||
renameViewFails,
|
||||
saveViewFails,
|
||||
submitTemplateRequest,
|
||||
templateRequestFails,
|
||||
templateRequestRejected,
|
||||
unlockDashboard,
|
||||
unpinDashboard,
|
||||
unpinFails,
|
||||
updateTags,
|
||||
} from './__story_mockdata__/dashboardsList.play';
|
||||
|
||||
import DashboardsListPage from '../DashboardsListPage';
|
||||
|
||||
type DashboardsListArgs = PageStoryArgs<typeof dashboardsListMocks>;
|
||||
|
||||
const pageStory = storyMocks(dashboardsListMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
@@ -176,21 +182,21 @@ export const DashboardDeletedToast: Story = {
|
||||
/** Pinning past the limit: the backend refuses with a 409. */
|
||||
export const PinLimitToast: Story = {
|
||||
args: { pinWrite: 'limit' },
|
||||
play: pinDashboard,
|
||||
play: pinPastLimit,
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
/** A pin the backend failed to write. */
|
||||
export const PinFailedToast: Story = {
|
||||
args: { pinWrite: 'error' },
|
||||
play: pinDashboard,
|
||||
play: pinFails,
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
/** An unpin the backend failed to write, from the first (pinned) row. */
|
||||
export const UnpinFailedToast: Story = {
|
||||
args: { pinWrite: 'error' },
|
||||
play: unpinDashboard,
|
||||
play: unpinFails,
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
@@ -207,14 +213,14 @@ export const DashboardMigratedToast: Story = {
|
||||
/** Retry migration of the legacy dashboard refused by the backend. */
|
||||
export const DashboardMigrationFailedToast: Story = {
|
||||
args: { migrationWrite: 'error' },
|
||||
play: migrateDashboard,
|
||||
play: migrationFails,
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
/** The Blank tab's create failing: the toast raised beside the error modal. */
|
||||
export const CreateDashboardFailedToast: Story = {
|
||||
args: { createWrite: 'error' },
|
||||
play: createDashboard,
|
||||
play: createDashboardFails,
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
@@ -226,7 +232,7 @@ export const ImportJsonInvalidImageToast: Story = {
|
||||
/** A valid dashboard file the create endpoint refuses. */
|
||||
export const ImportJsonCreateFailedToast: Story = {
|
||||
args: { createWrite: 'error' },
|
||||
play: importDashboard,
|
||||
play: importCreateFails,
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
@@ -238,33 +244,33 @@ export const TemplateRequestSubmittedToast: Story = {
|
||||
/** A template request the analytics endpoint answered with a failure. */
|
||||
export const TemplateRequestFailedToast: Story = {
|
||||
args: { templateRequest: 'error' },
|
||||
play: submitTemplateRequest,
|
||||
play: templateRequestFails,
|
||||
};
|
||||
|
||||
/** A template request that threw before an answer came back. */
|
||||
export const TemplateRequestRejectedToast: Story = {
|
||||
args: { templateRequest: 'rejected' },
|
||||
play: submitTemplateRequest,
|
||||
play: templateRequestRejected,
|
||||
};
|
||||
|
||||
/** Saving the current filters as a view, refused by the backend. */
|
||||
export const SaveViewFailedToast: Story = {
|
||||
args: { viewWrite: 'error' },
|
||||
play: saveView,
|
||||
play: saveViewFails,
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
/** Renaming a saved view, refused by the backend. */
|
||||
export const RenameViewFailedToast: Story = {
|
||||
args: { viewWrite: 'error' },
|
||||
play: renameView,
|
||||
play: renameViewFails,
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
/** Deleting a saved view, refused by the backend. */
|
||||
export const DeleteViewFailedToast: Story = {
|
||||
args: { viewWrite: 'error' },
|
||||
play: deleteView,
|
||||
play: deleteViewFails,
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
|
||||
@@ -5,68 +5,30 @@
|
||||
|
||||
import { expect, screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
canCreate,
|
||||
canDelete,
|
||||
canDuplicate,
|
||||
canList,
|
||||
canUpdate,
|
||||
clickRowAction,
|
||||
type DashboardsListArgs,
|
||||
isCloudTenant,
|
||||
listedDashboards,
|
||||
dashboardsListMocks,
|
||||
openLegacyDialog,
|
||||
openRowActions,
|
||||
pickRowAction,
|
||||
requestTemplate,
|
||||
retryMigration,
|
||||
showsWorkspaceEmpty,
|
||||
TOAST_TIMEOUT,
|
||||
uploadDashboardJson,
|
||||
} from '../DashboardsListPage.stories.mocks';
|
||||
import type { TemplateRequest } from './dashboardsList';
|
||||
|
||||
interface PlayContext {
|
||||
canvasElement: HTMLElement;
|
||||
args: DashboardsListArgs;
|
||||
args: PageStoryArgs<typeof dashboardsListMocks>;
|
||||
}
|
||||
|
||||
const IMPORTED_SPEC = { layouts: [], panels: {}, variables: [] };
|
||||
|
||||
const TEMPLATE_REQUEST_TOASTS = {
|
||||
success: 'Dashboard request submitted',
|
||||
error: 'Event rejected',
|
||||
rejected: 'Something went wrong',
|
||||
} satisfies Record<TemplateRequest, string>;
|
||||
|
||||
const openNewDashboard = async (canvasElement: HTMLElement): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'new-dashboard-cta',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const canEditRow = (args: DashboardsListArgs, index: number): boolean => {
|
||||
const row = listedDashboards(args)[index];
|
||||
|
||||
return !!row && !row.legacy && !row.locked && canUpdate(args, row);
|
||||
};
|
||||
|
||||
export const openCreatedByFilter = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (
|
||||
!canList(args) ||
|
||||
args.dataState !== 'loaded' ||
|
||||
showsWorkspaceEmpty(args)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filter = await within(canvasElement).findByTestId(
|
||||
'dashboards-filter-created-by',
|
||||
{},
|
||||
@@ -75,18 +37,18 @@ export const openCreatedByFilter = async ({
|
||||
|
||||
await userEvent.click(filter.querySelector('.ant-select-selector') ?? filter);
|
||||
await userEvent.click(await screen.findByText('Ada (ada@signoz.io)'));
|
||||
await screen.findAllByRole('checkbox', { checked: true });
|
||||
await screen.findByRole('checkbox', { checked: true });
|
||||
};
|
||||
|
||||
export const openTemplateTab = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!canCreate(args)) {
|
||||
return;
|
||||
}
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.click(
|
||||
await canvas.findByTestId('new-dashboard-cta', {}, { timeout: 10000 }),
|
||||
);
|
||||
|
||||
await openNewDashboard(canvasElement);
|
||||
await expect(
|
||||
await screen.findByRole('dialog', {}, { timeout: 10000 }),
|
||||
).toHaveTextContent('New dashboard');
|
||||
@@ -96,13 +58,12 @@ export const openTemplateTab = async ({
|
||||
|
||||
export const importInvalidJson = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!canCreate(args)) {
|
||||
return;
|
||||
}
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await openNewDashboard(canvasElement);
|
||||
await userEvent.click(
|
||||
await canvas.findByTestId('new-dashboard-cta', {}, { timeout: 10000 }),
|
||||
);
|
||||
await userEvent.click(await screen.findByText('Import JSON'));
|
||||
await userEvent.click(await screen.findByTestId('import-json-submit'));
|
||||
await screen.findByText(/error loading json/i);
|
||||
@@ -112,23 +73,13 @@ export const importInvalidJson = async ({
|
||||
|
||||
export const openFirstRowActions = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (listedDashboards(args).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openRowActions(canvasElement, 0);
|
||||
};
|
||||
|
||||
export const openRenameDialog = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!canEditRow(args, 1)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openRowActions(canvasElement, 1);
|
||||
await pickRowAction('Rename');
|
||||
await screen.findByRole('dialog', { name: 'Rename dashboard' });
|
||||
@@ -136,12 +87,7 @@ export const openRenameDialog = async ({
|
||||
|
||||
export const openEditTagsDialog = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!canEditRow(args, 1)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openRowActions(canvasElement, 1);
|
||||
await pickRowAction(/^(Edit|Add) Tags$/);
|
||||
await screen.findByRole('dialog', { name: /^(Edit|Add) tags$/ });
|
||||
@@ -149,12 +95,7 @@ export const openEditTagsDialog = async ({
|
||||
|
||||
export const openSaveViewPopover = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!canList(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole(
|
||||
'button',
|
||||
@@ -169,10 +110,6 @@ export const renameDashboard = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!canEditRow(args, 1)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openRowActions(canvasElement, 1);
|
||||
await pickRowAction('Rename');
|
||||
const input = await screen.findByTestId('rename-dashboard-input');
|
||||
@@ -189,10 +126,6 @@ export const updateTags = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!canEditRow(args, 1)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openRowActions(canvasElement, 1);
|
||||
await pickRowAction(/^(Edit|Add) Tags$/);
|
||||
const input = await screen.findByPlaceholderText('key:value (press Enter)');
|
||||
@@ -208,37 +141,11 @@ export const duplicateDashboard = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
const row = listedDashboards(args)[1];
|
||||
if (!row || row.legacy || !canDuplicate(args, row)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openRowActions(canvasElement, 1);
|
||||
await clickRowAction('dashboard-action-duplicate');
|
||||
if (args.rowWrite === 'success') {
|
||||
await screen.findByText(
|
||||
`Duplicated "${row.name}"`,
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleLockAt = async (
|
||||
canvasElement: HTMLElement,
|
||||
args: DashboardsListArgs,
|
||||
index: number,
|
||||
): Promise<void> => {
|
||||
const row = listedDashboards(args)[index];
|
||||
if (!row || row.legacy || !canUpdate(args, row)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openRowActions(canvasElement, index);
|
||||
await clickRowAction('dashboard-action-lock');
|
||||
if (args.rowWrite === 'success') {
|
||||
await screen.findByText(
|
||||
row.locked ? 'Dashboard unlocked' : 'Dashboard locked',
|
||||
'Duplicated "API latency and errors"',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
@@ -248,22 +155,29 @@ const toggleLockAt = async (
|
||||
export const lockDashboard = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => toggleLockAt(canvasElement, args, 1);
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openRowActions(canvasElement, 1);
|
||||
await clickRowAction('dashboard-action-lock');
|
||||
if (args.rowWrite === 'success') {
|
||||
await screen.findByText('Dashboard locked', {}, { timeout: TOAST_TIMEOUT });
|
||||
}
|
||||
};
|
||||
|
||||
export const unlockDashboard = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => toggleLockAt(canvasElement, args, 0);
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openRowActions(canvasElement, 0);
|
||||
await clickRowAction('dashboard-action-lock');
|
||||
if (args.rowWrite === 'success') {
|
||||
await screen.findByText('Dashboard unlocked', {}, { timeout: TOAST_TIMEOUT });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteDashboard = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
const row = listedDashboards(args)[1];
|
||||
if (!row || !canDelete(args, row)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openRowActions(canvasElement, 1);
|
||||
await clickRowAction('dashboard-action-delete');
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
@@ -280,73 +194,61 @@ export const deleteDashboard = async ({
|
||||
}
|
||||
};
|
||||
|
||||
export const pinDashboard = async ({
|
||||
export const pinPastLimit = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
const index = listedDashboards(args).findIndex((row) => !row.pinned);
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
`dashboard-pin-${index}`,
|
||||
'dashboard-pin-3',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
),
|
||||
);
|
||||
if (args.pinWrite === 'limit') {
|
||||
await screen.findByText(
|
||||
/You can pin up to 10 dashboards/,
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
}
|
||||
if (args.pinWrite === 'error') {
|
||||
await screen.findByText(
|
||||
'Failed to pin dashboard.',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
}
|
||||
await screen.findByText(
|
||||
/You can pin up to 10 dashboards/,
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
};
|
||||
|
||||
export const unpinDashboard = async ({
|
||||
export const pinFails = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
const index = listedDashboards(args).findIndex((row) => row.pinned);
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
`dashboard-pin-${index}`,
|
||||
'dashboard-pin-3',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
),
|
||||
);
|
||||
if (args.pinWrite === 'error') {
|
||||
await screen.findByText(
|
||||
'Failed to unpin dashboard.',
|
||||
await screen.findByText(
|
||||
'Failed to pin dashboard.',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
};
|
||||
|
||||
export const unpinFails = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'dashboard-pin-0',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
await screen.findByText(
|
||||
'Failed to unpin dashboard.',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
};
|
||||
|
||||
export const copyLegacyDashboardId = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
const index = listedDashboards(args).findIndex((row) => row.legacy);
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openLegacyDialog(canvasElement, index);
|
||||
await openLegacyDialog(canvasElement);
|
||||
await userEvent.click(await screen.findByTestId('legacy-dashboard-copy-id'));
|
||||
await screen.findByText('Dashboard ID copied', {}, { timeout: TOAST_TIMEOUT });
|
||||
};
|
||||
@@ -355,13 +257,7 @@ export const migrateDashboard = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
const rows = listedDashboards(args);
|
||||
const index = rows.findIndex((row) => row.legacy);
|
||||
if (index < 0 || !canUpdate(args, rows[index])) {
|
||||
return;
|
||||
}
|
||||
|
||||
await retryMigration(canvasElement, index);
|
||||
await retryMigration(canvasElement);
|
||||
if (args.migrationWrite === 'success') {
|
||||
await screen.findByText(
|
||||
'Dashboard migrated to the new experience',
|
||||
@@ -369,43 +265,47 @@ export const migrateDashboard = async ({
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
}
|
||||
if (args.migrationWrite === 'error') {
|
||||
await screen.findByText(
|
||||
'Something went wrong',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const createDashboard = async ({
|
||||
export const migrationFails = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!canCreate(args)) {
|
||||
return;
|
||||
}
|
||||
await retryMigration(canvasElement);
|
||||
await screen.findByText(
|
||||
'Something went wrong',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
};
|
||||
|
||||
await openNewDashboard(canvasElement);
|
||||
await userEvent.click(await screen.findByTestId('create-dashboard-submit'));
|
||||
if (args.createWrite === 'error') {
|
||||
await screen.findAllByText(
|
||||
/Something went wrong/,
|
||||
export const createDashboardFails = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'new-dashboard-cta',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
await userEvent.click(await screen.findByTestId('create-dashboard-submit'));
|
||||
await screen.findAllByText(
|
||||
/Something went wrong/,
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
};
|
||||
|
||||
export const importInvalidImage = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!canCreate(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openNewDashboard(canvasElement);
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'new-dashboard-cta',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
),
|
||||
);
|
||||
await uploadDashboardJson({
|
||||
schemaVersion: 'v6',
|
||||
image: 'https://example.com/logo.png',
|
||||
@@ -418,52 +318,59 @@ export const importInvalidImage = async ({
|
||||
);
|
||||
};
|
||||
|
||||
export const importDashboard = async ({
|
||||
export const importCreateFails = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!canCreate(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openNewDashboard(canvasElement);
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'new-dashboard-cta',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
),
|
||||
);
|
||||
await uploadDashboardJson({
|
||||
schemaVersion: 'v6',
|
||||
spec: { display: { name: 'Imported' }, ...IMPORTED_SPEC },
|
||||
});
|
||||
if (args.createWrite === 'error') {
|
||||
await screen.findAllByText(
|
||||
/Something went wrong/,
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const submitTemplateRequest = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!canCreate(args) || !isCloudTenant(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await requestTemplate(canvasElement);
|
||||
await screen.findByText(
|
||||
TEMPLATE_REQUEST_TOASTS[args.templateRequest],
|
||||
await screen.findAllByText(
|
||||
/Something went wrong/,
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
};
|
||||
|
||||
export const saveView = async ({
|
||||
export const submitTemplateRequest = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!canList(args)) {
|
||||
return;
|
||||
}
|
||||
await requestTemplate(canvasElement);
|
||||
await screen.findByText(
|
||||
'Dashboard request submitted',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
};
|
||||
|
||||
export const templateRequestFails = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await requestTemplate(canvasElement);
|
||||
await screen.findByText('Event rejected', {}, { timeout: TOAST_TIMEOUT });
|
||||
};
|
||||
|
||||
export const templateRequestRejected = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await requestTemplate(canvasElement);
|
||||
await screen.findByText(
|
||||
'Something went wrong',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
};
|
||||
|
||||
export const saveViewFails = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole(
|
||||
'button',
|
||||
@@ -473,26 +380,16 @@ export const saveView = async ({
|
||||
);
|
||||
await userEvent.type(await screen.findByTestId('save-view-name'), 'On-call');
|
||||
await userEvent.click(await screen.findByTestId('save-view-confirm'));
|
||||
if (args.viewWrite === 'error') {
|
||||
await screen.findByText(
|
||||
'Failed to save view.',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
}
|
||||
await screen.findByText(
|
||||
'Failed to save view.',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
};
|
||||
|
||||
const listsSavedViews = (args: DashboardsListArgs): boolean =>
|
||||
canList(args) && args.dataState === 'loaded' && args.savedViews > 0;
|
||||
|
||||
export const renameView = async ({
|
||||
export const renameViewFails = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!listsSavedViews(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [rename] = await within(canvasElement).findAllByRole(
|
||||
'button',
|
||||
{ name: 'Rename view' },
|
||||
@@ -506,23 +403,16 @@ export const renameView = async ({
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, 'Renamed view');
|
||||
await userEvent.click(await screen.findByTestId('rename-view-confirm'));
|
||||
if (args.viewWrite === 'error') {
|
||||
await screen.findByText(
|
||||
'Failed to update view.',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
}
|
||||
await screen.findByText(
|
||||
'Failed to update view.',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
};
|
||||
|
||||
export const deleteView = async ({
|
||||
export const deleteViewFails = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!listsSavedViews(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [remove] = await within(canvasElement).findAllByRole(
|
||||
'button',
|
||||
{ name: 'Delete view' },
|
||||
@@ -535,11 +425,9 @@ export const deleteView = async ({
|
||||
await userEvent.click(
|
||||
await within(dialog).findByRole('button', { name: 'Delete' }),
|
||||
);
|
||||
if (args.viewWrite === 'error') {
|
||||
await screen.findByText(
|
||||
'Failed to delete view.',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
}
|
||||
await screen.findByText(
|
||||
'Failed to delete view.',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,8 +9,6 @@ import {
|
||||
InfraMonitoringEntity,
|
||||
} from 'container/InfraMonitoringK8sV2/constants';
|
||||
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { infraStoryMocks } from './InfrastructureMonitoring.stories.mocks';
|
||||
import { infraListResponse } from './__story_mockdata__/infraMonitoring';
|
||||
|
||||
@@ -58,7 +56,6 @@ export const podLongNodeName = rest.post(
|
||||
* what puts `?category=` on the route the story starts on.
|
||||
*/
|
||||
export const podsMocks = infraStoryMocks(InfraMonitoringEntity.PODS);
|
||||
export type PodsArgs = PageStoryArgs<typeof podsMocks>;
|
||||
export const nodesMocks = infraStoryMocks(InfraMonitoringEntity.NODES);
|
||||
export const namespacesMocks = infraStoryMocks(
|
||||
InfraMonitoringEntity.NAMESPACES,
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import {
|
||||
findSuggestion,
|
||||
openKeySuggestions,
|
||||
typeFilter,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
import { VIEWS } from 'container/InfraMonitoringK8sV2/constants';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
podLongNodeName,
|
||||
podsMocks,
|
||||
type PodsArgs,
|
||||
} from './Kubernetes.stories.mocks';
|
||||
import {
|
||||
copyPodMetadata,
|
||||
copyPodName,
|
||||
openDrawerLogsTab,
|
||||
openTableOptions,
|
||||
suggestFilterKeys,
|
||||
suggestFilterValues,
|
||||
} from './__story_mockdata__/pods.play';
|
||||
import { podLongNodeName, podsMocks } from './Kubernetes.stories.mocks';
|
||||
|
||||
import InfrastructureMonitoringPage from '../InfrastructureMonitoringPage';
|
||||
|
||||
type PodsArgs = PageStoryArgs<typeof podsMocks>;
|
||||
|
||||
const pageStory = storyMocks(podsMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
@@ -60,19 +57,38 @@ export const PodDetailsEventsJsonBody: StoryObj<PodsArgs> = {
|
||||
/** The selected pod's details drawer, switched to its logs tab. */
|
||||
export const DetailsDrawerLogsTab: StoryObj<PodsArgs> = {
|
||||
args: { drawer: true },
|
||||
play: openDrawerLogsTab,
|
||||
play: async () => {
|
||||
const drawer = within(
|
||||
await screen.findByRole('dialog', {}, { timeout: 10000 }),
|
||||
);
|
||||
|
||||
await userEvent.click(
|
||||
await drawer.findByText('Logs', {}, { timeout: 10000 }),
|
||||
);
|
||||
await drawer.findAllByText(/handled request in/, {}, { timeout: 10000 });
|
||||
},
|
||||
};
|
||||
|
||||
/** The copy button beside the pod name in the drawer, which toasts the copy. */
|
||||
export const PodNameCopiedToast: StoryObj<PodsArgs> = {
|
||||
args: { drawer: true },
|
||||
play: copyPodName,
|
||||
play: async () => {
|
||||
await userEvent.click(
|
||||
await screen.findByTestId('copy-id-button', {}, { timeout: 10000 }),
|
||||
);
|
||||
await screen.findByText('Pod name copied to clipboard');
|
||||
},
|
||||
};
|
||||
|
||||
/** A metadata value's copy button in the drawer, which toasts the label it copied. */
|
||||
export const PodMetadataCopiedToast: StoryObj<PodsArgs> = {
|
||||
args: { drawer: true },
|
||||
play: copyPodMetadata,
|
||||
play: async () => {
|
||||
await userEvent.click(
|
||||
await screen.findByTestId('copy-metadata-namespace', {}, { timeout: 10000 }),
|
||||
);
|
||||
await screen.findByText('NAMESPACE copied to clipboard');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -106,7 +122,19 @@ export const TooltipsInDetailsDrawer: StoryObj<PodsArgs> = {
|
||||
*/
|
||||
export const TooltipsInOptionsPanel: StoryObj<PodsArgs> = {
|
||||
args: { tooltipsOpen: true },
|
||||
play: openTableOptions,
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.click(
|
||||
await canvas.findByTestId(
|
||||
'k8s-table-options-button',
|
||||
{},
|
||||
{ timeout: 10000 },
|
||||
),
|
||||
);
|
||||
|
||||
await screen.findByText('Columns');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -119,11 +147,16 @@ const heldViewport = { sbshot: { viewport: { height: 1200 } } };
|
||||
/** The pod filter focused: the Kubernetes keys pods can be narrowed by. */
|
||||
export const FilterKeySuggestions: StoryObj<PodsArgs> = {
|
||||
parameters: heldViewport,
|
||||
play: suggestFilterKeys,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openKeySuggestions(canvasElement, 'k8s.node.name');
|
||||
},
|
||||
};
|
||||
|
||||
/** The pod filter on a namespace: the namespaces the pods run in. */
|
||||
export const FilterValueSuggestions: StoryObj<PodsArgs> = {
|
||||
parameters: heldViewport,
|
||||
play: suggestFilterValues,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'k8s.namespace.name = ');
|
||||
await findSuggestion(canvasElement, 'kube-system');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import {
|
||||
findSuggestion,
|
||||
openKeySuggestions,
|
||||
typeFilter,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import type { PodsArgs } from '../Kubernetes.stories.mocks';
|
||||
|
||||
interface PlayContext {
|
||||
canvasElement: HTMLElement;
|
||||
args: PodsArgs;
|
||||
}
|
||||
|
||||
export const openDrawerLogsTab = async ({
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!args.drawer || args.dataState !== 'loaded' || args.telemetryRows === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const drawer = within(
|
||||
await screen.findByRole('dialog', {}, { timeout: 10000 }),
|
||||
);
|
||||
|
||||
await userEvent.click(await drawer.findByText('Logs', {}, { timeout: 10000 }));
|
||||
await drawer.findAllByText(/handled request in/, {}, { timeout: 10000 });
|
||||
};
|
||||
|
||||
export const copyPodName = async ({ args }: PlayContext): Promise<void> => {
|
||||
if (!args.drawer) {
|
||||
return;
|
||||
}
|
||||
|
||||
await userEvent.click(
|
||||
await screen.findByTestId('copy-id-button', {}, { timeout: 10000 }),
|
||||
);
|
||||
await screen.findByText('Pod name copied to clipboard');
|
||||
};
|
||||
|
||||
export const copyPodMetadata = async ({ args }: PlayContext): Promise<void> => {
|
||||
if (!args.drawer || args.dataState !== 'loaded') {
|
||||
return;
|
||||
}
|
||||
|
||||
await userEvent.click(
|
||||
await screen.findByTestId('copy-metadata-namespace', {}, { timeout: 10000 }),
|
||||
);
|
||||
await screen.findByText('NAMESPACE copied to clipboard');
|
||||
};
|
||||
|
||||
export const openTableOptions = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
// The drawer covers the list, so its options are out of reach.
|
||||
if (args.drawer) {
|
||||
return;
|
||||
}
|
||||
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'k8s-table-options-button',
|
||||
{},
|
||||
{ timeout: 10000 },
|
||||
),
|
||||
);
|
||||
await screen.findByText('Columns');
|
||||
};
|
||||
|
||||
export const suggestFilterKeys = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (args.dataState !== 'loaded') {
|
||||
return;
|
||||
}
|
||||
|
||||
await openKeySuggestions(canvasElement, 'k8s.node.name');
|
||||
};
|
||||
|
||||
export const suggestFilterValues = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (args.dataState !== 'loaded') {
|
||||
return;
|
||||
}
|
||||
|
||||
await typeFilter(canvasElement, 'k8s.namespace.name = ');
|
||||
await findSuggestion(canvasElement, 'kube-system');
|
||||
};
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
import {
|
||||
RESPONSE_STATES,
|
||||
type ResponseState,
|
||||
@@ -313,5 +312,3 @@ export const logsMocks = defineStoryMocks({
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export type LogsArgs = PageStoryArgs<typeof logsMocks>;
|
||||
|
||||
@@ -1,35 +1,23 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import {
|
||||
blurFilter,
|
||||
openKeySuggestions,
|
||||
showFilterErrors,
|
||||
typeFilter,
|
||||
typeFilterWithCaretBack,
|
||||
findSuggestion,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { type LogsArgs, logsMocks } from './LogsModulePage.stories.mocks';
|
||||
import {
|
||||
completeFilter,
|
||||
copyLog,
|
||||
copyLogDetailsLink,
|
||||
copyLogLinkFromRow,
|
||||
dirtyQuickFiltersSettings,
|
||||
openLogContext,
|
||||
openLogDetails,
|
||||
openLogDetailsMenu,
|
||||
openQuickFiltersSettings,
|
||||
showFilterSyntaxError,
|
||||
stepToNextLog,
|
||||
suggestConjunctions,
|
||||
suggestFilterKeys,
|
||||
suggestFilterValues,
|
||||
suggestInListValues,
|
||||
suggestInNestedGroup,
|
||||
suggestNegatedOperators,
|
||||
suggestNoValues,
|
||||
suggestNumberOperators,
|
||||
suggestOperators,
|
||||
suggestPartialKey,
|
||||
suggestRecentSearches,
|
||||
} from './__story_mockdata__/logs.play';
|
||||
import { logsMocks } from './LogsModulePage.stories.mocks';
|
||||
|
||||
import LogsModulePage from '../LogsModulePage';
|
||||
|
||||
type LogsArgs = PageStoryArgs<typeof logsMocks>;
|
||||
|
||||
const pageStory = storyMocks(logsMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
@@ -50,6 +38,42 @@ export default meta;
|
||||
|
||||
type Story = StoryObj<LogsArgs>;
|
||||
|
||||
/** The page fetches before it renders a row, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
const openQuickFiltersSettings = async (): Promise<void> => {
|
||||
// The settings control renders disabled while its permission check is in
|
||||
// flight and is swapped for the enabled one once the check answers, so it is
|
||||
// looked up again on every attempt; a click on the disabled one is dropped in
|
||||
// silence.
|
||||
const control = await waitFor(() => {
|
||||
const settings = screen.getByTestId('settings-icon-container');
|
||||
|
||||
expect(settings).not.toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
return settings;
|
||||
}, untilLoaded);
|
||||
|
||||
await userEvent.click(control);
|
||||
await screen.findByText('Edit quick filters');
|
||||
};
|
||||
|
||||
/** Opening a log is what the drawer is for, and the first row will do. */
|
||||
const openFirstLog = async (canvasElement: HTMLElement): Promise<void> => {
|
||||
const [row] = await within(canvasElement).findAllByTestId(
|
||||
/^logs-table-row-/,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
// The list hangs its click handler off the cells rather than the row, so a
|
||||
// click on the row itself opens nothing.
|
||||
const [, timestamp] = within(row).getAllByRole('cell');
|
||||
|
||||
await userEvent.click(timestamp);
|
||||
await screen.findByTestId('log-detail-drawer', undefined, untilLoaded);
|
||||
};
|
||||
|
||||
/**
|
||||
* The explorer on its list view: quick filters down the left, the frequency
|
||||
* chart over a page of log lines across every severity, and the saved views
|
||||
@@ -124,17 +148,30 @@ export const JsonBodyFieldsRaw: Story = {
|
||||
|
||||
/** One log opened up: its own fields, then the resource that sent it. */
|
||||
export const LogDetails: Story = {
|
||||
play: openLogDetails,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openFirstLog(canvasElement);
|
||||
},
|
||||
};
|
||||
|
||||
/** The logs either side of the one that was opened, on the same filters. */
|
||||
export const LogDetailsContext: Story = {
|
||||
play: openLogContext,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openFirstLog(canvasElement);
|
||||
await userEvent.click(
|
||||
await screen.findByText('Context', undefined, untilLoaded),
|
||||
);
|
||||
// The tab queries the logs either side before it can render them.
|
||||
await screen.findAllByText(/cache lookup for cart/, undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/** The log detail drawer with its header actions menu open. */
|
||||
export const LogDetailsMenuOpen: Story = {
|
||||
play: openLogDetailsMenu,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openFirstLog(canvasElement);
|
||||
await userEvent.click(await screen.findByTestId('log-details-header-menu'));
|
||||
await screen.findByText('Copy link to log');
|
||||
},
|
||||
};
|
||||
|
||||
/** A response warning shown beside the query actions. */
|
||||
@@ -159,6 +196,17 @@ export const QuickFiltersSettings: Story = {
|
||||
play: openQuickFiltersSettings,
|
||||
};
|
||||
|
||||
const dirtyQuickFiltersSettings = async (): Promise<void> => {
|
||||
await openQuickFiltersSettings();
|
||||
// One Remove per added filter; the first row's is the one clicked.
|
||||
const [removeFilter] = await screen.findAllByRole('button', {
|
||||
name: 'Remove',
|
||||
});
|
||||
|
||||
await userEvent.click(removeFilter);
|
||||
await screen.findByRole('button', { name: 'Save changes' });
|
||||
};
|
||||
|
||||
/** Settings with an unsaved filter removal and the fixed action footer. */
|
||||
export const QuickFiltersSettingsDirty: Story = {
|
||||
play: dirtyQuickFiltersSettings,
|
||||
@@ -184,48 +232,76 @@ export const QuickFiltersSettingsWithBanner: Story = {
|
||||
*/
|
||||
export const Tooltips: Story = {
|
||||
args: { tooltipsOpen: true },
|
||||
play: stepToNextLog,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openFirstLog(canvasElement);
|
||||
await userEvent.click(
|
||||
await screen.findByTestId('log-details-header-next', undefined, untilLoaded),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** The filter focused before anything is typed: every key the logs carry. */
|
||||
export const FilterKeySuggestions: Story = {
|
||||
play: suggestFilterKeys,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openKeySuggestions(canvasElement, 'severity_text');
|
||||
},
|
||||
};
|
||||
|
||||
/** A partial key, with the keys that still match and the typed part marked. */
|
||||
export const FilterPartialKey: Story = {
|
||||
play: suggestPartialKey,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'serv');
|
||||
await findSuggestion(canvasElement, 'service.name');
|
||||
},
|
||||
};
|
||||
|
||||
/** A string key followed by a space: the operators a string compares with. */
|
||||
export const FilterOperatorSuggestions: Story = {
|
||||
play: suggestOperators,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'service.name ');
|
||||
await findSuggestion(canvasElement, 'CONTAINS');
|
||||
},
|
||||
};
|
||||
|
||||
/** A number key puts the range comparisons first. */
|
||||
export const FilterNumberOperatorSuggestions: Story = {
|
||||
play: suggestNumberOperators,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'http.status_code ');
|
||||
await findSuggestion(canvasElement, 'BETWEEN');
|
||||
},
|
||||
};
|
||||
|
||||
/** `NOT` after a key narrows the list to the operators it can negate. */
|
||||
export const FilterNegatedOperatorSuggestions: Story = {
|
||||
play: suggestNegatedOperators,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'service.name NOT ');
|
||||
await findSuggestion(canvasElement, 'IN');
|
||||
},
|
||||
};
|
||||
|
||||
/** A key and an operator: the values the key holds, fetched for it. */
|
||||
export const FilterValueSuggestions: Story = {
|
||||
play: suggestFilterValues,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'service.name = ');
|
||||
await findSuggestion(canvasElement, 'checkout');
|
||||
},
|
||||
};
|
||||
|
||||
/** Values still being fetched for the key. */
|
||||
export const FilterValuesLoading: Story = {
|
||||
args: { filterValues: 'loading' },
|
||||
play: suggestFilterValues,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'service.name = ');
|
||||
await findSuggestion(canvasElement, 'Loading suggestions');
|
||||
},
|
||||
};
|
||||
|
||||
/** A key the backend holds no values for, such as the free-text body. */
|
||||
export const FilterNoValueSuggestions: Story = {
|
||||
play: suggestNoValues,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'body = ');
|
||||
await findSuggestion(canvasElement, 'No suggestions available');
|
||||
},
|
||||
};
|
||||
|
||||
/** The values request failed. */
|
||||
@@ -233,55 +309,120 @@ export const FilterValuesError: Story = {
|
||||
args: { filterValues: 'error' },
|
||||
// The values request deliberately fails.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: suggestFilterValues,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'service.name = ');
|
||||
await findSuggestion(canvasElement, 'Error loading suggestions');
|
||||
},
|
||||
};
|
||||
|
||||
/** Inside an `IN` list, after the first value: the rest of the values. */
|
||||
export const FilterInList: Story = {
|
||||
// The editor logs a TypeError while the list is open, which it survives.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: suggestInListValues,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilterWithCaretBack(
|
||||
canvasElement,
|
||||
"service.name IN ['auth', ]",
|
||||
1,
|
||||
'checkout',
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** A complete condition: the conjunctions that start the next one. */
|
||||
export const FilterConjunctionSuggestions: Story = {
|
||||
play: suggestConjunctions,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, "service.name = 'checkout' ");
|
||||
await findSuggestion(canvasElement, 'OR');
|
||||
},
|
||||
};
|
||||
|
||||
/** Inside an opened group: keys, another group and `NOT`. */
|
||||
export const FilterNestedGroup: Story = {
|
||||
play: suggestInNestedGroup,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilterWithCaretBack(canvasElement, '()', 1, 'NOT');
|
||||
},
|
||||
};
|
||||
|
||||
/** A long valid expression mixing operators, left for the next run. */
|
||||
export const FilterComplete: Story = {
|
||||
// The editor logs a TypeError while the `IN` list is typed, which it survives.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: completeFilter,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(
|
||||
canvasElement,
|
||||
"service.name IN ['checkout', 'payments'] AND severity_text = 'ERROR' AND http.status_code >= 500 AND body CONTAINS 'timeout'",
|
||||
);
|
||||
await blurFilter(canvasElement);
|
||||
},
|
||||
};
|
||||
|
||||
/** An incomplete expression after focus left: the marker and its errors. */
|
||||
export const FilterSyntaxError: Story = {
|
||||
play: showFilterSyntaxError,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await showFilterErrors(canvasElement, 'service.name = ');
|
||||
},
|
||||
};
|
||||
|
||||
/** Filters run before, offered above the key suggestions. */
|
||||
export const FilterRecentSearches: Story = {
|
||||
args: { recentFilters: 3 },
|
||||
play: suggestRecentSearches,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openKeySuggestions(
|
||||
canvasElement,
|
||||
"k8s.namespace.name = 'observability'",
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** Row actions render on hover, so the first row is hovered before the copy link is pressed. */
|
||||
export const LogLinkCopiedToast: Story = {
|
||||
play: copyLogLinkFromRow,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const [row] = await within(canvasElement).findAllByTestId(
|
||||
/^logs-table-row-/,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
const [, timestamp] = within(row).getAllByRole('cell');
|
||||
|
||||
await userEvent.hover(timestamp);
|
||||
await userEvent.click(
|
||||
await waitFor(() => {
|
||||
const button = canvasElement.querySelector<HTMLElement>('.copy-log-btn');
|
||||
|
||||
if (!button) {
|
||||
throw new Error('Row actions did not render');
|
||||
}
|
||||
|
||||
return button;
|
||||
}, untilLoaded),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Copied to clipboard')).toBeVisible(),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** "Copy log" in the log detail drawer's actions menu. */
|
||||
export const LogCopiedToast: Story = {
|
||||
play: copyLog,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openFirstLog(canvasElement);
|
||||
await userEvent.click(await screen.findByTestId('log-details-header-menu'));
|
||||
await userEvent.click(await screen.findByText('Copy log'));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Copied to clipboard')).toBeVisible(),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** "Copy link to log" in the log detail drawer's actions menu. */
|
||||
export const LogDetailsLinkCopiedToast: Story = {
|
||||
play: copyLogDetailsLink,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openFirstLog(canvasElement);
|
||||
await userEvent.click(await screen.findByTestId('log-details-header-menu'));
|
||||
await userEvent.click(await screen.findByText('Copy link to log'));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Copied to clipboard')).toBeVisible(),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,319 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import {
|
||||
blurFilter,
|
||||
findSuggestion,
|
||||
openKeySuggestions,
|
||||
showFilterErrors,
|
||||
typeFilter,
|
||||
typeFilterWithCaretBack,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
|
||||
import { QuickFilterManagePermissions } from 'lib/authz/hooks/useAuthZ/permissions/quick-filter.permissions';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { grantsAll } from '@/storybook/access/access';
|
||||
|
||||
import type { LogsArgs } from '../LogsModulePage.stories.mocks';
|
||||
|
||||
interface PlayContext {
|
||||
canvasElement: HTMLElement;
|
||||
args: LogsArgs;
|
||||
}
|
||||
|
||||
/** The page fetches before it renders a row, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
const VALUE_SUGGESTIONS = {
|
||||
loaded: 'checkout',
|
||||
loading: 'Loading suggestions',
|
||||
error: 'Error loading suggestions',
|
||||
} satisfies Record<LogsArgs['filterValues'], string>;
|
||||
|
||||
/** The rows a play opens exist only in the list view's table format. */
|
||||
const listsLogRows = (args: LogsArgs, count = 1): boolean =>
|
||||
args.view === 'list' &&
|
||||
args.format === 'table' &&
|
||||
args.dataState === 'loaded' &&
|
||||
args.logs >= count;
|
||||
|
||||
/** The settings control stays enabled when the check itself fails. */
|
||||
const canManageQuickFilters = (args: LogsArgs): boolean =>
|
||||
args.filtersPanel &&
|
||||
(args.authzState === 'error' ||
|
||||
grantsAll(args, ...QuickFilterManagePermissions));
|
||||
|
||||
/** Runs `drive` once the filter has its keys, which come with the page's data. */
|
||||
const onFilter =
|
||||
(drive: (canvasElement: HTMLElement, args: LogsArgs) => Promise<void>) =>
|
||||
async ({ canvasElement, args }: PlayContext): Promise<void> => {
|
||||
if (args.dataState === 'loaded') {
|
||||
await drive(canvasElement, args);
|
||||
}
|
||||
};
|
||||
|
||||
/** Opening a log is what the drawer is for, and the first row will do. */
|
||||
const openFirstLog = async (canvasElement: HTMLElement): Promise<void> => {
|
||||
const [row] = await within(canvasElement).findAllByTestId(
|
||||
/^logs-table-row-/,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
// The list hangs its click handler off the cells rather than the row, so a
|
||||
// click on the row itself opens nothing.
|
||||
const [, timestamp] = within(row).getAllByRole('cell');
|
||||
|
||||
await userEvent.click(timestamp);
|
||||
await screen.findByTestId('log-detail-drawer', undefined, untilLoaded);
|
||||
};
|
||||
|
||||
const openLogMenuItem = async (
|
||||
canvasElement: HTMLElement,
|
||||
item: string,
|
||||
): Promise<void> => {
|
||||
await openFirstLog(canvasElement);
|
||||
await userEvent.click(await screen.findByTestId('log-details-header-menu'));
|
||||
await userEvent.click(await screen.findByText(item));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Copied to clipboard')).toBeVisible(),
|
||||
);
|
||||
};
|
||||
|
||||
export const openLogDetails = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!listsLogRows(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openFirstLog(canvasElement);
|
||||
};
|
||||
|
||||
export const openLogContext = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!listsLogRows(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openFirstLog(canvasElement);
|
||||
await userEvent.click(
|
||||
await screen.findByText('Context', undefined, untilLoaded),
|
||||
);
|
||||
// The tab queries the logs either side before it can render them.
|
||||
await screen.findAllByText(/cache lookup for cart/, undefined, untilLoaded);
|
||||
};
|
||||
|
||||
export const openLogDetailsMenu = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!listsLogRows(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openFirstLog(canvasElement);
|
||||
await userEvent.click(await screen.findByTestId('log-details-header-menu'));
|
||||
await screen.findByText('Copy link to log');
|
||||
};
|
||||
|
||||
export const openQuickFiltersSettings = async ({
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!canManageQuickFilters(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The settings control renders disabled while its permission check is in
|
||||
// flight and is swapped for the enabled one once the check answers, so it is
|
||||
// looked up again on every attempt; a click on the disabled one is dropped in
|
||||
// silence.
|
||||
const control = await waitFor(async () => {
|
||||
const settings = screen.getByTestId('settings-icon-container');
|
||||
|
||||
await expect(settings).not.toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
return settings;
|
||||
}, untilLoaded);
|
||||
|
||||
await userEvent.click(control);
|
||||
await screen.findByText('Edit quick filters');
|
||||
};
|
||||
|
||||
export const dirtyQuickFiltersSettings = async (
|
||||
context: PlayContext,
|
||||
): Promise<void> => {
|
||||
const { args } = context;
|
||||
if (
|
||||
!canManageQuickFilters(args) ||
|
||||
args.dataState !== 'loaded' ||
|
||||
args.quickFilters === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openQuickFiltersSettings(context);
|
||||
// One Remove per added filter; the first row's is the one clicked.
|
||||
const [removeFilter] = await screen.findAllByRole('button', {
|
||||
name: 'Remove',
|
||||
});
|
||||
|
||||
await userEvent.click(removeFilter);
|
||||
await screen.findByRole('button', { name: 'Save changes' });
|
||||
};
|
||||
|
||||
export const stepToNextLog = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!listsLogRows(args, 2)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openFirstLog(canvasElement);
|
||||
await userEvent.click(
|
||||
await screen.findByTestId('log-details-header-next', undefined, untilLoaded),
|
||||
);
|
||||
};
|
||||
|
||||
export const suggestFilterKeys = onFilter((canvasElement) =>
|
||||
openKeySuggestions(canvasElement, 'severity_text'),
|
||||
);
|
||||
|
||||
export const suggestPartialKey = onFilter(async (canvasElement) => {
|
||||
await typeFilter(canvasElement, 'serv');
|
||||
await findSuggestion(canvasElement, 'service.name');
|
||||
});
|
||||
|
||||
export const suggestOperators = onFilter(async (canvasElement) => {
|
||||
await typeFilter(canvasElement, 'service.name ');
|
||||
await findSuggestion(canvasElement, 'CONTAINS');
|
||||
});
|
||||
|
||||
export const suggestNumberOperators = onFilter(async (canvasElement) => {
|
||||
await typeFilter(canvasElement, 'http.status_code ');
|
||||
await findSuggestion(canvasElement, 'BETWEEN');
|
||||
});
|
||||
|
||||
export const suggestNegatedOperators = onFilter(async (canvasElement) => {
|
||||
await typeFilter(canvasElement, 'service.name NOT ');
|
||||
await findSuggestion(canvasElement, 'IN');
|
||||
});
|
||||
|
||||
export const suggestFilterValues = onFilter(async (canvasElement, args) => {
|
||||
await typeFilter(canvasElement, 'service.name = ');
|
||||
await findSuggestion(canvasElement, VALUE_SUGGESTIONS[args.filterValues]);
|
||||
});
|
||||
|
||||
export const suggestNoValues = onFilter(async (canvasElement, args) => {
|
||||
if (args.filterValues !== 'loaded') {
|
||||
return;
|
||||
}
|
||||
|
||||
await typeFilter(canvasElement, 'body = ');
|
||||
await findSuggestion(canvasElement, 'No suggestions available');
|
||||
});
|
||||
|
||||
export const suggestInListValues = onFilter(async (canvasElement, args) => {
|
||||
if (args.filterValues !== 'loaded') {
|
||||
return;
|
||||
}
|
||||
|
||||
await typeFilterWithCaretBack(
|
||||
canvasElement,
|
||||
"service.name IN ['auth', ]",
|
||||
1,
|
||||
'checkout',
|
||||
);
|
||||
});
|
||||
|
||||
export const suggestConjunctions = onFilter(async (canvasElement) => {
|
||||
await typeFilter(canvasElement, "service.name = 'checkout' ");
|
||||
await findSuggestion(canvasElement, 'OR');
|
||||
});
|
||||
|
||||
export const suggestInNestedGroup = onFilter((canvasElement) =>
|
||||
typeFilterWithCaretBack(canvasElement, '()', 1, 'NOT'),
|
||||
);
|
||||
|
||||
export const completeFilter = onFilter(async (canvasElement) => {
|
||||
await typeFilter(
|
||||
canvasElement,
|
||||
"service.name IN ['checkout', 'payments'] AND severity_text = 'ERROR' AND http.status_code >= 500 AND body CONTAINS 'timeout'",
|
||||
);
|
||||
await blurFilter(canvasElement);
|
||||
});
|
||||
|
||||
export const showFilterSyntaxError = onFilter((canvasElement) =>
|
||||
showFilterErrors(canvasElement, 'service.name = '),
|
||||
);
|
||||
|
||||
export const suggestRecentSearches = onFilter(async (canvasElement, args) => {
|
||||
if (args.recentFilters === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openKeySuggestions(
|
||||
canvasElement,
|
||||
"k8s.namespace.name = 'observability'",
|
||||
);
|
||||
});
|
||||
|
||||
export const copyLogLinkFromRow = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!listsLogRows(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [row] = await within(canvasElement).findAllByTestId(
|
||||
/^logs-table-row-/,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
const [, timestamp] = within(row).getAllByRole('cell');
|
||||
|
||||
await userEvent.hover(timestamp);
|
||||
await userEvent.click(
|
||||
await waitFor(() => {
|
||||
const button = canvasElement.querySelector<HTMLElement>('.copy-log-btn');
|
||||
|
||||
if (!button) {
|
||||
throw new Error('Row actions did not render');
|
||||
}
|
||||
|
||||
return button;
|
||||
}, untilLoaded),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Copied to clipboard')).toBeVisible(),
|
||||
);
|
||||
};
|
||||
|
||||
export const copyLog = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!listsLogRows(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openLogMenuItem(canvasElement, 'Copy log');
|
||||
};
|
||||
|
||||
export const copyLogDetailsLink = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!listsLogRows(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openLogMenuItem(canvasElement, 'Copy link to log');
|
||||
};
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
import { dashboardsForUserResponse } from '@/storybook/msw/__story_mockdata__/dashboards';
|
||||
import {
|
||||
fieldKeysResponse,
|
||||
@@ -554,8 +553,6 @@ export const metricsMocks = defineStoryMocks({
|
||||
},
|
||||
});
|
||||
|
||||
export type MetricsArgs = PageStoryArgs<typeof metricsMocks>;
|
||||
|
||||
export const saveUnit = async (
|
||||
canvasElement: HTMLElement,
|
||||
expected: string | false,
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import {
|
||||
findSuggestion,
|
||||
openKeySuggestions,
|
||||
typeFilter,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
|
||||
import { screen, userEvent } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
type MetricsArgs,
|
||||
metricsMocks,
|
||||
} from './MetricsExplorerPage.stories.mocks';
|
||||
import {
|
||||
openDashboardsMenu,
|
||||
saveMetricUnit,
|
||||
suggestFilterKeys,
|
||||
suggestFilterValues,
|
||||
} from './__story_mockdata__/metricsExplorer.play';
|
||||
import { metricsMocks, saveUnit } from './MetricsExplorerPage.stories.mocks';
|
||||
import MetricsExplorerPage from '../MetricsExplorerPage';
|
||||
|
||||
type MetricsArgs = PageStoryArgs<typeof metricsMocks>;
|
||||
|
||||
const pageStory = storyMocks(metricsMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
@@ -103,17 +103,31 @@ export const Tooltips: Story = {
|
||||
*/
|
||||
export const MetricDetailsDashboardsMenu: Story = {
|
||||
args: { drawer: 'details' },
|
||||
play: openDashboardsMenu,
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await screen.findByTestId(
|
||||
'metric-dashboards-popover',
|
||||
{},
|
||||
{ timeout: 10000 },
|
||||
),
|
||||
);
|
||||
await screen.findByRole('menu');
|
||||
},
|
||||
};
|
||||
|
||||
/** The summary's metric search focused: the attributes metrics can be found by. */
|
||||
export const FilterKeySuggestions: Story = {
|
||||
play: suggestFilterKeys,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openKeySuggestions(canvasElement, 'k8s.cluster.name');
|
||||
},
|
||||
};
|
||||
|
||||
/** The metric search on an attribute and an operator: the values it holds. */
|
||||
export const FilterValueSuggestions: Story = {
|
||||
play: suggestFilterValues,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'service.name = ');
|
||||
await findSuggestion(canvasElement, 'checkout');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -122,7 +136,14 @@ export const FilterValueSuggestions: Story = {
|
||||
*/
|
||||
export const UnitSavedToast: Story = {
|
||||
args: { tab: 'explorer', metricUnit: 'unset' },
|
||||
play: saveMetricUnit,
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await saveUnit(
|
||||
canvasElement,
|
||||
args.unitSave === 'success'
|
||||
? 'Unit saved successfully'
|
||||
: 'Failed to save unit',
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** The same save with the metadata write failing. */
|
||||
@@ -130,5 +151,7 @@ export const UnitSaveErrorToast: Story = {
|
||||
args: { tab: 'explorer', metricUnit: 'unset', unitSave: 'error' },
|
||||
// The deliberate 500 is the state under test.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: saveMetricUnit,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await saveUnit(canvasElement, 'Failed to save unit');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import {
|
||||
findSuggestion,
|
||||
openKeySuggestions,
|
||||
typeFilter,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
|
||||
import { screen, userEvent } from 'storybook/test';
|
||||
|
||||
import {
|
||||
type MetricsArgs,
|
||||
saveUnit,
|
||||
} from '../MetricsExplorerPage.stories.mocks';
|
||||
|
||||
interface PlayContext {
|
||||
canvasElement: HTMLElement;
|
||||
args: MetricsArgs;
|
||||
}
|
||||
|
||||
const showsMetricSearch = (args: MetricsArgs): boolean =>
|
||||
args.dataState === 'loaded' && args.tab !== 'volume-control';
|
||||
|
||||
export const openDashboardsMenu = async ({
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (
|
||||
args.tab !== 'summary' ||
|
||||
args.drawer !== 'details' ||
|
||||
args.dataState !== 'loaded' ||
|
||||
args.relatedAssets === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await userEvent.click(
|
||||
await screen.findByTestId(
|
||||
'metric-dashboards-popover',
|
||||
{},
|
||||
{ timeout: 10000 },
|
||||
),
|
||||
);
|
||||
await screen.findByRole('menu');
|
||||
};
|
||||
|
||||
export const suggestFilterKeys = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!showsMetricSearch(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openKeySuggestions(canvasElement, 'k8s.cluster.name');
|
||||
};
|
||||
|
||||
export const suggestFilterValues = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!showsMetricSearch(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await typeFilter(canvasElement, 'service.name = ');
|
||||
await findSuggestion(canvasElement, 'checkout');
|
||||
};
|
||||
|
||||
export const saveMetricUnit = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (
|
||||
args.tab !== 'explorer' ||
|
||||
args.dataState !== 'loaded' ||
|
||||
args.metricUnit !== 'unset' ||
|
||||
args.explorerMetrics !== 1
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await saveUnit(
|
||||
canvasElement,
|
||||
args.unitSave === 'success'
|
||||
? 'Unit saved successfully'
|
||||
: 'Failed to save unit',
|
||||
);
|
||||
};
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
import { fieldValuesResponse } from '@/storybook/msw/__story_mockdata__/fields';
|
||||
import {
|
||||
queryRangeV5RawResponse,
|
||||
@@ -355,5 +354,3 @@ export const tracesMocks = defineStoryMocks({
|
||||
],
|
||||
config: (values) => ({ route: tracesRoute(values) }),
|
||||
});
|
||||
|
||||
export type TracesArgs = PageStoryArgs<typeof tracesMocks>;
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import {
|
||||
findSuggestion,
|
||||
openKeySuggestions,
|
||||
showFilterErrors,
|
||||
typeFilter,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { type TracesArgs, tracesMocks } from './TracesModulePage.stories.mocks';
|
||||
import {
|
||||
dirtyQuickFiltersSettings,
|
||||
loadNextListPage,
|
||||
openQuickFiltersSettings,
|
||||
showRootSpansNextPage,
|
||||
suggestBooleanValues,
|
||||
suggestFilterKeys,
|
||||
suggestFilterValues,
|
||||
suggestResourceKeys,
|
||||
showFilterSyntaxError,
|
||||
} from './__story_mockdata__/traces.play';
|
||||
import { tracesMocks } from './TracesModulePage.stories.mocks';
|
||||
import TracesModulePage from '../TracesModulePage';
|
||||
|
||||
type TracesArgs = PageStoryArgs<typeof tracesMocks>;
|
||||
|
||||
const pageStory = storyMocks(tracesMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
@@ -35,8 +34,28 @@ const meta = {
|
||||
|
||||
export default meta;
|
||||
|
||||
/** The page fetches before it renders its filters, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
type Story = StoryObj<TracesArgs>;
|
||||
|
||||
const openQuickFiltersSettings = async (): Promise<void> => {
|
||||
// The settings control renders disabled while its permission check is in
|
||||
// flight and is swapped for the enabled one once the check answers, so it is
|
||||
// looked up again on every attempt; a click on the disabled one is dropped in
|
||||
// silence.
|
||||
const control = await waitFor(() => {
|
||||
const settings = screen.getByTestId('settings-icon-container');
|
||||
|
||||
expect(settings).not.toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
return settings;
|
||||
}, untilLoaded);
|
||||
|
||||
await userEvent.click(control);
|
||||
await screen.findByText('Edit quick filters');
|
||||
};
|
||||
|
||||
/**
|
||||
* The Explorer tab on a full window of spans: the org's quick filters down the
|
||||
* left, the query builder and the view switcher above, and the span list with
|
||||
@@ -58,13 +77,35 @@ export const RootSpans: Story = {
|
||||
*/
|
||||
export const RootSpansNextPage: Story = {
|
||||
args: { view: ExplorerViews.TRACE, spans: 50 },
|
||||
play: showRootSpansNextPage,
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Go to next page' }, untilLoaded),
|
||||
);
|
||||
const pager = screen.getByLabelText('pagination');
|
||||
|
||||
await expect(
|
||||
await within(pager).findByText('3', undefined, untilLoaded),
|
||||
).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
/** The span list scrolled to its end, with the next 100 spans loaded under the first. */
|
||||
export const ListNextPage: Story = {
|
||||
args: { spans: 150 },
|
||||
play: loadNextListPage,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
// Scrolls again on every attempt, since each loaded page moves the end.
|
||||
await waitFor(async (): Promise<void> => {
|
||||
const scroller = canvasElement.querySelector<HTMLElement>(
|
||||
'[data-virtuoso-scroller]',
|
||||
);
|
||||
|
||||
await expect(scroller).not.toBeNull();
|
||||
(scroller as HTMLElement).scrollTop = (scroller as HTMLElement).scrollHeight;
|
||||
await expect(
|
||||
canvasElement.querySelector('tr[data-index="120"]'),
|
||||
).not.toBeNull();
|
||||
}, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/** The charted view of the same trace query. */
|
||||
@@ -113,6 +154,17 @@ export const QuickFiltersSettings: Story = {
|
||||
play: openQuickFiltersSettings,
|
||||
};
|
||||
|
||||
const dirtyQuickFiltersSettings = async (): Promise<void> => {
|
||||
await openQuickFiltersSettings();
|
||||
// One Remove per added filter; the first row's is the one clicked.
|
||||
const [removeFilter] = await screen.findAllByRole('button', {
|
||||
name: 'Remove',
|
||||
});
|
||||
|
||||
await userEvent.click(removeFilter);
|
||||
await screen.findByRole('button', { name: 'Save changes' });
|
||||
};
|
||||
|
||||
/** Settings with an unsaved filter removal and the fixed action footer. */
|
||||
export const QuickFiltersSettingsDirty: Story = {
|
||||
play: dirtyQuickFiltersSettings,
|
||||
@@ -131,25 +183,38 @@ export const QuickFiltersSettingsWithBanner: Story = {
|
||||
|
||||
/** The filter focused before anything is typed: span and resource keys together. */
|
||||
export const FilterKeySuggestions: Story = {
|
||||
play: suggestFilterKeys,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openKeySuggestions(canvasElement, 'status_code_string');
|
||||
},
|
||||
};
|
||||
|
||||
/** A context prefix: only the keys that live on the resource. */
|
||||
export const FilterResourceKeys: Story = {
|
||||
play: suggestResourceKeys,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'resource.');
|
||||
await findSuggestion(canvasElement, 'resource.service.name');
|
||||
},
|
||||
};
|
||||
|
||||
/** A key and an operator: the services the spans came from. */
|
||||
export const FilterValueSuggestions: Story = {
|
||||
play: suggestFilterValues,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'service.name = ');
|
||||
await findSuggestion(canvasElement, 'checkout');
|
||||
},
|
||||
};
|
||||
|
||||
/** A boolean key offers its two values. */
|
||||
export const FilterBooleanValues: Story = {
|
||||
play: suggestBooleanValues,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'has_error = ');
|
||||
await findSuggestion(canvasElement, 'false');
|
||||
},
|
||||
};
|
||||
|
||||
/** A dangling conjunction after focus left: the marker and its errors. */
|
||||
export const FilterSyntaxError: Story = {
|
||||
play: showFilterSyntaxError,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await showFilterErrors(canvasElement, 'has_error = true AND');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import {
|
||||
findSuggestion,
|
||||
openKeySuggestions,
|
||||
showFilterErrors,
|
||||
typeFilter,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
|
||||
import { QuickFilterManagePermissions } from 'lib/authz/hooks/useAuthZ/permissions/quick-filter.permissions';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { grantsAll } from '@/storybook/access/access';
|
||||
|
||||
import type { TracesArgs } from '../TracesModulePage.stories.mocks';
|
||||
|
||||
interface PlayContext {
|
||||
canvasElement: HTMLElement;
|
||||
args: TracesArgs;
|
||||
}
|
||||
|
||||
/** The page fetches before it renders its filters, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
const LIST_PAGE_SIZE = 100;
|
||||
|
||||
const TRACE_PAGE_SIZE = 20;
|
||||
|
||||
const showsFilter = (args: TracesArgs): boolean =>
|
||||
args.dataState === 'loaded' && args.tab !== 'funnels';
|
||||
|
||||
const showsView = (args: TracesArgs, view: ExplorerViews): boolean =>
|
||||
args.tab === 'explorer' && args.view === view && args.dataState === 'loaded';
|
||||
|
||||
/** The settings control stays enabled when the check itself fails. */
|
||||
const canManageQuickFilters = (args: TracesArgs): boolean =>
|
||||
args.tab === 'explorer' &&
|
||||
(args.authzState === 'error' ||
|
||||
grantsAll(args, ...QuickFilterManagePermissions));
|
||||
|
||||
export const showRootSpansNextPage = async ({
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
// The pager offers page 3 once page 2 comes back full.
|
||||
if (
|
||||
!showsView(args, ExplorerViews.TRACE) ||
|
||||
args.spans < 2 * TRACE_PAGE_SIZE
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Go to next page' }, untilLoaded),
|
||||
);
|
||||
const pager = screen.getByLabelText('pagination');
|
||||
|
||||
await expect(
|
||||
await within(pager).findByText('3', undefined, untilLoaded),
|
||||
).toBeVisible();
|
||||
};
|
||||
|
||||
export const loadNextListPage = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!showsView(args, ExplorerViews.LIST) || args.spans <= LIST_PAGE_SIZE) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Scrolls again on every attempt, since each loaded page moves the end.
|
||||
await waitFor(async (): Promise<void> => {
|
||||
const scroller = canvasElement.querySelector<HTMLElement>(
|
||||
'[data-virtuoso-scroller]',
|
||||
);
|
||||
|
||||
await expect(scroller).not.toBeNull();
|
||||
(scroller as HTMLElement).scrollTop = (scroller as HTMLElement).scrollHeight;
|
||||
const indexes = Array.from(
|
||||
canvasElement.querySelectorAll<HTMLElement>('tr[data-index]'),
|
||||
(row) => Number(row.dataset.index),
|
||||
);
|
||||
|
||||
await expect(Math.max(...indexes)).toBeGreaterThanOrEqual(LIST_PAGE_SIZE);
|
||||
}, untilLoaded);
|
||||
};
|
||||
|
||||
export const openQuickFiltersSettings = async ({
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!canManageQuickFilters(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The settings control renders disabled while its permission check is in
|
||||
// flight and is swapped for the enabled one once the check answers, so it is
|
||||
// looked up again on every attempt; a click on the disabled one is dropped in
|
||||
// silence.
|
||||
const control = await waitFor(async () => {
|
||||
const settings = screen.getByTestId('settings-icon-container');
|
||||
|
||||
await expect(settings).not.toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
return settings;
|
||||
}, untilLoaded);
|
||||
|
||||
await userEvent.click(control);
|
||||
await screen.findByText('Edit quick filters');
|
||||
};
|
||||
|
||||
export const dirtyQuickFiltersSettings = async (
|
||||
context: PlayContext,
|
||||
): Promise<void> => {
|
||||
const { args } = context;
|
||||
if (
|
||||
!canManageQuickFilters(args) ||
|
||||
args.dataState !== 'loaded' ||
|
||||
args.quickFilters === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openQuickFiltersSettings(context);
|
||||
// One Remove per added filter; the first row's is the one clicked.
|
||||
const [removeFilter] = await screen.findAllByRole('button', {
|
||||
name: 'Remove',
|
||||
});
|
||||
|
||||
await userEvent.click(removeFilter);
|
||||
await screen.findByRole('button', { name: 'Save changes' });
|
||||
};
|
||||
|
||||
export const suggestFilterKeys = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!showsFilter(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openKeySuggestions(canvasElement, 'status_code_string');
|
||||
};
|
||||
|
||||
export const suggestResourceKeys = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!showsFilter(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await typeFilter(canvasElement, 'resource.');
|
||||
await findSuggestion(canvasElement, 'resource.service.name');
|
||||
};
|
||||
|
||||
export const suggestFilterValues = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!showsFilter(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await typeFilter(canvasElement, 'service.name = ');
|
||||
await findSuggestion(canvasElement, 'checkout');
|
||||
};
|
||||
|
||||
export const suggestBooleanValues = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!showsFilter(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await typeFilter(canvasElement, 'has_error = ');
|
||||
await findSuggestion(canvasElement, 'false');
|
||||
};
|
||||
|
||||
export const showFilterSyntaxError = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!showsFilter(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await showFilterErrors(canvasElement, 'has_error = true AND');
|
||||
};
|
||||
@@ -6,16 +6,12 @@ import {
|
||||
IsViewerPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/legacy';
|
||||
import permissionsType from 'lib/authz/hooks/useAuthZ/permissions.type';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import {
|
||||
formatPermission,
|
||||
gettableTransactionToPermission,
|
||||
permissionToTransactionDto,
|
||||
} from 'lib/authz/hooks/useAuthZ/utils';
|
||||
import { ROLES, USER_ROLES } from 'types/roles';
|
||||
|
||||
import type { ResponseState } from '../runtime/responseState';
|
||||
|
||||
/**
|
||||
* `relation:kind` for every verb the backend allows on a resource, from the
|
||||
* generated permission catalogue, so a resource added there shows up here
|
||||
@@ -163,23 +159,3 @@ export const accessFor = (
|
||||
legacyRole: deriveLegacyRole(permissions),
|
||||
};
|
||||
};
|
||||
|
||||
export interface AccessArgs {
|
||||
access: AccessPreset;
|
||||
permissions: readonly string[];
|
||||
revoked: readonly string[];
|
||||
authzState: ResponseState;
|
||||
}
|
||||
|
||||
/** Whether `authz/check`, answered from a story's access controls, passes every one of `checks`. */
|
||||
export const grantsAll = (
|
||||
args: AccessArgs,
|
||||
...checks: BrandedPermission[]
|
||||
): boolean => {
|
||||
const grant = accessFor(args.access, args.permissions, args.revoked);
|
||||
|
||||
return (
|
||||
args.authzState === 'loaded' &&
|
||||
checks.every((check) => grant.allows(permissionToTransactionDto(check)))
|
||||
);
|
||||
};
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface PostableAlertRuleV2 {
|
||||
absentFor?: number;
|
||||
requireMinPoints?: boolean;
|
||||
requiredNumPoints?: number;
|
||||
algorithm?: string;
|
||||
seasonality?: string;
|
||||
};
|
||||
evaluation?: {
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/ruler"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
@@ -14,343 +14,124 @@ import (
|
||||
)
|
||||
|
||||
func (provider *provider) addRulerRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v3/rules", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.rulerHandler.ListRulesV3, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "ListRulesV3",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "List alert rules (v3)",
|
||||
Description: "Returns a page of alert rules with their current evaluation state, trimmed to the fields the list page renders. Supports a filter DSL (`query`), a repeated `states` filter applied after the state overlay, sort (`updated_at`/`created_at`/`name`/`state`/`severity`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`). In the filter DSL, a non-reserved key is matched as a rule label directly (`team = infra`); a key that collides with a reserved keyword matches either interpretation (negative operators exclude both), and `labels.<key>` targets only the label. The response also carries the org's label pairs and the reserved filter keys for building filter suggestions.",
|
||||
RequestQuery: new(ruletypes.ListRulesParams),
|
||||
Response: new(ruletypes.ListableRules),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRule,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v3/rules", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListRulesV3), handler.OpenAPIDef{
|
||||
ID: "ListRulesV3",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "List alert rules (v3)",
|
||||
Description: "Returns a page of alert rules with their current evaluation state, trimmed to the fields the list page renders. Supports a filter DSL (`query`), a repeated `states` filter applied after the state overlay, sort (`updated_at`/`created_at`/`name`/`state`/`severity`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`). In the filter DSL, a non-reserved key is matched as a rule label directly (`team = infra`); a key that collides with a reserved keyword matches either interpretation (negative operators exclude both), and `labels.<key>` targets only the label. The response also carries the org's label pairs and the reserved filter keys for building filter suggestions.",
|
||||
RequestQuery: new(ruletypes.ListRulesParams),
|
||||
Response: new(ruletypes.ListableRules),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rules/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.rulerHandler.GetRuleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetRuleByID",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Get alert rule by ID",
|
||||
Description: "This endpoint returns an alert rule by ID",
|
||||
Response: new(ruletypes.Rule),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRule,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/rules/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.GetRuleByID), handler.OpenAPIDef{
|
||||
ID: "GetRuleByID",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Get alert rule by ID",
|
||||
Description: "This endpoint returns an alert rule by ID",
|
||||
Response: new(ruletypes.Rule),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rules", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.rulerHandler.CreateRule, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CreateRule",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Create alert rule",
|
||||
Description: "This endpoint creates a new alert rule",
|
||||
Request: new(ruletypes.PostableRule),
|
||||
RequestContentType: "application/json",
|
||||
RequestExamples: postableRuleExamples(),
|
||||
Response: new(ruletypes.Rule),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
SecuritySchemes: newScopedSecuritySchemes(append([]string{
|
||||
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbCreate),
|
||||
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbAttach),
|
||||
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbAttach),
|
||||
}, telemetryReadScopes()...)),
|
||||
},
|
||||
handler.WithResourceDefs(
|
||||
handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRule,
|
||||
Verb: coretypes.VerbCreate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.ResponseJSONPath("data.id"),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
},
|
||||
handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
SourceResource: coretypes.ResourceMetaResourceRule,
|
||||
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
|
||||
SourceSelector: coretypes.WildcardSelector,
|
||||
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
|
||||
TargetIDs: ruler.NewCreateRuleChannelAttachExtractor(provider.alertmanagerService),
|
||||
TargetSelector: coretypes.IDSelector,
|
||||
},
|
||||
handler.TelemetryResourceDef{
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: querybuilder.TelemetrySelector,
|
||||
Resources: ruler.NewRuleBodyTelemetryResources(),
|
||||
},
|
||||
),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/rules", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.CreateRule), handler.OpenAPIDef{
|
||||
ID: "CreateRule",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Create alert rule",
|
||||
Description: "This endpoint creates a new alert rule",
|
||||
Request: new(ruletypes.PostableRule),
|
||||
RequestContentType: "application/json",
|
||||
RequestExamples: postableRuleExamples(),
|
||||
Response: new(ruletypes.Rule),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rules/{id}/clone", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.rulerHandler.CloneRuleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CloneRuleByID",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Clone alert rule",
|
||||
Description: "This endpoint creates a new alert rule from the stored definition of an existing one. The clone keeps every modeled field of the source, including its enabled/disabled state, and gets the source's name suffixed with \" - Copy\" (or a bumped \" - Copy (n)\" counter when the source is itself a copy). The clone is owned by the caller.",
|
||||
Response: new(ruletypes.Rule),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newScopedSecuritySchemes(append([]string{
|
||||
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbRead),
|
||||
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbCreate),
|
||||
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbAttach),
|
||||
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbAttach),
|
||||
}, telemetryReadScopes()...)),
|
||||
},
|
||||
handler.WithResourceDefs(
|
||||
handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRule,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
},
|
||||
handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRule,
|
||||
Verb: coretypes.VerbCreate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.ResponseJSONPath("data.id"),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
},
|
||||
handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
SourceResource: coretypes.ResourceMetaResourceRule,
|
||||
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
|
||||
SourceSelector: coretypes.WildcardSelector,
|
||||
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
|
||||
TargetIDs: ruler.NewCloneRuleChannelAttachExtractor(provider.alertmanagerService, provider.rulerService),
|
||||
TargetSelector: coretypes.IDSelector,
|
||||
},
|
||||
handler.TelemetryResourceDef{
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: querybuilder.TelemetrySelector,
|
||||
Resources: ruler.NewRuleQueryTelemetryResources(provider.rulerService),
|
||||
},
|
||||
),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := router.Handle("/api/v2/rules/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.rulerHandler.UpdateRuleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "UpdateRuleByID",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Update alert rule",
|
||||
Description: "This endpoint updates an alert rule by ID",
|
||||
Request: new(ruletypes.PostableRule),
|
||||
RequestContentType: "application/json",
|
||||
RequestExamples: postableRuleExamples(),
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newScopedSecuritySchemes(append([]string{
|
||||
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbUpdate),
|
||||
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbAttach),
|
||||
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbDetach),
|
||||
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbAttach),
|
||||
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbDetach),
|
||||
}, telemetryReadScopes()...)),
|
||||
},
|
||||
handler.WithResourceDefs(
|
||||
handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRule,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
},
|
||||
handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
SourceResource: coretypes.ResourceMetaResourceRule,
|
||||
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
|
||||
TargetIDs: ruler.NewReplaceRuleChannelAttachExtractor(provider.alertmanagerService, provider.rulerService),
|
||||
TargetSelector: coretypes.IDSelector,
|
||||
},
|
||||
handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbDetach,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
SourceResource: coretypes.ResourceMetaResourceRule,
|
||||
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
|
||||
TargetIDs: ruler.NewReplaceRuleChannelDetachExtractor(provider.alertmanagerService, provider.rulerService),
|
||||
TargetSelector: coretypes.IDSelector,
|
||||
},
|
||||
handler.TelemetryResourceDef{
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: querybuilder.TelemetrySelector,
|
||||
Resources: ruler.NewRuleBodyTelemetryResources(),
|
||||
},
|
||||
),
|
||||
)).Methods(http.MethodPut).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/rules/{id}/clone", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.CloneRuleByID), handler.OpenAPIDef{
|
||||
ID: "CloneRuleByID",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Clone alert rule",
|
||||
Description: "This endpoint creates a new alert rule from the stored definition of an existing one. The clone keeps every modeled field of the source, including its enabled/disabled state, and gets the source's name suffixed with \" - Copy\" (or a bumped \" - Copy (n)\" counter when the source is itself a copy). The clone is owned by the caller.",
|
||||
Response: new(ruletypes.Rule),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rules/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.rulerHandler.DeleteRuleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "DeleteRuleByID",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Delete alert rule",
|
||||
Description: "This endpoint deletes an alert rule by ID",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbDelete)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRule,
|
||||
Verb: coretypes.VerbDelete,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/rules/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.UpdateRuleByID), handler.OpenAPIDef{
|
||||
ID: "UpdateRuleByID",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Update alert rule",
|
||||
Description: "This endpoint updates an alert rule by ID",
|
||||
Request: new(ruletypes.PostableRule),
|
||||
RequestContentType: "application/json",
|
||||
RequestExamples: postableRuleExamples(),
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rules/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.rulerHandler.PatchRuleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "PatchRuleByID",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Patch alert rule",
|
||||
Description: "This endpoint applies a partial update to an alert rule by ID",
|
||||
Request: new(ruletypes.PostableRule),
|
||||
RequestContentType: "application/json",
|
||||
RequestExamples: postableRuleExamples(),
|
||||
Response: new(ruletypes.Rule),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newScopedSecuritySchemes(append([]string{
|
||||
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbUpdate),
|
||||
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbAttach),
|
||||
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbDetach),
|
||||
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbAttach),
|
||||
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbDetach),
|
||||
}, telemetryReadScopes()...)),
|
||||
},
|
||||
handler.WithResourceDefs(
|
||||
handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRule,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
},
|
||||
handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
SourceResource: coretypes.ResourceMetaResourceRule,
|
||||
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
|
||||
TargetIDs: ruler.NewPatchRuleChannelAttachExtractor(provider.alertmanagerService, provider.rulerService),
|
||||
TargetSelector: coretypes.IDSelector,
|
||||
},
|
||||
handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbDetach,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
SourceResource: coretypes.ResourceMetaResourceRule,
|
||||
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
|
||||
TargetIDs: ruler.NewPatchRuleChannelDetachExtractor(provider.alertmanagerService, provider.rulerService),
|
||||
TargetSelector: coretypes.IDSelector,
|
||||
},
|
||||
handler.TelemetryResourceDef{
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: querybuilder.TelemetrySelector,
|
||||
Resources: ruler.NewOptionalRuleBodyTelemetryResources(),
|
||||
Optional: true,
|
||||
},
|
||||
),
|
||||
)).Methods(http.MethodPatch).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/rules/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.DeleteRuleByID), handler.OpenAPIDef{
|
||||
ID: "DeleteRuleByID",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Delete alert rule",
|
||||
Description: "This endpoint deletes an alert rule by ID",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rules/test", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.rulerHandler.TestRule, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "TestRule",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Test alert rule",
|
||||
Description: "This endpoint fires a test notification for the given rule definition",
|
||||
Request: new(ruletypes.PostableRule),
|
||||
RequestContentType: "application/json",
|
||||
RequestExamples: postableRuleExamples(),
|
||||
Response: new(ruletypes.GettableTestRule),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
SecuritySchemes: newScopedSecuritySchemes(append([]string{
|
||||
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbCreate),
|
||||
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbAttach),
|
||||
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbAttach),
|
||||
}, telemetryReadScopes()...)),
|
||||
},
|
||||
handler.WithResourceDefs(
|
||||
handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRule,
|
||||
Verb: coretypes.VerbCreate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
},
|
||||
handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
SourceResource: coretypes.ResourceMetaResourceRule,
|
||||
SourceSelector: coretypes.WildcardSelector,
|
||||
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
|
||||
TargetIDs: ruler.NewCreateRuleChannelAttachExtractor(provider.alertmanagerService),
|
||||
TargetSelector: coretypes.IDSelector,
|
||||
},
|
||||
handler.TelemetryResourceDef{
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: querybuilder.TelemetrySelector,
|
||||
Resources: ruler.NewRuleBodyTelemetryResources(),
|
||||
},
|
||||
),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/rules/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.PatchRuleByID), handler.OpenAPIDef{
|
||||
ID: "PatchRuleByID",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Patch alert rule",
|
||||
Description: "This endpoint applies a partial update to an alert rule by ID",
|
||||
Request: new(ruletypes.PostableRule),
|
||||
RequestContentType: "application/json",
|
||||
RequestExamples: postableRuleExamples(),
|
||||
Response: new(ruletypes.Rule),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPatch).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rules/test", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.TestRule), handler.OpenAPIDef{
|
||||
ID: "TestRule",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Test alert rule",
|
||||
Description: "This endpoint fires a test notification for the given rule definition",
|
||||
Request: new(ruletypes.PostableRule),
|
||||
RequestContentType: "application/json",
|
||||
RequestExamples: postableRuleExamples(),
|
||||
Response: new(ruletypes.GettableTestRule),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -251,6 +251,7 @@ func postableRuleExamples() []handler.OpenAPIExample {
|
||||
},
|
||||
},
|
||||
},
|
||||
"algorithm": "standard",
|
||||
"seasonality": "daily",
|
||||
"selectedQueryName": "A",
|
||||
"requireMinPoints": true,
|
||||
|
||||
@@ -5,23 +5,28 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestPostableRuleExamplesValidate verifies every example payload returned by
|
||||
// postableRuleExamples() round-trips through PostableRule.DecodeStrict and
|
||||
// postableRuleExamples() round-trips through PostableRule.UnmarshalJSON and
|
||||
// passes Validate(). If an example drifts from the runtime contract this
|
||||
// breaks loudly so the spec doesn't ship invalid payloads to users.
|
||||
func TestPostableRuleExamplesValidate(t *testing.T) {
|
||||
for _, example := range postableRuleExamples() {
|
||||
t.Run(example.Name, func(t *testing.T) {
|
||||
raw, err := json.Marshal(example.Value)
|
||||
require.NoError(t, err)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal example: %v", err)
|
||||
}
|
||||
|
||||
var rule ruletypes.PostableRule
|
||||
require.NoError(t, rule.DecodeStrict(string(raw)), "payload: %s", raw)
|
||||
assert.NoError(t, rule.Validate(), "payload: %s", raw)
|
||||
if err := json.Unmarshal(raw, &rule); err != nil {
|
||||
t.Fatalf("unmarshal: %v\npayload: %s", err, raw)
|
||||
}
|
||||
|
||||
if err := rule.Validate(); err != nil {
|
||||
t.Fatalf("Validate: %v\npayload: %s", err, raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,13 +68,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: []handler.OpenAPISecurityScheme{{Name: authtypes.IdentNProviderTokenizer.StringValue()}},
|
||||
}, handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceUser,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: provider.userIDExtractor(),
|
||||
Selector: coretypes.IDSelector,
|
||||
}))).Methods(http.MethodGet).GetError(); err != nil {
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -134,13 +128,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: []handler.OpenAPISecurityScheme{{Name: authtypes.IdentNProviderTokenizer.StringValue()}},
|
||||
}, handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceUser,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: provider.userIDExtractor(),
|
||||
Selector: coretypes.IDSelector,
|
||||
}))).Methods(http.MethodPut).GetError(); err != nil {
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -306,12 +294,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: []handler.OpenAPISecurityScheme{},
|
||||
}, handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceFactorPassword,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}))).Methods(http.MethodPost).GetError(); err != nil {
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -328,12 +311,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: []handler.OpenAPISecurityScheme{{Name: authtypes.IdentNProviderTokenizer.StringValue()}},
|
||||
}, handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceFactorPassword,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}))).Methods(http.MethodPut).GetError(); err != nil {
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -350,12 +328,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnprocessableEntity},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: []handler.OpenAPISecurityScheme{},
|
||||
}, handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceFactorPassword,
|
||||
Verb: coretypes.VerbCreate,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}))).Methods(http.MethodPost).GetError(); err != nil {
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -372,12 +345,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: []handler.OpenAPISecurityScheme{},
|
||||
}, handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceFactorPassword,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}))).Methods(http.MethodPost).GetError(); err != nil {
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -525,21 +493,6 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) userIDExtractor() coretypes.ResourceIDExtractor {
|
||||
return coretypes.NewResourceIDExtractor(coretypes.PhaseRequest, func(ec coretypes.ExtractorContext) (string, error) {
|
||||
if ec.Request == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ec.Request.Context())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return claims.UserID, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (provider *provider) userRoleUserIDExtractor() coretypes.ResourceIDExtractor {
|
||||
return coretypes.NewResourceIDExtractor(coretypes.PhaseRequest, func(ec coretypes.ExtractorContext) (string, error) {
|
||||
if ec.Request == nil {
|
||||
|
||||
@@ -109,8 +109,6 @@ type TelemetryResourceDef struct {
|
||||
Category coretypes.ActionCategory
|
||||
Selector coretypes.SelectorFunc
|
||||
Resources coretypes.ResourceExtractor
|
||||
// Optional skips the check instead of failing when the request carries no telemetry resources.
|
||||
Optional bool
|
||||
}
|
||||
|
||||
func (def TelemetryResourceDef) resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource {
|
||||
@@ -118,9 +116,6 @@ func (def TelemetryResourceDef) resolveRequest(ec coretypes.ExtractorContext) []
|
||||
if err != nil {
|
||||
return []coretypes.ResolvedResource{coretypes.NewResolvedResourceWithError(def.Verb, def.Category, err)}
|
||||
}
|
||||
if len(refs) == 0 && def.Optional {
|
||||
return nil
|
||||
}
|
||||
if len(refs) == 0 {
|
||||
return []coretypes.ResolvedResource{coretypes.NewResolvedResourceWithError(
|
||||
def.Verb,
|
||||
|
||||
@@ -69,43 +69,3 @@ func TestAttachDetachSiblingResourceDefKeepsEmptySingleID(t *testing.T) {
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, []string{""}, withTarget.TargetIDs())
|
||||
}
|
||||
|
||||
func TestTelemetryResourceDefWithoutResources(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
optional bool
|
||||
wantLen int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "Required_Errors",
|
||||
optional: false,
|
||||
wantLen: 1,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "Optional_ResolvesNothing",
|
||||
optional: true,
|
||||
wantLen: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
def := TelemetryResourceDef{
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
Resources: func(coretypes.ExtractorContext) ([]coretypes.ResourceWithID, error) { return nil, nil },
|
||||
Optional: testCase.optional,
|
||||
}
|
||||
|
||||
resolved := ResolveRequest([]ResourceDef{def}, coretypes.ExtractorContext{})
|
||||
|
||||
require.Len(t, resolved, testCase.wantLen)
|
||||
if testCase.wantErr {
|
||||
assert.Error(t, resolved[0].Err())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2688,7 +2688,19 @@ func (aH *APIHandler) PreviewLogsPipelinesHandler(w http.ResponseWriter, r *http
|
||||
return
|
||||
}
|
||||
|
||||
resultLogs, err := aH.LogsParsingPipelineController.PreviewLogsPipelines(r.Context(), &req)
|
||||
claims, errv2 := authtypes.ClaimsFromContext(r.Context())
|
||||
if errv2 != nil {
|
||||
render.Error(w, errv2)
|
||||
return
|
||||
}
|
||||
|
||||
orgID, errv2 := valuer.NewUUID(claims.OrgID)
|
||||
if errv2 != nil {
|
||||
render.Error(w, errv2)
|
||||
return
|
||||
}
|
||||
|
||||
resultLogs, err := aH.LogsParsingPipelineController.PreviewLogsPipelines(r.Context(), orgID, &req)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
|
||||
@@ -165,6 +165,22 @@ func isNormalizerProcessor(name string) bool {
|
||||
return name == normalizerProcessor || strings.HasPrefix(name, normalizerProcessorPrefix)
|
||||
}
|
||||
|
||||
// collectorNormalizesFirst reports whether the logs pipeline of the collector config runs
|
||||
// signozlogsnormalizer ahead of user pipelines, judged by the first one as in buildCollectorPipelineProcessorsList.
|
||||
// Malformed configs report false; generateCollectorConfigWithPipelines surfaces their errors.
|
||||
func collectorNormalizesFirst(collectorConf map[string]interface{}) bool {
|
||||
p, err := getOtelPipelineFromConfig(collectorConf)
|
||||
if err != nil || p.Pipelines.Logs == nil {
|
||||
return false
|
||||
}
|
||||
normalizerIdx := slices.IndexFunc(p.Pipelines.Logs.Processors, isNormalizerProcessor)
|
||||
if normalizerIdx < 0 {
|
||||
return false
|
||||
}
|
||||
processorConfigs, _ := collectorConf["processors"].(map[string]interface{})
|
||||
return opampPosition(processorConfigs, p.Pipelines.Logs.Processors[normalizerIdx]) == normalizerBeforeUserPipelines
|
||||
}
|
||||
|
||||
func opampPosition(processorConfigs map[string]interface{}, name string) string {
|
||||
config, _ := processorConfigs[name].(map[string]interface{})
|
||||
opamp, _ := config["opamp"].(map[string]interface{})
|
||||
|
||||
@@ -5,10 +5,12 @@ import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/constants"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -499,3 +501,61 @@ func TestPipeCharInAliasDoesntBreakCollectorConfig(t *testing.T) {
|
||||
}, result[0].Attributes_string,
|
||||
)
|
||||
}
|
||||
|
||||
func TestRecommendAgentConfigWithNormalizerProcessorAndJSONBody(t *testing.T) {
|
||||
userPipeline := makeTestAddAttributePipeline()
|
||||
controller := &LogParsingPipelineController{
|
||||
fl: flaggertest.WithUseJSONBody(t, true),
|
||||
GetIntegrationPipelines: func(context.Context, string) ([]pipelinetypes.GettablePipeline, error) {
|
||||
return []pipelinetypes.GettablePipeline{userPipeline}, nil
|
||||
},
|
||||
}
|
||||
normalizeOperator := CollectorConfProcessorName(getNormalizePipeline())
|
||||
user := CollectorConfProcessorName(userPipeline)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
processors string
|
||||
logs string
|
||||
expected []any
|
||||
}{
|
||||
{
|
||||
name: "NoNormalizer_OperatorPrepended",
|
||||
logs: "[memory_limiter, batch]",
|
||||
expected: []any{"memory_limiter", normalizeOperator, user, "batch"},
|
||||
},
|
||||
{
|
||||
name: "NormalizerDefault_OperatorKept",
|
||||
logs: "[memory_limiter, signozlogsnormalizer, batch]",
|
||||
expected: []any{"memory_limiter", normalizeOperator, user, "signozlogsnormalizer", "batch"},
|
||||
},
|
||||
{
|
||||
name: "NormalizerBefore_OperatorDropped",
|
||||
processors: "signozlogsnormalizer:\n opamp:\n position: before\n",
|
||||
logs: "[memory_limiter, signozlogsnormalizer, batch]",
|
||||
expected: []any{"memory_limiter", "signozlogsnormalizer", user, "batch"},
|
||||
},
|
||||
{
|
||||
name: "NormalizerAfter_OperatorKept",
|
||||
processors: "signozlogsnormalizer:\n opamp:\n position: after\n",
|
||||
logs: "[memory_limiter, signozlogsnormalizer, batch]",
|
||||
expected: []any{"memory_limiter", normalizeOperator, user, "signozlogsnormalizer", "batch"},
|
||||
},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
conf := fmt.Sprintf(
|
||||
"processors:\n memory_limiter: {}\n batch: {}\n %s\nservice:\n pipelines:\n logs:\n receivers: [otlp]\n processors: %s\n exporters: [clickhouselogsexporter]\n",
|
||||
testCase.processors, testCase.logs,
|
||||
)
|
||||
|
||||
recommended, _, err := controller.RecommendAgentConfig(valuer.GenerateUUID(), []byte(conf), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
var recommendedConf map[string]any
|
||||
require.NoError(t, yaml.Unmarshal(recommended, &recommendedConf))
|
||||
logs := recommendedConf["service"].(map[string]any)["pipelines"].(map[string]any)["logs"].(map[string]any)
|
||||
assert.Equal(t, testCase.expected, logs["processors"])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/constants"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/interfaces"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/query-service/utils"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/opamptypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -37,18 +39,21 @@ type LogParsingPipelineController struct {
|
||||
GetIntegrationPipelines func(context.Context, string) ([]pipelinetypes.GettablePipeline, error)
|
||||
// TODO(Piyush): remove with qbv5 migration
|
||||
reader interfaces.Reader
|
||||
fl flagger.Flagger
|
||||
}
|
||||
|
||||
func NewLogParsingPipelinesController(
|
||||
sqlStore sqlstore.SQLStore,
|
||||
getIntegrationPipelines func(context.Context, string) ([]pipelinetypes.GettablePipeline, error),
|
||||
reader interfaces.Reader,
|
||||
fl flagger.Flagger,
|
||||
) (*LogParsingPipelineController, error) {
|
||||
repo := NewRepo(sqlStore)
|
||||
return &LogParsingPipelineController{
|
||||
Repo: repo,
|
||||
GetIntegrationPipelines: getIntegrationPipelines,
|
||||
reader: reader,
|
||||
fl: fl,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -215,6 +220,44 @@ func (ic *LogParsingPipelineController) ValidatePipelines(ctx context.Context,
|
||||
return err
|
||||
}
|
||||
|
||||
// withNormalizePipeline places normalize ahead of user pipelines when queries run on body_v2
|
||||
// (use_json_body), so operators see the body the explorer shows. Absent when the flag is off
|
||||
// or collectorNormalizesFirst.
|
||||
func (ic *LogParsingPipelineController) withNormalizePipeline(ctx context.Context, orgID valuer.UUID, pipelines []pipelinetypes.GettablePipeline, collectorNormalizesFirst bool) []pipelinetypes.GettablePipeline {
|
||||
if collectorNormalizesFirst || !ic.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgID)) {
|
||||
return pipelines
|
||||
}
|
||||
return append([]pipelinetypes.GettablePipeline{getNormalizePipeline()}, pipelines...)
|
||||
}
|
||||
|
||||
func getNormalizePipeline() pipelinetypes.GettablePipeline {
|
||||
return pipelinetypes.GettablePipeline{
|
||||
StoreablePipeline: pipelinetypes.StoreablePipeline{
|
||||
Name: "Default Pipeline - PreProcessing Body",
|
||||
Alias: "NormalizeBodyDefault",
|
||||
Enabled: true,
|
||||
},
|
||||
Filter: &v3.FilterSet{
|
||||
Items: []v3.FilterItem{
|
||||
{
|
||||
Key: v3.AttributeKey{
|
||||
Key: "body",
|
||||
},
|
||||
Operator: v3.FilterOperatorExists,
|
||||
},
|
||||
},
|
||||
},
|
||||
Config: []pipelinetypes.PipelineOperator{
|
||||
{
|
||||
ID: uuid.NewString(),
|
||||
Type: "normalize",
|
||||
Enabled: true,
|
||||
If: "body != nil",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Returns effective list of pipelines including user created
|
||||
// pipelines and pipelines for installed integrations
|
||||
func (ic *LogParsingPipelineController) getEffectivePipelinesByVersion(
|
||||
@@ -309,6 +352,7 @@ type PipelinesPreviewResponse struct {
|
||||
|
||||
func (ic *LogParsingPipelineController) PreviewLogsPipelines(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
request *PipelinesPreviewRequest,
|
||||
) (*PipelinesPreviewResponse, error) {
|
||||
pipelines, err := ic.enrichPipelinesFilters(ctx, request.Pipelines)
|
||||
@@ -316,6 +360,11 @@ func (ic *LogParsingPipelineController) PreviewLogsPipelines(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The collector gets the same pipeline prepended over opamp; see RecommendAgentConfig.
|
||||
if ic.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgID)) {
|
||||
pipelines = append([]pipelinetypes.GettablePipeline{getNormalizePipeline()}, pipelines...)
|
||||
}
|
||||
|
||||
result, collectorLogs, err := SimulatePipelinesProcessing(ctx, pipelines, request.Logs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -334,13 +383,16 @@ func (pc *LogParsingPipelineController) AgentFeatureType() agentConf.AgentFeatur
|
||||
|
||||
// Implements agentConf.AgentFeature interface.
|
||||
// RecommendAgentConfig generates the collector config to be sent to agents.
|
||||
// The normalize pipeline (when use_json_body is on) is placed
|
||||
// here, after rawPipelineData is serialized. So it is only present in the config sent to
|
||||
// the collector and never persisted to the database as part of the user's pipeline list.
|
||||
//
|
||||
// NOTE: The configId sent to agents is derived from the pipeline version number
|
||||
// (e.g. "LogPipelines:5"), not the YAML content. If server-side logic changes
|
||||
// the generated YAML without bumping the version (e.g. updating operator IfExpressions),
|
||||
// agents that already applied that version will not re-apply the new config. In such
|
||||
// cases, users must save a new pipeline version via the API to force agents to pick up
|
||||
// the change.
|
||||
// the generated YAML without bumping the version (e.g. toggling the use_json_body
|
||||
// flag or updating operator IfExpressions), agents that already
|
||||
// applied that version will not re-apply the new config. In such cases, users must save a
|
||||
// new pipeline version via the API to force agents to pick up the change.
|
||||
func (pc *LogParsingPipelineController) RecommendAgentConfig(
|
||||
orgId valuer.UUID,
|
||||
currentConfYaml []byte,
|
||||
@@ -366,7 +418,15 @@ func (pc *LogParsingPipelineController) RecommendAgentConfig(
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
updatedConf, err := GenerateCollectorConfigWithPipelines(currentConfYaml, enrichedPipelines)
|
||||
collectorConf, err := unmarshalCollectorConfig(currentConfYaml)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
// normalize is only for sending to the collector, never persisted
|
||||
enrichedPipelines = pc.withNormalizePipeline(ctx, orgId, enrichedPipelines, collectorNormalizesFirst(collectorConf))
|
||||
|
||||
updatedConf, err := generateCollectorConfigWithPipelines(collectorConf, enrichedPipelines)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/google/uuid"
|
||||
"github.com/open-telemetry/opentelemetry-collector-contrib/pkg/stanza/entry"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -117,11 +119,42 @@ func TestPipelinePreview(t *testing.T) {
|
||||
|
||||
}
|
||||
|
||||
func TestPipelinePreviewKeepsBodyAsIs(t *testing.T) {
|
||||
controller := &LogParsingPipelineController{}
|
||||
func TestPipelinePreviewNormalizesBodyWithJSONBodyEnabled(t *testing.T) {
|
||||
controller := &LogParsingPipelineController{fl: flaggertest.WithUseJSONBody(t, true)}
|
||||
|
||||
result, err := controller.PreviewLogsPipelines(
|
||||
context.Background(),
|
||||
valuer.GenerateUUID(),
|
||||
&PipelinesPreviewRequest{
|
||||
Pipelines: []pipelinetypes.GettablePipeline{makeTestAddAttributePipeline()},
|
||||
Logs: []model.SignozLog{
|
||||
makeTestSignozLog("test log body", map[string]interface{}{"method": "GET"}),
|
||||
makeTestSignozLog(
|
||||
`{"level":"error","msg":"json log body"}`,
|
||||
map[string]interface{}{"method": "GET"},
|
||||
),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result.OutputLogs, 2)
|
||||
|
||||
assert.Equal(t, `{"message":"test log body"}`, result.OutputLogs[0].Body)
|
||||
assert.Equal(
|
||||
t,
|
||||
`{"level":"error","message":"json log body"}`,
|
||||
result.OutputLogs[1].Body,
|
||||
)
|
||||
assert.Equal(t, "val", result.OutputLogs[0].Attributes_string["test"])
|
||||
}
|
||||
|
||||
func TestPipelinePreviewKeepsBodyAsIsWithJSONBodyDisabled(t *testing.T) {
|
||||
controller := &LogParsingPipelineController{fl: flaggertest.WithUseJSONBody(t, false)}
|
||||
|
||||
result, err := controller.PreviewLogsPipelines(
|
||||
context.Background(),
|
||||
valuer.GenerateUUID(),
|
||||
&PipelinesPreviewRequest{
|
||||
Pipelines: []pipelinetypes.GettablePipeline{makeTestAddAttributePipeline()},
|
||||
Logs: []model.SignozLog{
|
||||
|
||||
@@ -46,6 +46,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
signoz.SQLStore,
|
||||
integrationsController.GetPipelinesForInstalledIntegrations,
|
||||
reader,
|
||||
signoz.Flagger,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -1054,24 +1054,31 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
storedRule, err := ruletypes.NewPatchedPostableRule([]byte(storedJSON.Data), []byte(ruleStr))
|
||||
if err != nil {
|
||||
storedRule := ruletypes.PostableRule{}
|
||||
if err := json.Unmarshal([]byte(storedJSON.Data), &storedRule); err != nil {
|
||||
m.logger.ErrorContext(ctx, "failed to unmarshal rule from db", slog.String("rule.id", id.StringValue()), errors.Attr(err))
|
||||
return nil, err
|
||||
}
|
||||
// a rule stored before selectedQueryName was required resolves it the way evaluation does
|
||||
storedRule.RuleCondition.SelectedQuery = storedRule.RuleCondition.SelectedQueryName()
|
||||
|
||||
if err := storedRule.DecodeStrict(ruleStr); err != nil {
|
||||
m.logger.ErrorContext(ctx, "failed to unmarshal patched rule with given id", slog.String("rule.id", id.StringValue()), errors.Attr(err))
|
||||
return nil, err
|
||||
}
|
||||
if err := storedRule.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.validateChannels(ctx, claims.OrgID, storedRule); err != nil {
|
||||
if err := m.validateChannels(ctx, claims.OrgID, &storedRule); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// deploy or un-deploy task according to patched (new) rule state
|
||||
if err := m.syncRuleStateWithTask(ctx, orgID, taskName, storedRule); err != nil {
|
||||
if err := m.syncRuleStateWithTask(ctx, orgID, taskName, &storedRule); err != nil {
|
||||
m.logger.ErrorContext(ctx, "failed to sync stored rule state with the task", slog.String("task.name", taskName), errors.Attr(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newStoredJson, err := json.Marshal(storedRule)
|
||||
newStoredJson, err := json.Marshal(&storedRule)
|
||||
if err != nil {
|
||||
m.logger.ErrorContext(ctx, "failed to marshal new stored rule with given id", slog.String("rule.id", id.StringValue()), errors.Attr(err))
|
||||
return nil, err
|
||||
@@ -1084,7 +1091,7 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID)
|
||||
|
||||
err = m.ruleStore.EditRule(ctx, storedJSON, func(ctx context.Context) error { return nil })
|
||||
if err != nil {
|
||||
if err := m.syncRuleStateWithTask(ctx, orgID, taskName, storedRule); err != nil {
|
||||
if err := m.syncRuleStateWithTask(ctx, orgID, taskName, &storedRule); err != nil {
|
||||
m.logger.ErrorContext(ctx, "failed to restore rule after patch failure", "task_name", taskName, errors.Attr(err))
|
||||
}
|
||||
return nil, err
|
||||
@@ -1093,7 +1100,7 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID)
|
||||
// prepare http response
|
||||
response := ruletypes.GettableRule{
|
||||
Id: id.StringValue(),
|
||||
PostableRule: *storedRule,
|
||||
PostableRule: storedRule,
|
||||
}
|
||||
|
||||
// fetch state of rule from memory
|
||||
|
||||
@@ -5,15 +5,12 @@ import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -23,8 +20,6 @@ const (
|
||||
|
||||
type diffSide int
|
||||
|
||||
type incomingChannelsFunc func(stored *ruletypes.GettableRule, body []byte) ([]string, error)
|
||||
|
||||
var DowntimeRuleIDsExtractor = coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
|
||||
ids, err := coretypes.BodyJSONArray("alertIds").Fn(ec)
|
||||
if err != nil {
|
||||
@@ -86,126 +81,6 @@ func newDowntimeDiffExtractor(rulerService Ruler, side diffSide) coretypes.Resou
|
||||
}}
|
||||
}
|
||||
|
||||
func NewCreateRuleChannelAttachExtractor(alertmanagerService alertmanager.Alertmanager) coretypes.ResourceIDsExtractor {
|
||||
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
|
||||
if ec.Request == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ctx := ec.Request.Context()
|
||||
orgID, err := orgIDFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
names, err := parseRuleChannels(ec.RequestBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return resolveKnownChannelIDs(ctx, alertmanagerService, orgID, names)
|
||||
}}
|
||||
}
|
||||
|
||||
func NewCloneRuleChannelAttachExtractor(alertmanagerService alertmanager.Alertmanager, rulerService Ruler) coretypes.ResourceIDsExtractor {
|
||||
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
|
||||
if ec.Request == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ctx := ec.Request.Context()
|
||||
orgID, err := orgIDFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stored, err := fetchStoredRule(ec, rulerService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return resolveKnownChannelIDs(ctx, alertmanagerService, orgID, stored.Channels())
|
||||
}}
|
||||
}
|
||||
|
||||
func NewReplaceRuleChannelAttachExtractor(alertmanagerService alertmanager.Alertmanager, rulerService Ruler) coretypes.ResourceIDsExtractor {
|
||||
return newRuleChannelDiffExtractor(alertmanagerService, rulerService, parseReplacedRuleChannels, attachedSide)
|
||||
}
|
||||
|
||||
func NewReplaceRuleChannelDetachExtractor(alertmanagerService alertmanager.Alertmanager, rulerService Ruler) coretypes.ResourceIDsExtractor {
|
||||
return newRuleChannelDiffExtractor(alertmanagerService, rulerService, parseReplacedRuleChannels, detachedSide)
|
||||
}
|
||||
|
||||
func NewPatchRuleChannelAttachExtractor(alertmanagerService alertmanager.Alertmanager, rulerService Ruler) coretypes.ResourceIDsExtractor {
|
||||
return newRuleChannelDiffExtractor(alertmanagerService, rulerService, parsePatchedRuleChannels, attachedSide)
|
||||
}
|
||||
|
||||
func NewPatchRuleChannelDetachExtractor(alertmanagerService alertmanager.Alertmanager, rulerService Ruler) coretypes.ResourceIDsExtractor {
|
||||
return newRuleChannelDiffExtractor(alertmanagerService, rulerService, parsePatchedRuleChannels, detachedSide)
|
||||
}
|
||||
|
||||
func newRuleChannelDiffExtractor(alertmanagerService alertmanager.Alertmanager, rulerService Ruler, incoming incomingChannelsFunc, side diffSide) coretypes.ResourceIDsExtractor {
|
||||
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
|
||||
if ec.Request == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ctx := ec.Request.Context()
|
||||
orgID, err := orgIDFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stored, err := fetchStoredRule(ec, rulerService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
incomingNames, err := incoming(stored, ec.RequestBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
attached, detached := diffLinks(stored.Channels(), incomingNames)
|
||||
if side == detachedSide {
|
||||
return resolveKnownChannelIDs(ctx, alertmanagerService, orgID, detached)
|
||||
}
|
||||
|
||||
return resolveKnownChannelIDs(ctx, alertmanagerService, orgID, attached)
|
||||
}}
|
||||
}
|
||||
|
||||
func parseReplacedRuleChannels(_ *ruletypes.GettableRule, body []byte) ([]string, error) {
|
||||
return parseRuleChannels(body)
|
||||
}
|
||||
|
||||
func parsePatchedRuleChannels(stored *ruletypes.GettableRule, body []byte) ([]string, error) {
|
||||
patched, err := applyRulePatch(stored, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return patched.Channels(), nil
|
||||
}
|
||||
|
||||
func applyRulePatch(stored *ruletypes.GettableRule, body []byte) (*ruletypes.PostableRule, error) {
|
||||
encoded, err := json.Marshal(stored.PostableRule)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ruletypes.NewPatchedPostableRule(encoded, body)
|
||||
}
|
||||
|
||||
func parseRuleChannels(body []byte) ([]string, error) {
|
||||
rule := new(ruletypes.PostableRule)
|
||||
if err := rule.DecodeStrict(string(body)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return rule.Channels(), nil
|
||||
}
|
||||
|
||||
func diffDowntimeRuleLinks(stored, incoming []string) (attached, detached []string) {
|
||||
return diffLinks(defaultToWildcardIfEmpty(stored), incoming)
|
||||
}
|
||||
@@ -278,20 +153,6 @@ func newRoutePolicyDiffExtractor(alertmanagerService alertmanager.Alertmanager,
|
||||
}}
|
||||
}
|
||||
|
||||
// resolveKnownChannelIDs skips unknown names, the handler rejects them with a clearer error.
|
||||
func resolveKnownChannelIDs(ctx context.Context, alertmanagerService alertmanager.Alertmanager, orgID valuer.UUID, names []string) ([]string, error) {
|
||||
if len(names) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
channels, err := alertmanagerService.ListChannels(ctx, orgID.StringValue())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return alertmanagertypes.MapKnownChannelNamesToIDs(channels, names), nil
|
||||
}
|
||||
|
||||
func orgIDFromContext(ctx context.Context) (valuer.UUID, error) {
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
@@ -323,47 +184,17 @@ func NewRuleQueryFieldResources(rulerService Ruler) coretypes.ResourceExtractor
|
||||
}
|
||||
}
|
||||
|
||||
func NewRuleBodyTelemetryResources() coretypes.ResourceExtractor {
|
||||
return func(ec coretypes.ExtractorContext) ([]coretypes.ResourceWithID, error) {
|
||||
compositeQuery := gjson.GetBytes(ec.RequestBody, "condition.compositeQuery")
|
||||
if !compositeQuery.Exists() {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.compositeQuery is required")
|
||||
}
|
||||
|
||||
body, err := json.Marshal(map[string]json.RawMessage{"compositeQuery": json.RawMessage(compositeQuery.Raw)})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return querybuilder.QueryRangeResources(coretypes.ExtractorContext{Request: ec.Request, RequestBody: body})
|
||||
}
|
||||
}
|
||||
|
||||
func NewOptionalRuleBodyTelemetryResources() coretypes.ResourceExtractor {
|
||||
return func(ec coretypes.ExtractorContext) ([]coretypes.ResourceWithID, error) {
|
||||
if !gjson.GetBytes(ec.RequestBody, "condition.compositeQuery").Exists() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return NewRuleBodyTelemetryResources()(ec)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchStoredRule(ec coretypes.ExtractorContext, rulerService Ruler) (*ruletypes.GettableRule, error) {
|
||||
ruleID, err := valuer.NewUUID(mux.Vars(ec.Request)["id"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return rulerService.GetRule(ec.Request.Context(), ruleID)
|
||||
}
|
||||
|
||||
func fetchStoredCompositeQuery(ec coretypes.ExtractorContext, rulerService Ruler) ([]byte, error) {
|
||||
if ec.Request == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
stored, err := fetchStoredRule(ec, rulerService)
|
||||
ruleID, err := valuer.NewUUID(mux.Vars(ec.Request)["id"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stored, err := rulerService.GetRule(ec.Request.Context(), ruleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
package ruler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertest"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes/alertmanagertypestest"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -320,309 +314,3 @@ func TestMapFieldResources(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func v2RuleJSON(channels string) string {
|
||||
return `{
|
||||
"alert": "TestAlert",
|
||||
"version": "v5",
|
||||
"schemaVersion": "v2alpha1",
|
||||
"ruleType": "threshold_rule",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "cpu", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
}]
|
||||
},
|
||||
"thresholds": {
|
||||
"kind": "basic",
|
||||
"spec": [{"name": "critical", "target": 90.0, "matchType": "1", "op": "1", "channels": ` + channels + `}]
|
||||
}
|
||||
},
|
||||
"evaluation": {
|
||||
"kind": "rolling",
|
||||
"spec": {"evalWindow": "5m", "frequency": "1m"}
|
||||
},
|
||||
"notificationSettings": {
|
||||
"renotify": {"enabled": true, "interval": "4h", "alertStates": ["firing"]}
|
||||
}
|
||||
}`
|
||||
}
|
||||
|
||||
func TestParseRuleChannels(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantChannels []string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "V2ThresholdChannels_ReadDirectly",
|
||||
body: v2RuleJSON(`["email"]`),
|
||||
wantChannels: []string{"email"},
|
||||
},
|
||||
{
|
||||
name: "V2EmptyChannels_Empty",
|
||||
body: v2RuleJSON(`[]`),
|
||||
wantChannels: nil,
|
||||
},
|
||||
{
|
||||
name: "NotJSON_InvalidInput",
|
||||
body: "not json",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "TruncatedJSON_InvalidInput",
|
||||
body: `{"alert": `,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "UnknownField_InvalidInput",
|
||||
body: `{"preferredChannels": ["pager"]}`,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
channels, err := parseRuleChannels([]byte(testCase.body))
|
||||
|
||||
if testCase.wantErr {
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Ast(err, errors.TypeInvalidInput), err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testCase.wantChannels, channels)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncomingRuleChannels(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
incoming incomingChannelsFunc
|
||||
stored string
|
||||
body string
|
||||
wantChannels []string
|
||||
wantStoredAfter []string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "Replaced_IgnoresStored",
|
||||
incoming: parseReplacedRuleChannels,
|
||||
stored: v2RuleJSON(`["slack"]`),
|
||||
body: v2RuleJSON(`["pager"]`),
|
||||
wantChannels: []string{"pager"},
|
||||
wantStoredAfter: []string{"slack"},
|
||||
},
|
||||
{
|
||||
name: "Patched_OmitsCondition_StoredChannelsSurvive",
|
||||
incoming: parsePatchedRuleChannels,
|
||||
stored: v2RuleJSON(`["slack"]`),
|
||||
body: `{"disabled": true}`,
|
||||
wantChannels: []string{"slack"},
|
||||
wantStoredAfter: []string{"slack"},
|
||||
},
|
||||
{
|
||||
name: "Patched_SetsChannels_StoredChannelsReplaced",
|
||||
incoming: parsePatchedRuleChannels,
|
||||
stored: v2RuleJSON(`["slack"]`),
|
||||
body: v2RuleJSON(`["pager"]`),
|
||||
wantChannels: []string{"pager"},
|
||||
wantStoredAfter: []string{"slack"},
|
||||
},
|
||||
{
|
||||
name: "Patched_EmptiesChannels_NoChannelsLeft",
|
||||
incoming: parsePatchedRuleChannels,
|
||||
stored: v2RuleJSON(`["slack"]`),
|
||||
body: v2RuleJSON(`[]`),
|
||||
wantChannels: nil,
|
||||
wantStoredAfter: []string{"slack"},
|
||||
},
|
||||
{
|
||||
name: "Patched_UnknownField_Errors",
|
||||
incoming: parsePatchedRuleChannels,
|
||||
stored: v2RuleJSON(`["slack"]`),
|
||||
body: `{"preferredChannels": ["pager"]}`,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
stored := new(ruletypes.GettableRule)
|
||||
require.NoError(t, json.Unmarshal([]byte(testCase.stored), &stored.PostableRule))
|
||||
|
||||
channels, err := testCase.incoming(stored, []byte(testCase.body))
|
||||
|
||||
if testCase.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testCase.wantChannels, channels)
|
||||
assert.Equal(t, testCase.wantStoredAfter, stored.Channels())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleBodyTelemetryResources(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantKind coretypes.Kind
|
||||
wantID string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "V5Body_NoFilter_MetricsWildcard",
|
||||
body: v2RuleJSON(`[]`),
|
||||
wantKind: coretypes.KindMetrics,
|
||||
wantID: "builder_query/*",
|
||||
},
|
||||
{
|
||||
name: "MissingCondition_Errors",
|
||||
body: `{"alert": "TestAlert", "version": "v5"}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "NotJSON_Errors",
|
||||
body: "not json",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
refs, err := NewRuleBodyTelemetryResources()(coretypes.ExtractorContext{RequestBody: []byte(testCase.body)})
|
||||
|
||||
if testCase.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, refs, 1)
|
||||
assert.Equal(t, testCase.wantKind, refs[0].Resource.Kind())
|
||||
assert.Equal(t, testCase.wantID, refs[0].ID)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRulePatch(t *testing.T) {
|
||||
logsQuery := `{"condition": {"compositeQuery": {"queryType": "builder", "queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "stepInterval": "5m"}}]}}}`
|
||||
testCases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantSignal string
|
||||
wantChannels []string
|
||||
}{
|
||||
{
|
||||
name: "BodyWithoutCondition_KeepsStoredQueryAndChannels",
|
||||
body: `{"disabled": true}`,
|
||||
wantSignal: "metrics",
|
||||
wantChannels: []string{"slack"},
|
||||
},
|
||||
{
|
||||
name: "BodyWithNewQuery_ReplacesSignalKeepsThresholds",
|
||||
body: logsQuery,
|
||||
wantSignal: "logs",
|
||||
wantChannels: []string{"slack"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
stored := new(ruletypes.GettableRule)
|
||||
require.NoError(t, json.Unmarshal([]byte(v2RuleJSON(`["slack"]`)), &stored.PostableRule))
|
||||
|
||||
patched, err := applyRulePatch(stored, []byte(testCase.body))
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, patched.RuleCondition)
|
||||
require.NotNil(t, patched.RuleCondition.CompositeQuery)
|
||||
encoded, err := json.Marshal(patched.RuleCondition.CompositeQuery)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(encoded), `"signal":"`+testCase.wantSignal+`"`)
|
||||
assert.Equal(t, testCase.wantChannels, patched.Channels())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionalRuleBodyTelemetryResources(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantRefs int
|
||||
}{
|
||||
{
|
||||
name: "BodyWithoutCondition_NoResources",
|
||||
body: `{"disabled": true}`,
|
||||
wantRefs: 0,
|
||||
},
|
||||
{
|
||||
name: "BodyWithQuery_MetricsResource",
|
||||
body: v2RuleJSON(`[]`),
|
||||
wantRefs: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
refs, err := NewOptionalRuleBodyTelemetryResources()(coretypes.ExtractorContext{RequestBody: []byte(testCase.body)})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, refs, testCase.wantRefs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveKnownChannelIDs(t *testing.T) {
|
||||
channels := []*alertmanagertypes.Channel{
|
||||
{Identifiable: types.Identifiable{ID: valuer.GenerateUUID()}, Name: "slack", DisplayName: "slack"},
|
||||
}
|
||||
testCases := []struct {
|
||||
name string
|
||||
names []string
|
||||
wantIDs []string
|
||||
}{
|
||||
{
|
||||
name: "KnownName_Resolved",
|
||||
names: []string{"slack"},
|
||||
wantIDs: []string{channels[0].ID.StringValue()},
|
||||
},
|
||||
{
|
||||
name: "UnknownName_Skipped",
|
||||
names: []string{"slack", "gone"},
|
||||
wantIDs: []string{channels[0].ID.StringValue()},
|
||||
},
|
||||
{
|
||||
name: "NoNames_NoLookup",
|
||||
names: nil,
|
||||
wantIDs: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
alertmanagerService := alertmanagertest.NewMockAlertmanager(t)
|
||||
if len(testCase.names) > 0 {
|
||||
alertmanagerService.EXPECT().ListChannels(mock.Anything, mock.Anything).Return(channels, nil)
|
||||
}
|
||||
|
||||
ids, err := resolveKnownChannelIDs(context.Background(), alertmanagerService, valuer.GenerateUUID(), testCase.names)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testCase.wantIDs, ids)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,23 +168,6 @@ func MapChannelNamesToIDs(channels Channels, names []string) ([]string, error) {
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// MapKnownChannelNamesToIDs skips names with no matching channel instead of failing.
|
||||
func MapKnownChannelNamesToIDs(channels Channels, names []string) []string {
|
||||
idByName := make(map[string]string, len(channels))
|
||||
for _, channel := range channels {
|
||||
idByName[channel.DisplayName] = channel.ID.StringValue()
|
||||
}
|
||||
|
||||
ids := make([]string, 0, len(names))
|
||||
for _, name := range names {
|
||||
if id, ok := idByName[name]; ok {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
func NewStatsFromChannels(channels Channels) map[string]any {
|
||||
stats := make(map[string]any)
|
||||
for _, channel := range channels {
|
||||
|
||||
@@ -332,28 +332,3 @@ func TestMapChannelNamesToIDs(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapKnownChannelNamesToIDs(t *testing.T) {
|
||||
slackID := valuer.GenerateUUID()
|
||||
pagerID := valuer.GenerateUUID()
|
||||
channels := Channels{
|
||||
{Identifiable: types.Identifiable{ID: slackID}, DisplayName: "slack-alerts"},
|
||||
{Identifiable: types.Identifiable{ID: pagerID}, DisplayName: "pager-oncall"},
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
names []string
|
||||
want []string
|
||||
}{
|
||||
{name: "AllKnown_IDsInRequestOrder", names: []string{"pager-oncall", "slack-alerts"}, want: []string{pagerID.StringValue(), slackID.StringValue()}},
|
||||
{name: "UnknownName_Skipped", names: []string{"slack-alerts", "deleted-channel"}, want: []string{slackID.StringValue()}},
|
||||
{name: "NoNames_Empty", names: nil, want: []string{}},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
assert.Equal(t, testCase.want, MapKnownChannelNamesToIDs(channels, testCase.names))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -669,6 +669,216 @@ func TestValidateHeatmapDashboard(t *testing.T) {
|
||||
assert.Equal(t, request, rereadRequest)
|
||||
}
|
||||
|
||||
func TestValidateDistributionDashboard(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/perses_distribution_panel.json")
|
||||
require.NoError(t, err, "reading example file")
|
||||
|
||||
spec, err := unmarshalDashboard(data)
|
||||
require.NoError(t, err, "unmarshal and validate failed")
|
||||
|
||||
require.IsType(t, &DistributionPanelSpec{}, spec.Panels["p1"].Spec.Plugin.Spec)
|
||||
panelSpec := spec.Panels["p1"].Spec.Plugin.Spec.(*DistributionPanelSpec)
|
||||
require.NotNil(t, panelSpec.Axes.X.Min)
|
||||
assert.Equal(t, 10.0, *panelSpec.Axes.X.Min)
|
||||
assert.Nil(t, panelSpec.Axes.X.Max)
|
||||
assert.Equal(t, "log", panelSpec.Axes.Y.Scale.ValueOrDefault())
|
||||
assert.Equal(t, "none", panelSpec.ChartAppearance.Stack.ValueOrDefault())
|
||||
assert.True(t, panelSpec.ChartAppearance.Percentiles.Enabled)
|
||||
assert.Equal(t, []float64{75, 99.9}, panelSpec.ChartAppearance.Percentiles.Values)
|
||||
|
||||
dashboard := &DashboardV2{Spec: *spec}
|
||||
request, err := dashboard.GetPanelQuery(1, 2, "p1")
|
||||
require.NoError(t, err, "building the panel's query failed")
|
||||
assert.Equal(t, qb.RequestTypeDistribution, request.RequestType)
|
||||
require.NoError(t, request.Validate(), "the request built from the panel is not a valid distribution request")
|
||||
|
||||
stored, err := json.Marshal(spec)
|
||||
require.NoError(t, err, "marshal dashboard failed")
|
||||
reread, err := unmarshalDashboard(stored)
|
||||
require.NoError(t, err, "the stored dashboard does not validate")
|
||||
assert.Equal(t, panelSpec, reread.Panels["p1"].Spec.Plugin.Spec)
|
||||
}
|
||||
|
||||
func TestDistributionPanelSpecDefaults(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
spec string
|
||||
}{
|
||||
{name: "EmptySpec", spec: `{}`},
|
||||
{name: "EmptyChartAppearance", spec: `{"chartAppearance": {}}`},
|
||||
{name: "EmptyPercentiles", spec: `{"chartAppearance": {"percentiles": {}}}`},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
var plugin PanelPlugin
|
||||
require.NoError(t, json.Unmarshal([]byte(`{"kind": "signoz/DistributionPanel", "spec": `+testCase.spec+`}`), &plugin))
|
||||
|
||||
spec := plugin.Spec.(*DistributionPanelSpec)
|
||||
assert.Nil(t, spec.Axes.X.Min)
|
||||
assert.Nil(t, spec.Axes.X.Max)
|
||||
assert.Equal(t, "linear", spec.Axes.Y.Scale.ValueOrDefault())
|
||||
assert.Equal(t, "normal", spec.ChartAppearance.Stack.ValueOrDefault())
|
||||
assert.True(t, spec.ChartAppearance.Percentiles.Enabled)
|
||||
assert.Equal(t, []float64{50, 90, 99}, spec.ChartAppearance.Percentiles.Values)
|
||||
|
||||
stored, err := json.Marshal(spec)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(stored), `"stack":"normal"`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistributionPanelSpecPercentileValues(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
spec string
|
||||
expectedEnabled bool
|
||||
expectedValues []float64
|
||||
}{
|
||||
{name: "Disabled_KeepsDefaultValues", spec: `{"chartAppearance": {"percentiles": {"enabled": false}}}`, expectedEnabled: false, expectedValues: []float64{50, 90, 99}},
|
||||
{name: "EmptyValues_MarksNone", spec: `{"chartAppearance": {"percentiles": {"values": []}}}`, expectedEnabled: true, expectedValues: []float64{}},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
var plugin PanelPlugin
|
||||
require.NoError(t, json.Unmarshal([]byte(`{"kind": "signoz/DistributionPanel", "spec": `+testCase.spec+`}`), &plugin))
|
||||
|
||||
spec := plugin.Spec.(*DistributionPanelSpec)
|
||||
assert.Equal(t, testCase.expectedEnabled, spec.ChartAppearance.Percentiles.Enabled)
|
||||
assert.Equal(t, testCase.expectedValues, spec.ChartAppearance.Percentiles.Values)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistributionPanelSpecXMinEqualsMax(t *testing.T) {
|
||||
var plugin PanelPlugin
|
||||
require.NoError(t, json.Unmarshal([]byte(`{"kind": "signoz/DistributionPanel", "spec": {"axes": {"x": {"min": 10, "max": 10}}}}`), &plugin))
|
||||
|
||||
spec := plugin.Spec.(*DistributionPanelSpec)
|
||||
require.NotNil(t, spec.Axes.X.Min)
|
||||
require.NotNil(t, spec.Axes.X.Max)
|
||||
assert.Equal(t, 10.0, *spec.Axes.X.Min)
|
||||
assert.Equal(t, 10.0, *spec.Axes.X.Max)
|
||||
}
|
||||
|
||||
func TestDistributionPanelSpecRejections(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
spec string
|
||||
expectedMessage string
|
||||
}{
|
||||
{
|
||||
name: "XMin_AboveMax",
|
||||
spec: `{"axes": {"x": {"min": 20, "max": 10}}}`,
|
||||
expectedMessage: "axes.x.min 20 is greater than axes.x.max 10",
|
||||
},
|
||||
{
|
||||
name: "YScale_Unknown",
|
||||
spec: `{"axes": {"y": {"scale": "symlog"}}}`,
|
||||
expectedMessage: "invalid distribution y scale",
|
||||
},
|
||||
{
|
||||
name: "UnknownField",
|
||||
spec: `{"axes": {"y": {"softMax": 10}}}`,
|
||||
expectedMessage: "unknown field \"softMax\"",
|
||||
},
|
||||
{
|
||||
name: "Percentile_Zero",
|
||||
spec: `{"chartAppearance": {"percentiles": {"values": [0, 50]}}}`,
|
||||
expectedMessage: "0 is not in (0, 100)",
|
||||
},
|
||||
{
|
||||
name: "Percentile_Hundred",
|
||||
spec: `{"chartAppearance": {"percentiles": {"values": [100]}}}`,
|
||||
expectedMessage: "100 is not in (0, 100)",
|
||||
},
|
||||
{
|
||||
name: "Percentile_Duplicate",
|
||||
spec: `{"chartAppearance": {"percentiles": {"values": [90, 90]}}}`,
|
||||
expectedMessage: "90 is listed more than once",
|
||||
},
|
||||
{
|
||||
name: "Percentiles_MoreThanFive",
|
||||
spec: `{"chartAppearance": {"percentiles": {"values": [10, 25, 50, 75, 90, 99]}}}`,
|
||||
expectedMessage: "at most 5 percentiles",
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
var plugin PanelPlugin
|
||||
err := json.Unmarshal([]byte(`{"kind": "signoz/DistributionPanel", "spec": `+testCase.spec+`}`), &plugin)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), testCase.expectedMessage)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistributionPanelQueryKinds(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
queryPluginKind string
|
||||
queryPluginSpec string
|
||||
expectedAllowed bool
|
||||
}{
|
||||
{
|
||||
name: "PromQL_Allowed",
|
||||
queryPluginKind: "signoz/PromQLQuery",
|
||||
queryPluginSpec: `{"name": "A", "query": "sum by (le) (increase(signoz_latency_bucket[5m]))"}`,
|
||||
expectedAllowed: true,
|
||||
},
|
||||
{
|
||||
name: "ClickHouseSQL_Allowed",
|
||||
queryPluginKind: "signoz/ClickHouseSQL",
|
||||
queryPluginSpec: `{"name": "A", "query": "SELECT ts, bucket, value FROM cells"}`,
|
||||
expectedAllowed: true,
|
||||
},
|
||||
{
|
||||
name: "TraceOperator_Refused",
|
||||
queryPluginKind: "signoz/TraceOperator",
|
||||
queryPluginSpec: `{"name": "T1", "expression": "A => B"}`,
|
||||
expectedAllowed: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
data := fmt.Sprintf(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/DistributionPanel", "spec": {}},
|
||||
"queries": [{
|
||||
"kind": "distribution",
|
||||
"spec": {
|
||||
"plugin": {"kind": %q, "spec": %s}
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`, testCase.queryPluginKind, testCase.queryPluginSpec)
|
||||
|
||||
_, err := unmarshalDashboard([]byte(data))
|
||||
|
||||
if testCase.expectedAllowed {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "is not supported by panel kind")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateOneInvalidPanel(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
|
||||
@@ -28,17 +28,18 @@ type PanelPlugin struct {
|
||||
// (and strips the duplicate parent properties) after reflection.
|
||||
func (PanelPlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
return markDiscriminator(s, "kind", map[string]string{
|
||||
string(PanelKindTimeSeries): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec"),
|
||||
string(PanelKindBarChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec"),
|
||||
string(PanelKindAreaChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec"),
|
||||
string(PanelKindNumber): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec"),
|
||||
string(PanelKindPieChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec"),
|
||||
string(PanelKindTable): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec"),
|
||||
string(PanelKindHistogram): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec"),
|
||||
string(PanelKindList): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec"),
|
||||
string(PanelKindText): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec"),
|
||||
string(PanelKindHeatmap): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec"),
|
||||
string(PanelKindScatterPlot): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec"),
|
||||
string(PanelKindTimeSeries): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec"),
|
||||
string(PanelKindBarChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec"),
|
||||
string(PanelKindAreaChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec"),
|
||||
string(PanelKindNumber): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec"),
|
||||
string(PanelKindPieChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec"),
|
||||
string(PanelKindTable): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec"),
|
||||
string(PanelKindHistogram): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec"),
|
||||
string(PanelKindList): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec"),
|
||||
string(PanelKindText): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec"),
|
||||
string(PanelKindHeatmap): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec"),
|
||||
string(PanelKindScatterPlot): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec"),
|
||||
string(PanelKindDistribution): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpec"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -73,6 +74,7 @@ func (PanelPlugin) JSONSchemaOneOf() []any {
|
||||
PanelPluginVariant[TextPanelSpec]{Kind: string(PanelKindText)},
|
||||
PanelPluginVariant[HeatmapPanelSpec]{Kind: string(PanelKindHeatmap)},
|
||||
PanelPluginVariant[ScatterPlotPanelSpec]{Kind: string(PanelKindScatterPlot)},
|
||||
PanelPluginVariant[DistributionPanelSpec]{Kind: string(PanelKindDistribution)},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,17 +238,18 @@ func (v VariablePluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error
|
||||
|
||||
var (
|
||||
panelPluginSpecs = map[PanelPluginKind]func() any{
|
||||
PanelKindTimeSeries: func() any { return new(TimeSeriesPanelSpec) },
|
||||
PanelKindBarChart: func() any { return new(BarChartPanelSpec) },
|
||||
PanelKindAreaChart: func() any { return new(AreaChartPanelSpec) },
|
||||
PanelKindNumber: func() any { return new(NumberPanelSpec) },
|
||||
PanelKindPieChart: func() any { return new(PieChartPanelSpec) },
|
||||
PanelKindTable: func() any { return new(TablePanelSpec) },
|
||||
PanelKindHistogram: func() any { return new(HistogramPanelSpec) },
|
||||
PanelKindList: func() any { return new(ListPanelSpec) },
|
||||
PanelKindText: func() any { return new(TextPanelSpec) },
|
||||
PanelKindHeatmap: func() any { return new(HeatmapPanelSpec) },
|
||||
PanelKindScatterPlot: func() any { return new(ScatterPlotPanelSpec) },
|
||||
PanelKindTimeSeries: func() any { return new(TimeSeriesPanelSpec) },
|
||||
PanelKindBarChart: func() any { return new(BarChartPanelSpec) },
|
||||
PanelKindAreaChart: func() any { return new(AreaChartPanelSpec) },
|
||||
PanelKindNumber: func() any { return new(NumberPanelSpec) },
|
||||
PanelKindPieChart: func() any { return new(PieChartPanelSpec) },
|
||||
PanelKindTable: func() any { return new(TablePanelSpec) },
|
||||
PanelKindHistogram: func() any { return new(HistogramPanelSpec) },
|
||||
PanelKindList: func() any { return new(ListPanelSpec) },
|
||||
PanelKindText: func() any { return new(TextPanelSpec) },
|
||||
PanelKindHeatmap: func() any { return new(HeatmapPanelSpec) },
|
||||
PanelKindScatterPlot: func() any { return new(ScatterPlotPanelSpec) },
|
||||
PanelKindDistribution: func() any { return new(DistributionPanelSpec) },
|
||||
}
|
||||
queryPluginSpecs = map[QueryPluginKind]func() any{
|
||||
QueryKindBuilder: func() any { return new(BuilderQuerySpec) },
|
||||
@@ -263,17 +266,18 @@ var (
|
||||
VariableKindCustom: func() any { return new(CustomVariableSpec) },
|
||||
}
|
||||
allowedQueryKinds = map[PanelPluginKind][]QueryPluginKind{
|
||||
PanelKindTimeSeries: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindBarChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindAreaChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindNumber: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindHistogram: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindPieChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindTable: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindList: {QueryKindBuilder, QueryKindAIBuilder},
|
||||
PanelKindText: {},
|
||||
PanelKindHeatmap: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindScatterPlot: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindClickHouseSQL},
|
||||
PanelKindTimeSeries: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindBarChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindAreaChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindNumber: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindHistogram: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindPieChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindTable: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindList: {QueryKindBuilder, QueryKindAIBuilder},
|
||||
PanelKindText: {},
|
||||
PanelKindHeatmap: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindScatterPlot: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindClickHouseSQL},
|
||||
PanelKindDistribution: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ func (k *PanelKind) UnmarshalJSON(data []byte) error {
|
||||
|
||||
type PanelSpec struct {
|
||||
Display Display `json:"display" required:"true" description:"The panel title and description."`
|
||||
Plugin PanelPlugin `json:"plugin" required:"true" description:"The visualization: kind is one of signoz/TimeSeriesPanel, signoz/BarChartPanel, signoz/AreaChartPanel, signoz/NumberPanel, signoz/PieChartPanel, signoz/TablePanel, signoz/HistogramPanel, signoz/ListPanel, signoz/TextPanel or signoz/HeatmapPanel, and spec holds that kind's options ({} accepts every default)."`
|
||||
Plugin PanelPlugin `json:"plugin" required:"true" description:"The visualization: kind is one of signoz/TimeSeriesPanel, signoz/BarChartPanel, signoz/AreaChartPanel, signoz/NumberPanel, signoz/PieChartPanel, signoz/TablePanel, signoz/HistogramPanel, signoz/ListPanel, signoz/TextPanel, signoz/HeatmapPanel or signoz/DistributionPanel, and spec holds that kind's options ({} accepts every default)."`
|
||||
Queries []Query `json:"queries" required:"true" nullable:"false" description:"The queries the panel runs. Use exactly one entry for a data panel and [] for signoz/TextPanel. To run several named queries or formulas, use one entry whose spec.plugin.kind is signoz/CompositeQuery and put the constituent queries in its plugin spec."`
|
||||
Links []Link `json:"links,omitzero" description:"Context links offered in the data point drilldown menu of the panel, with dashboard variables resolved in the URL."`
|
||||
}
|
||||
@@ -92,13 +92,13 @@ type Link struct {
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
type Query struct {
|
||||
Kind qb.RequestType `json:"kind" required:"true" description:"The result shape the panel requests: time_series for time series, bar, area and histogram panels, scalar for number, table and pie panels, raw for list panels, heatmap for heatmap panels, trace for trace panels."`
|
||||
Kind qb.RequestType `json:"kind" required:"true" description:"The result shape the panel requests: time_series for time series, bar, area and histogram panels, scalar for number, table and pie panels, raw for list panels, heatmap for heatmap panels, distribution for distribution panels, trace for trace panels."`
|
||||
Spec QuerySpec `json:"spec" required:"true" description:"The query content: an optional name and the query plugin."`
|
||||
}
|
||||
|
||||
type QuerySpec struct {
|
||||
Name string `json:"name,omitempty" description:"An optional label for the query. The name queries refer to each other by lives in the plugin spec."`
|
||||
Plugin QueryPlugin `json:"plugin" required:"true" description:"The query: kind is one of signoz/BuilderQuery, signoz/AIBuilderQuery (builder query over AI observability traces), signoz/CompositeQuery (several named queries and formulas), signoz/Formula, signoz/PromQLQuery, signoz/ClickHouseSQL or signoz/TraceOperator, and spec holds that kind's query. Pie and table panels reject signoz/PromQLQuery; heatmap panels reject signoz/AIBuilderQuery and signoz/TraceOperator; list panels accept only signoz/BuilderQuery and signoz/AIBuilderQuery."`
|
||||
Plugin QueryPlugin `json:"plugin" required:"true" description:"The query: kind is one of signoz/BuilderQuery, signoz/AIBuilderQuery (builder query over AI observability traces), signoz/CompositeQuery (several named queries and formulas), signoz/Formula, signoz/PromQLQuery, signoz/ClickHouseSQL or signoz/TraceOperator, and spec holds that kind's query. Pie and table panels reject signoz/PromQLQuery; heatmap and distribution panels reject signoz/AIBuilderQuery and signoz/TraceOperator; list panels accept only signoz/BuilderQuery and signoz/AIBuilderQuery."`
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
@@ -189,21 +189,22 @@ func (AIBuilderQuerySpec) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
type PanelPluginKind string
|
||||
|
||||
const (
|
||||
PanelKindTimeSeries PanelPluginKind = "signoz/TimeSeriesPanel"
|
||||
PanelKindBarChart PanelPluginKind = "signoz/BarChartPanel"
|
||||
PanelKindAreaChart PanelPluginKind = "signoz/AreaChartPanel"
|
||||
PanelKindNumber PanelPluginKind = "signoz/NumberPanel"
|
||||
PanelKindPieChart PanelPluginKind = "signoz/PieChartPanel"
|
||||
PanelKindTable PanelPluginKind = "signoz/TablePanel"
|
||||
PanelKindHistogram PanelPluginKind = "signoz/HistogramPanel"
|
||||
PanelKindList PanelPluginKind = "signoz/ListPanel"
|
||||
PanelKindText PanelPluginKind = "signoz/TextPanel"
|
||||
PanelKindHeatmap PanelPluginKind = "signoz/HeatmapPanel"
|
||||
PanelKindScatterPlot PanelPluginKind = "signoz/ScatterPlotPanel"
|
||||
PanelKindTimeSeries PanelPluginKind = "signoz/TimeSeriesPanel"
|
||||
PanelKindBarChart PanelPluginKind = "signoz/BarChartPanel"
|
||||
PanelKindAreaChart PanelPluginKind = "signoz/AreaChartPanel"
|
||||
PanelKindNumber PanelPluginKind = "signoz/NumberPanel"
|
||||
PanelKindPieChart PanelPluginKind = "signoz/PieChartPanel"
|
||||
PanelKindTable PanelPluginKind = "signoz/TablePanel"
|
||||
PanelKindHistogram PanelPluginKind = "signoz/HistogramPanel"
|
||||
PanelKindList PanelPluginKind = "signoz/ListPanel"
|
||||
PanelKindText PanelPluginKind = "signoz/TextPanel"
|
||||
PanelKindHeatmap PanelPluginKind = "signoz/HeatmapPanel"
|
||||
PanelKindScatterPlot PanelPluginKind = "signoz/ScatterPlotPanel"
|
||||
PanelKindDistribution PanelPluginKind = "signoz/DistributionPanel"
|
||||
)
|
||||
|
||||
func (PanelPluginKind) Enum() []any {
|
||||
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindAreaChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText, PanelKindHeatmap, PanelKindScatterPlot}
|
||||
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindAreaChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText, PanelKindHeatmap, PanelKindScatterPlot, PanelKindDistribution}
|
||||
}
|
||||
|
||||
func (k PanelPluginKind) rendersWithoutQuery() bool {
|
||||
@@ -346,6 +347,89 @@ func (c HeatmapColors) validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type DistributionPanelSpec struct {
|
||||
Visualization BasicVisualization `json:"visualization" description:"The time range the bucket counts are summed over."`
|
||||
Formatting PanelFormatting `json:"formatting" description:"Unit and decimal precision of the bucket bounds; counts take no unit."`
|
||||
Axes DistributionAxes `json:"axes" description:"The X (bucket) axis range and Y (count) axis scale."`
|
||||
Legend Legend `json:"legend" description:"Legend placement and series colors."`
|
||||
ChartAppearance DistributionChartAppearance `json:"chartAppearance" description:"Stacking and percentile markers."`
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes over the defaults, since the stack default (normal)
|
||||
// differs from StackMode's own and percentile markers default to on.
|
||||
func (spec *DistributionPanelSpec) UnmarshalJSON(data []byte) error {
|
||||
type alias DistributionPanelSpec
|
||||
tmp := alias{ChartAppearance: DistributionChartAppearance{
|
||||
Stack: StackModeNormal,
|
||||
Percentiles: DistributionPercentiles{Enabled: true, Values: []float64{50, 90, 99}},
|
||||
}}
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&tmp); err != nil {
|
||||
return err
|
||||
}
|
||||
*spec = DistributionPanelSpec(tmp)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (spec *DistributionPanelSpec) validate() error {
|
||||
if err := spec.Axes.X.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
return spec.ChartAppearance.Percentiles.validate()
|
||||
}
|
||||
|
||||
type DistributionAxes struct {
|
||||
X DistributionXAxis `json:"x" description:"The bucket axis."`
|
||||
Y DistributionYAxis `json:"y" description:"The count axis."`
|
||||
}
|
||||
|
||||
// DistributionXAxis draws every bucket equally wide, whatever its bounds.
|
||||
type DistributionXAxis struct {
|
||||
Min *float64 `json:"min" description:"Buckets wholly below this value are hidden. Null shows them all."`
|
||||
Max *float64 `json:"max" description:"Buckets wholly above this value are hidden. Null shows them all."`
|
||||
}
|
||||
|
||||
func (axis DistributionXAxis) validate() error {
|
||||
if axis.Min != nil && axis.Max != nil && *axis.Min > *axis.Max {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "axes.x.min %v is greater than axes.x.max %v", *axis.Min, *axis.Max)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type DistributionYAxis struct {
|
||||
Scale DistributionYScale `json:"scale" required:"true" description:"How counts are spaced: linear (default) or log."`
|
||||
}
|
||||
|
||||
type DistributionChartAppearance struct {
|
||||
Stack StackMode `json:"stack" required:"true" description:"How groups combine in a bucket: normal (default) stacks them, none draws them side by side, percent stacks them as shares of the bucket."`
|
||||
Percentiles DistributionPercentiles `json:"percentiles" description:"Percentile markers drawn across the bucket axis."`
|
||||
}
|
||||
|
||||
const maxDistributionPercentiles = 5
|
||||
|
||||
type DistributionPercentiles struct {
|
||||
Enabled bool `json:"enabled" required:"true" description:"Whether percentile markers are drawn. Defaults to true."`
|
||||
Values []float64 `json:"values" required:"true" description:"The percentiles to mark, each in (0, 100), at most 5, no duplicates. Defaults to [50, 90, 99]."`
|
||||
}
|
||||
|
||||
func (percentiles DistributionPercentiles) validate() error {
|
||||
if len(percentiles.Values) > maxDistributionPercentiles {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "chartAppearance.percentiles.values: at most %d percentiles, got %d", maxDistributionPercentiles, len(percentiles.Values))
|
||||
}
|
||||
seen := make(map[float64]struct{}, len(percentiles.Values))
|
||||
for _, value := range percentiles.Values {
|
||||
if value <= 0 || value >= 100 {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "chartAppearance.percentiles.values: %v is not in (0, 100)", value)
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "chartAppearance.percentiles.values: %v is listed more than once", value)
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type TextPanelSpec struct {
|
||||
Mode TextMode `json:"mode" description:"How text is interpreted. Only markdown is supported."`
|
||||
Text string `json:"text" description:"The content to render."`
|
||||
@@ -1462,6 +1546,43 @@ func (s *HeatmapYScale) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
}
|
||||
|
||||
type DistributionYScale struct{ valuer.String }
|
||||
|
||||
var (
|
||||
DistributionYScaleLinear = DistributionYScale{valuer.NewString("linear")} // default
|
||||
DistributionYScaleLog = DistributionYScale{valuer.NewString("log")}
|
||||
)
|
||||
|
||||
func (DistributionYScale) Enum() []any {
|
||||
return []any{DistributionYScaleLinear, DistributionYScaleLog}
|
||||
}
|
||||
|
||||
func (scale DistributionYScale) ValueOrDefault() string {
|
||||
if scale.IsZero() {
|
||||
return DistributionYScaleLinear.StringValue()
|
||||
}
|
||||
return scale.StringValue()
|
||||
}
|
||||
|
||||
func (scale DistributionYScale) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(scale.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (scale *DistributionYScale) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid distribution y scale: must be a string, one of `linear` or `log`")
|
||||
}
|
||||
parsed := DistributionYScale{valuer.NewString(v)}
|
||||
switch parsed {
|
||||
case DistributionYScaleLinear, DistributionYScaleLog:
|
||||
*scale = parsed
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid distribution y scale %q: must be `linear` or `log`", v)
|
||||
}
|
||||
}
|
||||
|
||||
type HeatmapColorScale struct{ valuer.String }
|
||||
|
||||
var (
|
||||
|
||||
95
pkg/types/dashboardtypes/testdata/perses_distribution_panel.json
vendored
Normal file
95
pkg/types/dashboardtypes/testdata/perses_distribution_panel.json
vendored
Normal file
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"display": {
|
||||
"name": "latency",
|
||||
"description": "how request duration is distributed over the range"
|
||||
},
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "request duration",
|
||||
"description": ""
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "signoz/DistributionPanel",
|
||||
"spec": {
|
||||
"visualization": {
|
||||
"timePreference": "global_time"
|
||||
},
|
||||
"formatting": {
|
||||
"unit": "s",
|
||||
"decimalPrecision": "3"
|
||||
},
|
||||
"axes": {
|
||||
"x": {
|
||||
"min": 10
|
||||
},
|
||||
"y": {
|
||||
"scale": "log"
|
||||
}
|
||||
},
|
||||
"legend": {
|
||||
"position": "right",
|
||||
"mode": "list"
|
||||
},
|
||||
"chartAppearance": {
|
||||
"stack": "none",
|
||||
"percentiles": {
|
||||
"enabled": true,
|
||||
"values": [75, 99.9]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"queries": [
|
||||
{
|
||||
"kind": "distribution",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "signoz/BuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "http.server.request.duration",
|
||||
"timeAggregation": "increase",
|
||||
"spaceAggregation": "sum"
|
||||
}
|
||||
],
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "service.name"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": [
|
||||
{
|
||||
"kind": "Grid",
|
||||
"spec": {
|
||||
"items": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 12,
|
||||
"height": 8,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/p1"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -250,21 +250,6 @@ func (r *PostableRule) UnmarshalJSON(bytes []byte) error {
|
||||
return r.validate()
|
||||
}
|
||||
|
||||
func NewPatchedPostableRule(stored []byte, patch []byte) (*PostableRule, error) {
|
||||
patched := new(PostableRule)
|
||||
if err := json.Unmarshal(stored, patched); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// a rule stored before selectedQueryName was required resolves it the way evaluation does
|
||||
patched.RuleCondition.SelectedQuery = patched.RuleCondition.SelectedQueryName()
|
||||
|
||||
if err := patched.DecodeStrict(string(patch)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return patched, nil
|
||||
}
|
||||
|
||||
func (r *PostableRule) DecodeStrict(ruleStr string) error {
|
||||
err := json.Unmarshal([]byte(ruleStr), r)
|
||||
if err == nil {
|
||||
|
||||
@@ -116,6 +116,6 @@ def pytest_addoption(parser: pytest.Parser):
|
||||
parser.addoption(
|
||||
"--schema-migrator-version",
|
||||
action="store",
|
||||
default="v0.144.15",
|
||||
default="v0.144.9", # todo(nikhil): change to 0.144.10
|
||||
help="schema migrator version",
|
||||
)
|
||||
|
||||
2
tests/fixtures/logs.py
vendored
2
tests/fixtures/logs.py
vendored
@@ -174,7 +174,7 @@ class Logs(ABC):
|
||||
self.body_v2 = body_v2
|
||||
else:
|
||||
# Try to parse body as JSON; if successful use it directly,
|
||||
# otherwise wrap as {"message": body} matching signozlogsnormalizer.
|
||||
# otherwise wrap as {"message": body} matching the normalize operator behavior.
|
||||
try:
|
||||
json.loads(body)
|
||||
self.body_v2 = body
|
||||
|
||||
34
tests/fixtures/querier.py
vendored
34
tests/fixtures/querier.py
vendored
@@ -1018,38 +1018,20 @@ def assert_distribution_sums_heatmap(
|
||||
# we already create the evolution for resource during schema migration
|
||||
# since we have to create test data around it, we need to get the evolution time
|
||||
def get_resource_evolution_time(signoz: types.SigNoz, signal: str) -> datetime:
|
||||
return get_column_evolution_time(signoz, signal, "resource", "resource")
|
||||
|
||||
|
||||
# the migrator also seeds the traces attributes JSON rollout at install
|
||||
def get_attribute_evolution_time(signoz: types.SigNoz, signal: str) -> datetime:
|
||||
return get_column_evolution_time(signoz, signal, "attributes", "attribute")
|
||||
|
||||
|
||||
def get_attribute_backend_now(signoz: types.SigNoz, attribute_backend: str) -> datetime:
|
||||
"""The "now" a test anchors its spans and query window to. "map" sits before the traces
|
||||
attributes JSON rollout, where the querier reads only the legacy maps, the same SQL as with
|
||||
use_trace_attributes_json off; "json" is the wall clock, after the rollout."""
|
||||
if attribute_backend == "map":
|
||||
return get_attribute_evolution_time(signoz, "traces") - timedelta(hours=1)
|
||||
return datetime.now(tz=UTC)
|
||||
|
||||
|
||||
def get_column_evolution_time(signoz: types.SigNoz, signal: str, column_name: str, field_context: str) -> datetime:
|
||||
result = signoz.telemetrystore.conn.query(
|
||||
"""
|
||||
SELECT min(release_time)
|
||||
SELECT release_time
|
||||
FROM signoz_metadata.distributed_column_evolution_metadata
|
||||
WHERE signal = %(signal)s
|
||||
AND field_context = %(field_context)s
|
||||
AND field_context = 'resource'
|
||||
AND field_name = '__all__'
|
||||
AND column_name = %(column_name)s
|
||||
HAVING count() > 0
|
||||
AND column_name = 'resource'
|
||||
LIMIT 1
|
||||
""",
|
||||
parameters={"signal": signal, "column_name": column_name, "field_context": field_context},
|
||||
parameters={"signal": signal},
|
||||
).result_rows
|
||||
|
||||
assert result, f"Expected {signal} {column_name} evolution metadata to exist"
|
||||
assert result, f"Expected {signal} resource evolution metadata to exist"
|
||||
|
||||
release_time_ns = int(result[0][0])
|
||||
return datetime.fromtimestamp(release_time_ns / 1e9, tz=UTC)
|
||||
@@ -1150,7 +1132,7 @@ def generate_logs_with_corrupt_metadata() -> list[Logs]:
|
||||
]
|
||||
|
||||
|
||||
def generate_traces_with_corrupt_metadata(now: datetime | None = None) -> list[Traces]:
|
||||
def generate_traces_with_corrupt_metadata() -> list[Traces]:
|
||||
"""
|
||||
Specifically, entries with 'id', 'timestamp', 'trace_id' and 'duration_nano' fields in metadata
|
||||
"""
|
||||
@@ -1161,7 +1143,7 @@ def generate_traces_with_corrupt_metadata(now: datetime | None = None) -> list[T
|
||||
topic_service_trace_id = TraceIdGenerator.trace_id()
|
||||
topic_service_span_id = TraceIdGenerator.span_id()
|
||||
|
||||
now = (now or datetime.now(tz=UTC)).replace(second=0, microsecond=0)
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
|
||||
return [
|
||||
Traces(
|
||||
|
||||
2
tests/fixtures/signoz.py
vendored
2
tests/fixtures/signoz.py
vendored
@@ -92,7 +92,7 @@ def create_signoz(
|
||||
"SIGNOZ_ALERTMANAGER_SIGNOZ_ROUTE_GROUP__WAIT": "1s",
|
||||
"SIGNOZ_ALERTMANAGER_SIGNOZ_ROUTE_GROUP__INTERVAL": "5s",
|
||||
"SIGNOZ_CLOUDINTEGRATION_AGENT_VERSION": "v0.0.8",
|
||||
# no schema metadata caching in tests
|
||||
# tests reseed the attributes rollout row between cases
|
||||
"SIGNOZ_QUERIER_SCHEMAMETADATASTORE_CACHE__TTL": "1ns",
|
||||
}
|
||||
| sqlstore.env
|
||||
|
||||
54
tests/fixtures/traces.py
vendored
54
tests/fixtures/traces.py
vendored
@@ -986,6 +986,60 @@ def insert_traces(
|
||||
)
|
||||
|
||||
|
||||
def insert_attribute_evolution_to_clickhouse(conn, signal: str, release_time: datetime.datetime) -> None:
|
||||
"""Seed the `attributes` JSON column-evolution row for a signal at release_time. Unlike the
|
||||
resource row (seeded by the migrator at install), the attribute JSON rollout is install-specific
|
||||
and not migrator-seeded, so tests insert it to gate map-vs-JSON resolution across a window."""
|
||||
# insert_deduplicate=0: successive tests seed a byte-identical row (release_time is
|
||||
# minute-aligned), and ReplicatedMergeTree would drop the re-insert as a duplicate even
|
||||
# after the prior test's teardown deleted it, leaving the querier to fall back to the Map.
|
||||
conn.command(
|
||||
"""
|
||||
INSERT INTO signoz_metadata.distributed_column_evolution_metadata
|
||||
(signal, column_name, column_type, field_context, field_name, version, release_time)
|
||||
SETTINGS insert_deduplicate = 0
|
||||
VALUES (%(signal)s, 'attributes', 'JSON()', 'attribute', '__all__', 1, %(release_time_ns)s)
|
||||
""",
|
||||
parameters={"signal": signal, "release_time_ns": int(release_time.timestamp() * 1e9)},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="seed_attribute_evolution", scope="function")
|
||||
def seed_attribute_evolution(
|
||||
clickhouse: types.TestContainerClickhouse,
|
||||
) -> Generator[Callable[[str, datetime.datetime], None], Any]:
|
||||
def _seed(signal: str, release_time: datetime.datetime) -> None:
|
||||
insert_attribute_evolution_to_clickhouse(clickhouse.conn, signal, release_time)
|
||||
|
||||
yield _seed
|
||||
|
||||
cluster = clickhouse.env["SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER"]
|
||||
clickhouse.conn.query(f"ALTER TABLE signoz_metadata.column_evolution_metadata ON CLUSTER '{cluster}' DELETE WHERE column_name = 'attributes' AND field_context = 'attribute' AND field_name = '__all__' SETTINGS mutations_sync = 1")
|
||||
|
||||
|
||||
# A rollout far before any test's query window, so seeding it makes the querier read span
|
||||
# attributes entirely from the native JSON column rather than straddling into the legacy Map.
|
||||
ATTRIBUTE_JSON_ROLLOUT_TIME = datetime.datetime(2020, 1, 1, tzinfo=datetime.UTC)
|
||||
|
||||
|
||||
@pytest.fixture(name="use_attribute_backend")
|
||||
def use_attribute_backend(
|
||||
seed_attribute_evolution: Callable[[str, datetime.datetime], None],
|
||||
) -> Callable[[str], None]:
|
||||
"""Applies the physical attribute layout a test runs under. "map" leaves the querier on the
|
||||
legacy attributes_{string,number,bool} maps; "json" seeds the column-evolution row so it reads
|
||||
the native `attributes` JSON column instead. Pair with
|
||||
@pytest.mark.parametrize("attribute_backend", ["map", "json"]) and call it once in the body;
|
||||
insert_traces dual-writes both layouts, so a test that passes under both has strict Map/JSON
|
||||
parity."""
|
||||
|
||||
def _apply(backend: str) -> None:
|
||||
if backend == "json":
|
||||
seed_attribute_evolution("traces", ATTRIBUTE_JSON_ROLLOUT_TIME)
|
||||
|
||||
return _apply
|
||||
|
||||
|
||||
@pytest.fixture(name="insert_top_level_operations", scope="function")
|
||||
def insert_top_level_operations(
|
||||
clickhouse: types.TestContainerClickhouse,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
from typing import Any
|
||||
|
||||
@@ -20,7 +20,6 @@ from fixtures.querier import (
|
||||
build_order_by,
|
||||
build_traces_scalar_query,
|
||||
get_all_series,
|
||||
get_attribute_backend_now,
|
||||
get_scalar_table_data,
|
||||
index_series_by_label,
|
||||
make_query_request,
|
||||
@@ -71,6 +70,7 @@ def test_traces_aggregate_order_by_count(
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
use_attribute_backend: Callable[[str], None],
|
||||
attribute_backend: str,
|
||||
order_by: dict[str, str],
|
||||
aggregation_alias: str,
|
||||
@@ -86,9 +86,9 @@ def test_traces_aggregate_order_by_count(
|
||||
fieldContext. Only the combinations that double-specify the context are a bad
|
||||
request; the rest resolve and return the span count.
|
||||
"""
|
||||
now = get_attribute_backend_now(signoz, attribute_backend)
|
||||
use_attribute_backend(attribute_backend)
|
||||
|
||||
now = now.replace(second=0, microsecond=0)
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
trace_id = TraceIdGenerator.trace_id()
|
||||
spans = [
|
||||
Traces(
|
||||
@@ -109,11 +109,9 @@ def test_traces_aggregate_order_by_count(
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((now - timedelta(minutes=5)).timestamp() * 1000),
|
||||
end_ms=int(now.timestamp() * 1000),
|
||||
start_ms=int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000),
|
||||
end_ms=int(datetime.now(tz=UTC).timestamp() * 1000),
|
||||
request_type=RequestType.TIME_SERIES,
|
||||
# cases share the query, and a "map" window is old enough to be served from the cache
|
||||
no_cache=True,
|
||||
queries=[
|
||||
BuilderQuery(
|
||||
signal="traces",
|
||||
@@ -147,6 +145,7 @@ def test_traces_aggregate_functions(
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
use_attribute_backend: Callable[[str], None],
|
||||
attribute_backend: str,
|
||||
noise: str,
|
||||
) -> None:
|
||||
@@ -164,10 +163,10 @@ def test_traces_aggregate_functions(
|
||||
the corrupt variant the same-named colliding attributes must not change any of
|
||||
these.
|
||||
"""
|
||||
now = get_attribute_backend_now(signoz, attribute_backend)
|
||||
use_attribute_backend(attribute_backend)
|
||||
|
||||
extra_attrs, extra_resources = trace_noise(noise)
|
||||
now = now.replace(second=0, microsecond=0)
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
# A scalar rate divides by the whole query window, so both sides agree on it.
|
||||
# Traces derives this separately from logs (telemetrytraces/statement_builder.go).
|
||||
lookback_minutes = 5
|
||||
@@ -254,6 +253,7 @@ def test_traces_aggregate_calculated_range_countif(
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
use_attribute_backend: Callable[[str], None],
|
||||
attribute_backend: str,
|
||||
) -> None:
|
||||
"""
|
||||
@@ -264,9 +264,9 @@ def test_traces_aggregate_calculated_range_countif(
|
||||
countIf(response_status_code >= 400 AND response_status_code < 500) counts the
|
||||
single 4XX span (404).
|
||||
"""
|
||||
now = get_attribute_backend_now(signoz, attribute_backend)
|
||||
use_attribute_backend(attribute_backend)
|
||||
|
||||
now = now.replace(second=0, microsecond=0)
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
spans = [
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=i + 1),
|
||||
@@ -307,6 +307,7 @@ def test_traces_aggregate_having(
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
use_attribute_backend: Callable[[str], None],
|
||||
attribute_backend: str,
|
||||
noise: str,
|
||||
) -> None:
|
||||
@@ -318,10 +319,10 @@ def test_traces_aggregate_having(
|
||||
A grouped scalar query with `having count() > 2` returns only the groups
|
||||
whose count qualifies (svc-a), derived from the inserted spans.
|
||||
"""
|
||||
now = get_attribute_backend_now(signoz, attribute_backend)
|
||||
use_attribute_backend(attribute_backend)
|
||||
|
||||
extra_attrs, extra_resources = trace_noise(noise)
|
||||
now = now.replace(second=0, microsecond=0)
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
counts = {"svc-a": 3, "svc-b": 1}
|
||||
spans = [
|
||||
Traces(
|
||||
@@ -378,6 +379,7 @@ def test_traces_aggregate_time_series_limit(
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
use_attribute_backend: Callable[[str], None],
|
||||
attribute_backend: str,
|
||||
noise: str,
|
||||
limit: int,
|
||||
@@ -393,10 +395,10 @@ def test_traces_aggregate_time_series_limit(
|
||||
summing to that service's span count. Under the corrupt variant the grouping
|
||||
(resource.service.name) and count are unaffected by the colliding attributes.
|
||||
"""
|
||||
now = get_attribute_backend_now(signoz, attribute_backend)
|
||||
use_attribute_backend(attribute_backend)
|
||||
|
||||
extra_attrs, extra_resources = trace_noise(noise)
|
||||
now = now.replace(second=0, microsecond=0)
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
counts = {"svc-a": 5, "svc-b": 3, "svc-c": 7, "svc-d": 1}
|
||||
spans = [
|
||||
Traces(
|
||||
@@ -461,6 +463,7 @@ def test_traces_aggregate_unknown_key(
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
use_attribute_backend: Callable[[str], None],
|
||||
attribute_backend: str,
|
||||
expression: str,
|
||||
expected_value: Any,
|
||||
@@ -473,9 +476,9 @@ def test_traces_aggregate_unknown_key(
|
||||
Aggregating a bare unknown key synthesizes a null column: count() of it is 0
|
||||
(no non-null values) and sum()/avg() are null — the query never errors.
|
||||
"""
|
||||
now = get_attribute_backend_now(signoz, attribute_backend)
|
||||
use_attribute_backend(attribute_backend)
|
||||
|
||||
now = now.replace(second=0, microsecond=0)
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
spans = [
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=i + 1),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
from typing import Any
|
||||
|
||||
@@ -16,7 +16,6 @@ from fixtures.querier import (
|
||||
find_named_result,
|
||||
format_timestamp,
|
||||
generate_traces_with_corrupt_metadata,
|
||||
get_attribute_backend_now,
|
||||
get_rows,
|
||||
make_query_request,
|
||||
)
|
||||
@@ -238,10 +237,11 @@ def test_trace_operator(
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
use_attribute_backend: Callable[[str], None],
|
||||
attribute_backend: str,
|
||||
case: dict,
|
||||
) -> None:
|
||||
now = get_attribute_backend_now(signoz, attribute_backend)
|
||||
use_attribute_backend(attribute_backend)
|
||||
|
||||
t1_trace_id = TraceIdGenerator.trace_id()
|
||||
t1_checkout_span_id = TraceIdGenerator.span_id() # POST /checkout — structural root of T1
|
||||
@@ -251,7 +251,7 @@ def test_trace_operator(
|
||||
t2_root_span_id = TraceIdGenerator.span_id()
|
||||
t2_child_span_id = TraceIdGenerator.span_id()
|
||||
|
||||
now = now.replace(second=0, microsecond=0)
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
|
||||
insert_traces(
|
||||
[
|
||||
@@ -580,6 +580,7 @@ def test_trace_operator_with_adjusted_keys(
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
use_attribute_backend: Callable[[str], None],
|
||||
attribute_backend: str,
|
||||
payload_factory: Callable[[list[Traces]], list[dict[str, Any]]],
|
||||
request_type: str,
|
||||
@@ -591,9 +592,9 @@ def test_trace_operator_with_adjusted_keys(
|
||||
queries, otherwise deprecated keys and context-prefixed aliases don't
|
||||
resolve.
|
||||
"""
|
||||
now = get_attribute_backend_now(signoz, attribute_backend)
|
||||
use_attribute_backend(attribute_backend)
|
||||
|
||||
traces = generate_traces_with_corrupt_metadata(now)
|
||||
traces = generate_traces_with_corrupt_metadata()
|
||||
insert_traces(traces)
|
||||
payload = payload_factory(traces)
|
||||
|
||||
@@ -602,8 +603,8 @@ def test_trace_operator_with_adjusted_keys(
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((now - timedelta(minutes=5)).timestamp() * 1000),
|
||||
end_ms=int(now.timestamp() * 1000),
|
||||
start_ms=int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000),
|
||||
end_ms=int(datetime.now(tz=UTC).timestamp() * 1000),
|
||||
request_type=request_type,
|
||||
queries=payload,
|
||||
)
|
||||
@@ -641,6 +642,7 @@ def test_trace_operator_select_fields(
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
use_attribute_backend: Callable[[str], None],
|
||||
attribute_backend: str,
|
||||
select_fields: list[dict[str, Any]],
|
||||
expansion: str,
|
||||
@@ -665,9 +667,9 @@ def test_trace_operator_select_fields(
|
||||
- pkg/telemetrytraces/trace_operator_cte_builder.go::buildListQuery for
|
||||
the per-row SELECT.
|
||||
"""
|
||||
now = get_attribute_backend_now(signoz, attribute_backend)
|
||||
use_attribute_backend(attribute_backend)
|
||||
|
||||
now = now.replace(second=0, microsecond=0)
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
|
||||
trace_id = TraceIdGenerator.trace_id()
|
||||
parent_span_id = TraceIdGenerator.span_id()
|
||||
@@ -752,8 +754,8 @@ def test_trace_operator_select_fields(
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((now - timedelta(minutes=5)).timestamp() * 1000),
|
||||
end_ms=int(now.timestamp() * 1000),
|
||||
start_ms=int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000),
|
||||
end_ms=int(datetime.now(tz=UTC).timestamp() * 1000),
|
||||
request_type=RequestType.RAW,
|
||||
queries=queries,
|
||||
)
|
||||
@@ -790,6 +792,7 @@ def test_trace_operator_scalar_limit(
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
use_attribute_backend: Callable[[str], None],
|
||||
attribute_backend: str,
|
||||
) -> None:
|
||||
"""
|
||||
@@ -800,9 +803,9 @@ def test_trace_operator_scalar_limit(
|
||||
1. A scalar trace operator grouped by service.name with limit=2 returns
|
||||
only the top two groups by count.
|
||||
"""
|
||||
now = get_attribute_backend_now(signoz, attribute_backend)
|
||||
use_attribute_backend(attribute_backend)
|
||||
|
||||
now = now.replace(second=0, microsecond=0)
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
span_counts = {"limit-a": 4, "limit-b": 3, "limit-c": 2, "limit-d": 1}
|
||||
|
||||
insert_traces(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
@@ -12,7 +12,6 @@ from fixtures.querier import (
|
||||
build_group_by_field,
|
||||
build_raw_query,
|
||||
build_traces_scalar_query,
|
||||
get_attribute_backend_now,
|
||||
get_rows,
|
||||
get_scalar_table_data,
|
||||
make_query_request,
|
||||
@@ -29,12 +28,14 @@ def test_traces_filter_unknown_key_synthesizes(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
use_attribute_backend: Callable[[str], None],
|
||||
attribute_backend: str,
|
||||
) -> None:
|
||||
"""An unknown attribute key runs against synthesized columns and returns 200 with a
|
||||
"not found" warning so typos still surface."""
|
||||
now = get_attribute_backend_now(signoz, attribute_backend)
|
||||
use_attribute_backend(attribute_backend)
|
||||
|
||||
now = datetime.now(tz=UTC)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
@@ -62,13 +63,15 @@ def test_traces_filter_unknown_resource_key_filters(
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
use_attribute_backend: Callable[[str], None],
|
||||
attribute_backend: str,
|
||||
) -> None:
|
||||
"""A resource-qualified unknown key must FILTER (0 rows), not be dropped: the
|
||||
resource-fingerprint sub-query skips keys absent from metadata, so the synthesized
|
||||
condition stays in the main query. A regression here returns every span."""
|
||||
now = get_attribute_backend_now(signoz, attribute_backend)
|
||||
use_attribute_backend(attribute_backend)
|
||||
|
||||
now = datetime.now(tz=UTC)
|
||||
insert_traces(
|
||||
[
|
||||
Traces(
|
||||
@@ -114,12 +117,14 @@ def test_traces_group_by_unknown_key_null_bucket(
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
use_attribute_backend: Callable[[str], None],
|
||||
attribute_backend: str,
|
||||
) -> None:
|
||||
"""Grouping by an unknown key runs against synthesized columns: every span lands in
|
||||
the NULL bucket instead of the query failing."""
|
||||
now = get_attribute_backend_now(signoz, attribute_backend)
|
||||
use_attribute_backend(attribute_backend)
|
||||
|
||||
now = datetime.now(tz=UTC)
|
||||
insert_traces(
|
||||
[
|
||||
Traces(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user