Compare commits

..

17 Commits

Author SHA1 Message Date
Abhi Kumar
2449c1b802 refactor(public-dashboard): use lodash noop for the read-only refetch 2026-11-13 14:18:35 +05:30
Abhi Kumar
7122b56373 fix(public-dashboard): fetch list panels unpaged on the public endpoint
The public endpoint takes only the time window, so a query runner can now say it cannot
page and list panels skip the server pager.
2026-11-13 14:18:31 +05:30
Abhi Kumar
55f92f383e Merge branch 'feat/system-dashboard-cost-meter' into feat/public-dashboard-shared 2026-11-13 14:17:02 +05:30
Abhi Kumar
3851339e6c test(cost-meter): find the UTC billing notice by test id 2026-11-13 14:16:53 +05:30
Abhi Kumar
a4dde27546 fix(dashboard): tell a viewer without dashboard read why a system dashboard is missing 2026-11-13 14:16:49 +05:30
Abhi Kumar
0402254209 Merge branch 'feat/dashboard-host-seams' into feat/system-dashboard-cost-meter 2026-11-13 14:13:56 +05:30
Abhi Kumar
4225452554 test(dashboard): build controls test fixtures from typed builders 2026-11-13 14:13:49 +05:30
Abhi Kumar
50dfec7247 fix(dashboard): keep settings and empty-state actions in step with host controls
The settings drawer mounts once in the container, so Add variable still opens it when a
host hides the dashboard actions. The empty state's Configure and New Panel read the same
controls as the toolbar.
2026-11-13 14:13:42 +05:30
Abhi Kumar
f6765286cc feat(public-dashboard): render on the shared dashboard
createPublicDashboardHost mounts the shared tree read-only: variables dropped, charts only, panels
run on the public endpoint by key. The page opens on the shared default range and keeps the
viewer's own only when the link allows changing time; a fixed link can't zoom either, so zoom
becomes panelActions.zoom and CHARTS_ONLY names what it hides.
2026-11-13 14:01:02 +05:30
Abhi Kumar
ce1117eaa0 feat(cost-meter): render the breakdown as a read-only system dashboard
The eight widgets move into a backend system dashboard definition, so the page is data rather than
hand-built V1 cards. The LLM overview and the breakdown share one SystemDashboard mount.

The page drops its own time picker for the dashboard's, and hides panel actions.
2026-11-13 14:00:48 +05:30
Abhi Kumar
2eb46f19fb refactor(dashboard): mount system dashboards through a shared host
LLM Overview moves onto container/SystemDashboard, so later pages reuse its fetch,
loading and error states.
2026-11-13 14:00:21 +05:30
Abhi Kumar
e37e94af53 feat(dashboard): let hosts choose what runs panel queries
A host's queryRunner replaces only the network call; usePanelQuery still owns the window, the
request and the loading states, so renderers are untouched. The runner's key joins every panel
cache key, so results from different runners never mix.
2026-11-13 14:00:03 +05:30
Abhi Kumar
f060e262d2 feat(dashboard): let hosts turn off drag-to-zoom
CHARTS_ONLY lists its panel actions so zoom stays on.
2026-11-13 13:59:55 +05:30
Abhi Kumar
27a11ada95 feat(dashboard): add layout presets for host controls
EMBEDDED and CHARTS_ONLY name the layouts hosts repeat; withControls merges them with overrides item
by item. Presets only decide what shows: readOnly stays separate, so none can bring back a write.
2026-11-13 13:59:50 +05:30
Abhi Kumar
a4d83ea2fd feat(dashboard): let hosts choose which dashboard controls render
controls is grouped by area and on by default. Visibility is kept apart from readOnly: every
control declares whether it writes, and a read-only mount hides the writing ones whatever the
host asks for.

A lockdown test renders the container with every control off and fails on any interactive
element left, so a new control can't ship ungated.
2026-11-13 13:59:38 +05:30
Abhi Kumar
2e4d47a124 test(ai-observability): serve the system dashboard in the overview story
The mock builds the response from the backend's own definition JSON, so the story can't drift
from what the server serves.
2026-11-13 13:59:25 +05:30
Abhi Kumar
5a62bcd85c feat(dashboard): mount a dashboard read-only from any cache entry
The host tells the store where it cached the dashboard and the subtree observes that entry, so the
system dashboard no longer primes the dashboards API cache under a fake id.

readOnly replaces canEditDashboardOverride: it also rejects writes and skips edit-only requests.
2026-11-13 13:59:25 +05:30
174 changed files with 4491 additions and 6079 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 }}"}'

View File

@@ -88,7 +88,7 @@ jobs:
postgres:15 \
clickhouse/clickhouse-server:25.12.5 \
clickhouse/clickhouse-keeper:25.12.5 \
signoz/signoz-otel-collector:v0.144.16-rc.1 \
signoz/signoz-schema-migrator:v0.144.9 \
wiremock/wiremock:2.35.1-1 \
| xargs -P 0 -n 1 docker pull -q
- name: python

View File

@@ -40,7 +40,6 @@ jobs:
suite:
- alertmanager
- alertmanagerrotation
- auditquerier
- basepath
- callbackauthn
- cloudintegrations
@@ -84,8 +83,8 @@ jobs:
- wal
clickhouse-version:
- 25.12.5
signoz-otel-collector-version:
- v0.144.16-rc.1
schema-migrator-version:
- v0.144.6
postgres-version:
- 15
if: |
@@ -156,4 +155,4 @@ jobs:
--sqlite-mode ${{matrix.sqlite-mode}} \
--postgres-version ${{matrix.postgres-version}} \
--clickhouse-version ${{matrix.clickhouse-version}} \
--signoz-otel-collector-version ${{matrix.signoz-otel-collector-version}}
--schema-migrator-version ${{matrix.schema-migrator-version}}

View File

@@ -25613,6 +25613,7 @@ paths:
low (z-score {{$value}}).
summary: Span ingestion anomaly
condition:
algorithm: standard
compositeQuery:
panelType: graph
queries:
@@ -26279,23 +26280,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 +26327,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- rule:delete
- EDITOR
- tokenizer:
- rule:delete
- EDITOR
summary: Delete alert rule
tags:
- rules
@@ -26398,9 +26385,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 +26569,7 @@ paths:
low (z-score {{$value}}).
summary: Span ingestion anomaly
condition:
algorithm: standard
compositeQuery:
panelType: graph
queries:
@@ -27254,27 +27242,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 +27426,7 @@ paths:
low (z-score {{$value}}).
summary: Span ingestion anomaly
condition:
algorithm: standard
compositeQuery:
panelType: graph
queries:
@@ -28116,27 +28087,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 +28156,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 +29045,7 @@ paths:
low (z-score {{$value}}).
summary: Span ingestion anomaly
condition:
algorithm: standard
compositeQuery:
panelType: graph
queries:
@@ -29774,23 +29712,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 +31809,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- rule:list
- VIEWER
- tokenizer:
- rule:list
- VIEWER
summary: List alert rules (v3)
tags:
- rules

View File

@@ -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`)
- `--signoz-otel-collector-version`: SigNoz OTel collector version, used to run the ClickHouse migrations (default: `v0.144.16-rc.1`)
- `--schema-migrator-version` — SigNoz schema migrator version (default: `v0.144.9`)
- `--with-web` — Build the frontend into the SigNoz image (required for e2e)
Example:

View File

@@ -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;

View File

@@ -1,6 +1,7 @@
/* eslint-disable import/no-extraneous-dependencies */
/* eslint-disable sonarjs/cognitive-complexity */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from 'react-query';
import {
autocompletion,
closeCompletion,
@@ -23,16 +24,14 @@ import CodeMirror, {
ViewUpdate,
} from '@uiw/react-codemirror';
import { Button, Popover, Tooltip } from 'antd';
import { TelemetrytypesFieldDataTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { QueryBuilderKeys } from 'constants/queryBuilder';
import { tracesAggregateOperatorOptions } from 'constants/queryBuilderOperators';
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { Info, TriangleAlert } from '@signozhq/icons';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import {
DATA_SOURCE_TO_SIGNAL,
TracesAggregatorOperator,
} from 'types/common/queryBuilder';
import { FieldDataType } from 'types/api/v5/queryRange';
import { TracesAggregatorOperator } from 'types/common/queryBuilder';
import { useQueryBuilderV2Context } from '../../QueryBuilderV2Context';
@@ -42,12 +41,6 @@ const chipDecoration = Decoration.mark({
class: 'chip-decorator',
});
const OPERATORS_WITHOUT_DATA_TYPE: string[] = [
TracesAggregatorOperator.COUNT,
TracesAggregatorOperator.COUNT_DISTINCT,
TracesAggregatorOperator.RATE,
];
const operatorArgMeta: Record<
string,
{ acceptsArgs: boolean; multiple: boolean }
@@ -317,19 +310,31 @@ function QueryAggregationSelect({
// Find function context for fetching suggestions
const functionContextForFetch = getFunctionContextAtCursor(input, cursorPos);
const numericKeysOnly = !(
functionContextForFetch &&
OPERATORS_WITHOUT_DATA_TYPE.includes(functionContextForFetch)
);
const { data: fieldKeys, isLoading: isLoadingFields } = useFieldKeysSuggestion(
{
signal: DATA_SOURCE_TO_SIGNAL[queryData.dataSource],
searchText: '',
fieldDataType: numericKeysOnly
? TelemetrytypesFieldDataTypeDTO.number
: undefined,
const { data: aggregateAttributeData, isLoading: isLoadingFields } = useQuery(
[
QueryBuilderKeys.GET_AGGREGATE_ATTRIBUTE,
functionContextForFetch,
queryData.dataSource,
],
() => {
const operatorsWithoutDataType: (string | undefined)[] = [
TracesAggregatorOperator.COUNT,
TracesAggregatorOperator.COUNT_DISTINCT,
TracesAggregatorOperator.RATE,
];
const fieldDataType: FieldDataType | undefined =
functionContextForFetch &&
operatorsWithoutDataType.includes(functionContextForFetch)
? undefined
: 'number';
return getKeySuggestions({
signal: queryData.dataSource,
searchText: '',
fieldDataType,
});
},
queryData.builderQueryType,
{
enabled:
!!functionContextForFetch &&
@@ -422,42 +427,48 @@ function QueryAggregationSelect({
}),
);
// Memoize field suggestions from API (no search filtering here)
// Memoize field suggestions from API (no filtering here)
const fieldSuggestions = useMemo(
() =>
(fieldKeys ?? []).map((key) => ({
// The backend reads trace.-prefixed columns as per-trace aggregates.
label: key.fieldContext === 'trace' ? `trace.${key.name}` : key.name,
type: 'variable',
info: key.fieldDataType,
apply: (
view: EditorView,
completion: Completion,
from: number,
to: number,
): void => {
const text = view.state.sliceDoc(0, from);
const funcName = getFunctionContextAtCursor(text, from);
const multiple = funcName ? operatorArgMeta[funcName]?.multiple : false;
Object.keys(aggregateAttributeData?.data.data.keys || {}).flatMap((key) => {
const attributeKeys = aggregateAttributeData?.data.data.keys[key];
if (!attributeKeys) {
return [];
}
// Insert the selected key followed by either a comma or closing parenthesis
const insertText = multiple
? `${completion.label},`
: `${completion.label}) `;
const cursorPos = from + insertText.length; // Use insertText.length instead of hardcoded values
return attributeKeys.map((attributeKey) => ({
label: attributeKey.name,
type: 'variable',
info: attributeKey.fieldDataType,
apply: (
view: EditorView,
completion: Completion,
from: number,
to: number,
): void => {
const text = view.state.sliceDoc(0, from);
const funcName = getFunctionContextAtCursor(text, from);
const multiple = funcName ? operatorArgMeta[funcName]?.multiple : false;
view.dispatch({
changes: { from, to, insert: insertText },
selection: { anchor: cursorPos },
});
// Insert the selected key followed by either a comma or closing parenthesis
const insertText = multiple
? `${completion.label},`
: `${completion.label}) `;
const cursorPos = from + insertText.length; // Use insertText.length instead of hardcoded values
// Trigger next suggestions after a small delay
setTimeout(() => {
safeStartCompletion();
}, 50);
},
})),
[fieldKeys, safeStartCompletion],
view.dispatch({
changes: { from, to, insert: insertText },
selection: { anchor: cursorPos },
});
// Trigger next suggestions after a small delay
setTimeout(() => {
safeStartCompletion();
}, 50);
},
}));
}) || [],
[aggregateAttributeData, safeStartCompletion],
);
const aggregatorAutocomplete = useMemo(

View File

@@ -1,168 +0,0 @@
import {
GetAIObservabilityFieldsKeys200,
GetFieldsKeys200,
TelemetrytypesFieldContextDTO,
TelemetrytypesFieldDataTypeDTO,
TelemetrytypesSignalDTO,
TelemetrytypesTelemetryFieldKeyDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ENVIRONMENT } from 'constants/env';
import { initialQueriesMap } from 'constants/queryBuilder';
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { QueryBuilderV2Provider } from '../../QueryBuilderV2Context';
import QueryAggregationSelect from '../QueryAggregation/QueryAggregationSelect';
import { mockCodeMirrorDomApis } from './codemirrorDomMocks';
const EDITOR_TEST_ID = 'query-aggregation-editor';
// CodeMirror's suggestion popup is slow to open under a parallel jest run.
const SUGGESTIONS_TIMEOUT_MS = 5000;
const TEST_TIMEOUT_MS = 15000;
const fieldKey = (
name: string,
fieldContext: TelemetrytypesFieldContextDTO,
fieldDataType: TelemetrytypesFieldDataTypeDTO,
): TelemetrytypesTelemetryFieldKeyDTO => ({
name,
fieldContext,
fieldDataType,
signal: TelemetrytypesSignalDTO.traces,
});
const durationNano = fieldKey(
'duration_nano',
TelemetrytypesFieldContextDTO.span,
TelemetrytypesFieldDataTypeDTO.float64,
);
const serviceName = fieldKey(
'service.name',
TelemetrytypesFieldContextDTO.resource,
TelemetrytypesFieldDataTypeDTO.string,
);
const totalTokens = fieldKey(
'total_tokens',
TelemetrytypesFieldContextDTO.trace,
TelemetrytypesFieldDataTypeDTO.float64,
);
const aiKeysResponse = (
keys: TelemetrytypesTelemetryFieldKeyDTO[],
): GetAIObservabilityFieldsKeys200 => ({
status: 'success',
data: {
complete: true,
keys: Object.fromEntries(keys.map((key) => [key.name, [key]])),
},
});
// Assumes the AI endpoint's number filter also returns the float64 trace aggregates.
const aiKeysFor = (
params: URLSearchParams,
): TelemetrytypesTelemetryFieldKeyDTO[] =>
params.get('fieldDataType') === TelemetrytypesFieldDataTypeDTO.number
? [durationNano, totalTokens]
: [durationNano, serviceName, totalTokens];
const keysResponse: GetFieldsKeys200 = {
status: 'success',
data: { complete: true, keys: { duration_nano: [durationNano] } },
};
const aiQuery: IBuilderQuery = {
...(initialQueriesMap.traces.builder.queryData[0] as IBuilderQuery),
builderQueryType: 'builder_ai_query',
aggregations: [],
};
const aiKeysRequests: URLSearchParams[] = [];
const keysRequests: URLSearchParams[] = [];
beforeAll(() => {
mockCodeMirrorDomApis();
});
beforeEach(() => {
aiKeysRequests.length = 0;
keysRequests.length = 0;
server.use(
rest.get(
`${ENVIRONMENT.baseURL}/api/v1/ai_observability/fields/keys`,
(req, res, ctx) => {
aiKeysRequests.push(req.url.searchParams);
return res(
ctx.status(200),
ctx.json(aiKeysResponse(aiKeysFor(req.url.searchParams))),
);
},
),
rest.get(`${ENVIRONMENT.baseURL}/api/v1/fields/keys`, (req, res, ctx) => {
keysRequests.push(req.url.searchParams);
return res(ctx.status(200), ctx.json(keysResponse));
}),
);
});
async function enterAggregation(text: string): Promise<void> {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<QueryBuilderV2Provider>
<QueryAggregationSelect queryData={aiQuery} />
</QueryBuilderV2Provider>,
);
const content = (
await screen.findByTestId(EDITOR_TEST_ID)
).querySelector<HTMLElement>('.cm-content');
if (!content) {
throw new Error('CodeMirror content element not rendered');
}
await user.click(content);
// Typed keys drop under load: a re-render's dispatch discards DOM input CodeMirror hasn't read yet.
await user.paste(text);
await user.keyboard('{Control>} {/Control}');
}
function suggestionLabels(): string[] {
return Array.from(
screen.getByTestId(EDITOR_TEST_ID).querySelectorAll('.cm-completionLabel'),
(label) => label.textContent ?? '',
).sort();
}
describe('QueryAggregationSelect field suggestions for an AI query', () => {
it.each([
{
aggregation: 'avg(',
fieldDataType: TelemetrytypesFieldDataTypeDTO.number,
labels: ['duration_nano', 'trace.total_tokens'],
},
{
aggregation: 'count_distinct(',
fieldDataType: null,
labels: ['duration_nano', 'service.name', 'trace.total_tokens'],
},
])(
'suggests keys from the AI keys endpoint, with trace aggregates as trace.<name>, for $aggregation',
async ({ aggregation, fieldDataType, labels }) => {
await enterAggregation(aggregation);
await waitFor(() => expect(suggestionLabels()).toStrictEqual(labels), {
timeout: SUGGESTIONS_TIMEOUT_MS,
});
expect(keysRequests).toHaveLength(0);
expect(
aiKeysRequests.map((params) => ({
signal: params.get('signal'),
fieldDataType: params.get('fieldDataType'),
fieldContext: params.get('fieldContext'),
})),
).toStrictEqual([{ signal: 'traces', fieldDataType, fieldContext: null }]);
},
TEST_TIMEOUT_MS,
);
});

View File

@@ -1,6 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useDispatch } from 'react-redux';
import { useEffect, useState } from 'react';
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
import { matchRoute } from 'lib/router/matchRoute';
import { navigate } from 'lib/router/navigation';
@@ -28,7 +26,6 @@ import {
} from 'api/ai-assistant/chat';
import ROUTES from 'constants/routes';
import { QueryParams } from 'constants/query';
import { UpdateTimeInterval } from 'store/actions';
import { buildExplorerNavigationUrl } from 'utils/buildExplorerNavigationUrl';
import { openInNewTab } from 'utils/navigation';
import {
@@ -56,7 +53,6 @@ import {
getPanelTypeForRequestType,
requestTypeFromActionQuery,
} from './utils/getPanelTypeForRequestType';
import { getTimeRangeFromActionQuery } from './utils/getTimeRangeFromActionQuery';
import { isOnResourcePage } from './utils/isOnResourcePage';
import { openSavedViewByKey } from './utils/openSavedView';
import {
@@ -283,7 +279,6 @@ interface ApplyFilterDeps {
typeof useQueryBuilder
>['redirectWithQueryBuilderData'];
handleSetQueryData: ReturnType<typeof useQueryBuilder>['handleSetQueryData'];
setCustomTimeRange: (start: number, end: number) => void;
}
/**
@@ -349,35 +344,18 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
requestTypeFromActionQuery(action.query),
action.signal,
);
const timeRange = getTimeRangeFromActionQuery(action.query);
const normalized = withDerivedFilterExpressions(urlQuery as unknown as Query);
// eslint-disable-next-line no-console
console.log('[apply_filter] normalized', normalized);
if (signalMatchesPathname(action.signal, deps.pathname)) {
// eslint-disable-next-line no-console
console.log('[apply_filter] on-page → handleSetQueryData + redirect');
if (timeRange) {
deps.setCustomTimeRange(timeRange.start, timeRange.end);
}
normalized.builder.queryData.forEach((q, i) => {
deps.handleSetQueryData(i, q);
});
deps.redirectWithQueryBuilderData(
normalized,
{
[QueryParams.panelTypes]: panelType,
...(timeRange && {
[QueryParams.startTime]: timeRange.start,
[QueryParams.endTime]: timeRange.end,
// relativeTime outranks startTime/endTime in the time picker, so drop it.
[QueryParams.relativeTime]: null,
}),
},
undefined,
undefined,
// New params like startTime would otherwise make safeNavigate replace the entry.
{ replace: false },
);
deps.redirectWithQueryBuilderData(normalized, {
[QueryParams.panelTypes]: panelType,
});
return;
}
const base = explorerRouteForSignal(action.signal);
@@ -390,10 +368,6 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
console.log('[apply_filter] off-page → navigate', base);
const url = buildExplorerNavigationUrl(base, normalized, {
[QueryParams.panelTypes]: panelType,
...(timeRange && {
[QueryParams.startTime]: timeRange.start,
[QueryParams.endTime]: timeRange.end,
}),
});
navigate(url);
}
@@ -428,13 +402,6 @@ export default function ActionsSection({
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
const { threadId, page, mode } = useAIAssistantAnalyticsContext();
const { redirectWithQueryBuilderData, handleSetQueryData } = useQueryBuilder();
const dispatch = useDispatch();
const setCustomTimeRange = useCallback(
(start: number, end: number): void => {
dispatch(UpdateTimeInterval('custom', [start, end]));
},
[dispatch],
);
// Per-chip click state, keyed by chip key (see `key` below). Persists
// loading/success/error so the chip reflects the rollback outcome until
@@ -466,16 +433,9 @@ export default function ActionsSection({
pathname,
redirectWithQueryBuilderData,
handleSetQueryData,
setCustomTimeRange,
});
});
}, [
actions,
pathname,
redirectWithQueryBuilderData,
handleSetQueryData,
setCustomTimeRange,
]);
}, [actions, pathname, redirectWithQueryBuilderData, handleSetQueryData]);
if (actions.length === 0) {
return null;
@@ -608,7 +568,6 @@ export default function ActionsSection({
pathname,
redirectWithQueryBuilderData,
handleSetQueryData,
setCustomTimeRange,
});
break;
}

View File

@@ -9,7 +9,6 @@ import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { getCurrentLocation } from 'lib/router/navigation';
import { UpdateTimeInterval } from 'store/actions';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { QueryBuilderContextType } from 'types/common/queryBuilder';
@@ -25,17 +24,7 @@ jest.mock('api/common/logEvent', () => ({
default: jest.fn(),
}));
jest.mock('store/actions', () => {
const actual =
jest.requireActual<typeof import('store/actions')>('store/actions');
return { ...actual, UpdateTimeInterval: jest.fn(actual.UpdateTimeInterval) };
});
const mockUndo = undoExecution as jest.MockedFunction<typeof undoExecution>;
const mockUpdateTimeInterval = jest.mocked(UpdateTimeInterval);
const START_MS = Date.UTC(2026, 6, 26, 10, 34);
const END_MS = Date.UTC(2026, 6, 26, 10, 49);
const UNDO_ACTION = {
kind: MessageActionKindDTO.undo,
@@ -49,18 +38,13 @@ function renderUndoChip(): HTMLElement {
}
// Labels differ per test: auto-applied filters are deduped module-wide by label.
function countByServiceAction(
label: string,
{ start, end }: { start?: number; end?: number } = {},
): MessageActionDTO {
function countByServiceAction(label: string): MessageActionDTO {
return {
kind: MessageActionKindDTO.apply_filter,
label,
signal: ApplyFilterSignalDTO.logs,
query: {
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
start,
end,
compositeQuery: {
queries: [
{
@@ -78,8 +62,10 @@ function countByServiceAction(
};
}
function currentParam(param: string): string | null {
return new URLSearchParams(getCurrentLocation().search).get(param);
function currentPanelTypesParam(): string | null {
return new URLSearchParams(getCurrentLocation().search).get(
QueryParams.panelTypes,
);
}
describe('ActionsSection rollback chip', () => {
@@ -153,34 +139,7 @@ describe('ActionsSection apply_filter', () => {
await waitFor(() =>
expect(getCurrentLocation().pathname).toBe(ROUTES.LOGS_EXPLORER),
);
expect(currentParam(QueryParams.panelTypes)).toBe(
JSON.stringify(PANEL_TYPES.TABLE),
);
});
it('opens the explorer on the time range that produced the answer', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<ActionsSection
actions={[
countByServiceAction('Open with time', {
start: START_MS,
end: END_MS,
}),
]}
messageId="message-with-time"
/>,
undefined,
{ initialRoute: ROUTES.HOME },
);
await user.click(screen.getByTestId('ai-action-chip-apply_filter'));
await waitFor(() =>
expect(getCurrentLocation().pathname).toBe(ROUTES.LOGS_EXPLORER),
);
expect(currentParam(QueryParams.startTime)).toBe(String(START_MS));
expect(currentParam(QueryParams.endTime)).toBe(String(END_MS));
expect(currentPanelTypesParam()).toBe(JSON.stringify(PANEL_TYPES.TABLE));
});
it('switches the open explorer to the Table view when applied on it', async () => {
@@ -208,54 +167,6 @@ describe('ActionsSection apply_filter', () => {
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({ builder: expect.anything() }),
{ [QueryParams.panelTypes]: PANEL_TYPES.TABLE },
undefined,
undefined,
{ replace: false },
);
});
it('switches the open explorer to the answer time range', async () => {
const redirectWithQueryBuilderData = jest.fn() as jest.MockedFunction<
QueryBuilderContextType['redirectWithQueryBuilderData']
>;
const handleSetQueryData = jest.fn() as jest.MockedFunction<
QueryBuilderContextType['handleSetQueryData']
>;
render(
<ActionsSection
actions={[
countByServiceAction('Apply with time', {
start: START_MS,
end: END_MS,
}),
]}
messageId="message-on-page-with-time"
/>,
undefined,
{
initialRoute: ROUTES.LOGS_EXPLORER,
queryBuilderOverrides: { redirectWithQueryBuilderData, handleSetQueryData },
},
);
await waitFor(() =>
expect(redirectWithQueryBuilderData).toHaveBeenCalledTimes(1),
);
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({ builder: expect.anything() }),
{
[QueryParams.panelTypes]: PANEL_TYPES.TABLE,
[QueryParams.startTime]: START_MS,
[QueryParams.endTime]: END_MS,
[QueryParams.relativeTime]: null,
},
undefined,
undefined,
{ replace: false },
);
expect(mockUpdateTimeInterval).toHaveBeenCalledWith('custom', [
START_MS,
END_MS,
]);
});
});

View File

@@ -1,25 +0,0 @@
import type { MessageActionDTOQuery } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { normalizeTimeToMs } from 'utils/timeUtils';
export interface ActionTimeRange {
start: number;
end: number;
}
function toTimestampMs(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
return null;
}
return normalizeTimeToMs(Math.trunc(value));
}
export function getTimeRangeFromActionQuery(
query: MessageActionDTOQuery | undefined,
): ActionTimeRange | null {
const start = toTimestampMs(query?.start);
const end = toTimestampMs(query?.end);
if (start === null || end === null || end <= start) {
return null;
}
return { start, end };
}

View File

@@ -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,

View File

@@ -324,6 +324,7 @@ export function buildCreateAnomalyAlertRulePayload(
condition: {
...payload.condition,
thresholds: { kind: 'basic', spec: thresholds },
algorithm: thresholdState.algorithm,
seasonality: thresholdState.seasonality,
},
};

View File

@@ -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',
});
});

View File

@@ -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,
};

View File

@@ -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(

View File

@@ -104,5 +104,5 @@ describe('AI observability Explorer', () => {
...defaultOptionsQuery,
selectColumns: [],
});
}, 15_000);
});
});

View File

@@ -3,24 +3,4 @@
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-0) var(--spacing-0);
//TODO: remove this once we have a proper dashboard page.
// The embedded V2 DashboardContainer renders its own page header (dashboard
// title + share/feedback chrome) removed it for now
:global([class*='dashboardPageHeader']),
:global([class*='dashboardInfoWithActions']) {
display: none;
}
// Embedded read-only: the add-variable and panel-action triggers are dead here.
:global([class*='addSlot']),
:global([data-testid^='panel-actions-']) {
display: none;
}
}
.errorState {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-8);
}

View File

@@ -1,47 +1,24 @@
import { useEffect } from 'react';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import Spinner from 'components/Spinner';
import DashboardContainer from 'pages/DashboardPage/DashboardContainer';
import SystemDashboard from 'container/SystemDashboard/SystemDashboard';
import { createSystemDashboardHost } from 'container/SystemDashboard/systemDashboardHost';
import { CHARTS_ONLY } from 'pages/DashboardPage/DashboardContainer/controls/presets';
import { useSystemDashboard } from './hooks/useSystemDashboard';
import styles from './Overview.module.scss';
function Overview(): JSX.Element {
const { dashboard, isLoading, isError, error, refetch } = useSystemDashboard();
const AI_OVERVIEW = createSystemDashboardHost(
'ai-o11y-overview',
'llm-observability-overview',
);
function Overview(): JSX.Element {
useEffect(() => {
void logEvent('AI Observability Overview: Page visited', {});
}, []);
const renderContent = (): JSX.Element => {
if (isLoading) {
return <Spinner tip="Loading dashboard..." />;
}
if (isError || !dashboard) {
return (
<div className={styles.errorState}>
<Typography.Title>Failed to load dashboard</Typography.Title>
<Typography.Text>
{error?.response?.data?.error?.message ?? error?.message}
</Typography.Text>
</div>
);
}
return (
<DashboardContainer
dashboard={dashboard}
refetch={refetch}
canEditDashboardOverride={false}
/>
);
};
return (
<div className={styles.overview} data-testid="llm-observability-overview">
{renderContent()}
<SystemDashboard host={AI_OVERVIEW} controls={CHARTS_ONLY} />
</div>
);
}

View File

@@ -1,56 +0,0 @@
import { useCallback, useMemo } from 'react';
import { useQueryClient } from 'react-query';
import {
getGetDashboardV2QueryKey,
useGetSystemDashboard,
} from 'api/generated/services/dashboard';
import type { GetSystemDashboardQueryError } from 'api/generated/services/dashboard';
import type {
DashboardtypesGettableDashboardV2DTO,
GetDashboardV2200,
} from 'api/generated/services/sigNoz.schemas';
const SYSTEM_DASHBOARD_NAME = 'ai-o11y-overview';
const DASHBOARD_ID = 'llm-observability-overview';
export interface UseSystemDashboardResult {
dashboard: DashboardtypesGettableDashboardV2DTO | undefined;
isLoading: boolean;
isError: boolean;
error: GetSystemDashboardQueryError | null;
refetch: () => void;
}
export function useSystemDashboard(): UseSystemDashboardResult {
const queryClient = useQueryClient();
const { data, isLoading, isError, error, refetch } = useGetSystemDashboard(
{ name: SYSTEM_DASHBOARD_NAME },
{ query: { staleTime: Infinity, refetchOnMount: false } },
);
const dashboard = useMemo(
() =>
data
? ({
...data.data,
id: DASHBOARD_ID,
} as DashboardtypesGettableDashboardV2DTO)
: undefined,
[data],
);
if (dashboard) {
queryClient.setQueryData<GetDashboardV2200>(
getGetDashboardV2QueryKey({ id: DASHBOARD_ID }),
{ data: dashboard, status: 'success' },
);
}
const refetchDashboard = useCallback((): void => {
void refetch();
}, [refetch]);
return { dashboard, isLoading, isError, error, refetch: refetchDashboard };
}

View File

@@ -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(

View File

@@ -2,77 +2,14 @@
display: flex;
flex-direction: column;
.meter-explorer-date-time {
display: flex;
min-height: 30px;
justify-content: end;
border-bottom: 1px solid var(--l1-border);
padding: 10px 16px;
}
.meter-explorer-graphs {
.meter-explorer-notices {
display: flex;
flex-direction: column;
padding: 20px;
gap: 36px;
gap: 12px;
padding: 16px 20px 0;
.info {
display: flex;
flex-direction: column;
gap: 12px;
}
.meter-column-graph {
.row-card {
background-color: var(--l2-background);
padding-left: 10px;
height: 32px;
display: flex;
justify-content: center;
align-items: center;
.section-title {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px;
letter-spacing: -0.07px;
}
}
.graph-description {
padding: 10px;
display: flex;
justify-content: center;
align-items: center;
}
.meter-page-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
align-items: flex-start;
gap: 10px;
.meter-graph {
height: 400px;
padding: 10px;
width: 100%;
box-sizing: border-box;
}
}
}
.total {
.meter-column-graph {
.meter-page-grid {
grid-template-columns: repeat(3, 1fr);
.meter-graph {
height: 200px;
}
}
}
&:empty {
display: none;
}
}
}

View File

@@ -1,121 +1,30 @@
import { useCallback } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useDispatch, useSelector } from 'react-redux';
import { useAppLocation } from 'lib/router/useAppLocation';
import { navigate } from 'lib/router/navigation';
import { useSelector } from 'react-redux';
import { Alert } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import GridCard from 'container/WidgetCard/Card';
import { Card, CardContainer } from 'container/WidgetCard/styles';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import SystemDashboard from 'container/SystemDashboard/SystemDashboard';
import { createSystemDashboardHost } from 'container/SystemDashboard/systemDashboardHost';
import dayjs from 'dayjs';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import useUrlQuery from 'hooks/useUrlQuery';
import { UpdateTimeInterval } from 'store/actions';
import { AppState } from 'store/reducers';
import { Widgets } from 'types/api/widgets/widget';
import { GlobalReducer } from 'types/reducer/globalTime';
import { v4 as uuid } from 'uuid';
import {
getLogCountWidgetData,
getLogSizeWidgetData,
getMetricCountWidgetData,
getSpanCountWidgetData,
getSpanSizeWidgetData,
getTotalLogSizeWidgetData,
getTotalMetricDatapointCountWidgetData,
getTotalTraceSizeWidgetData,
} from './graphs';
CHARTS_ONLY,
withControls,
} from 'pages/DashboardPage/DashboardContainer/controls/presets';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import './BreakDown.styles.scss';
type MetricSection = {
id: string;
title: string;
graphs: Widgets[];
};
const COST_METER = createSystemDashboardHost(
'cost-meter-breakdown',
'cost-meter-breakdown',
);
const sections: MetricSection[] = [
{
id: uuid(),
title: 'Total',
graphs: [
getTotalLogSizeWidgetData(),
getTotalTraceSizeWidgetData(),
getTotalMetricDatapointCountWidgetData(),
],
},
{
id: uuid(),
title: 'Logs',
graphs: [getLogCountWidgetData(), getLogSizeWidgetData()],
},
{
id: uuid(),
title: 'Traces',
graphs: [getSpanCountWidgetData(), getSpanSizeWidgetData()],
},
{
id: uuid(),
title: 'Metrics',
graphs: [getMetricCountWidgetData()],
},
];
function Section(section: MetricSection): JSX.Element {
const isDarkMode = useIsDarkMode();
const { title, graphs } = section;
const { pathname } = useAppLocation();
const dispatch = useDispatch();
const urlQuery = useUrlQuery();
const onDragSelect = useCallback(
(start: number, end: number) => {
const startTimestamp = Math.trunc(start);
const endTimestamp = Math.trunc(end);
urlQuery.set(QueryParams.startTime, startTimestamp.toString());
urlQuery.set(QueryParams.endTime, endTimestamp.toString());
const generatedUrl = `${pathname}?${urlQuery.toString()}`;
navigate(generatedUrl);
if (startTimestamp !== endTimestamp) {
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
}
},
[dispatch, pathname, urlQuery],
);
return (
<div
className="meter-column-graph"
data-testid={`meter-breakdown-section-${title.toLowerCase()}`}
>
<CardContainer className="row-card" isDarkMode={isDarkMode}>
<Typography.Text className="section-title">{title}</Typography.Text>
</CardContainer>
<div className="meter-page-grid">
{graphs.map((widget) => (
<Card
key={widget?.id}
isDarkMode={isDarkMode}
$panelType={PANEL_TYPES.BAR}
className="meter-graph"
>
<GridCard widget={widget} onDragSelect={onDragSelect} version="v5" />
</Card>
))}
</div>
</div>
);
}
const CONTROLS = withControls(CHARTS_ONLY, {
toolbar: { autoRefresh: false },
});
function BreakDown(): JSX.Element {
const { isCloudUser } = useGetTenantLicense();
@@ -133,12 +42,10 @@ function BreakDown(): JSX.Element {
return (
<div className="meter-explorer-breakdown">
<section className="meter-explorer-date-time">
<DateTimeSelectionV2 showAutoRefresh={false} />
</section>
<section className="meter-explorer-graphs">
<section className="meter-explorer-notices">
{showInfo && (
<Alert
data-testid="cost-meter-utc-notice"
type="info"
showIcon
closable
@@ -179,28 +86,8 @@ function BreakDown(): JSX.Element {
}
/>
)}
<section className="total">
<Section
id={sections[0].id}
title={sections[0].title}
graphs={sections[0].graphs}
/>
</section>
{sections.map((section, idx) => {
if (idx === 0) {
return;
}
return (
<Section
key={section.id}
id={section.id}
title={section.title}
graphs={section.graphs}
/>
);
})}
</section>
<SystemDashboard host={COST_METER} controls={CONTROLS} />
</div>
);
}

View File

@@ -0,0 +1,51 @@
import type { DashboardControlsInput } from 'pages/DashboardPage/DashboardContainer/controls/types';
import { resolveControls } from 'pages/DashboardPage/DashboardContainer/controls/dashboardControls';
import { render, screen } from 'tests/test-utils';
import type { SystemDashboardHost } from 'container/SystemDashboard/systemDashboardHost';
import BreakDown from '../BreakDown';
jest.mock('container/SystemDashboard/SystemDashboard', () => ({
__esModule: true,
default: ({
host,
controls,
}: {
host: SystemDashboardHost;
controls: DashboardControlsInput;
}): JSX.Element => (
<div
data-testid="system-dashboard"
data-name={host.name}
data-controls={JSON.stringify(controls)}
/>
),
}));
describe('Cost meter breakdown', () => {
it('mounts the cost meter system dashboard as charts only, without auto-refresh', () => {
render(<BreakDown />);
const dashboard = screen.getByTestId('system-dashboard');
expect(dashboard).toHaveAttribute('data-name', 'cost-meter-breakdown');
const controls = resolveControls(
JSON.parse(dashboard.getAttribute('data-controls') ?? ''),
true,
);
expect(controls.header.breadcrumbs).toBe(false);
expect(controls.panelActions.createAlert).toBe(false);
expect(controls.panelActions.drilldown).toBe(false);
expect(controls.panelActions.zoom).toBe(true);
expect(controls.toolbar.autoRefresh).toBe(false);
expect(controls.toolbar.timeRange).toBe(true);
});
it('keeps the UTC billing notice above the dashboard', () => {
render(<BreakDown />);
expect(screen.getByTestId('cost-meter-utc-notice')).toHaveTextContent(
'Billing is calculated in UTC',
);
});
});

View File

@@ -1,374 +0,0 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { GetWidgetQueryBuilderProps } from 'container/MetricsApplication/types';
import { Widgets } from 'types/api/widgets/widget';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderFormula,
IBuilderQuery,
} from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 as uuid } from 'uuid';
interface GetWidgetQueryProps {
title: string;
description: string;
queryData: IBuilderQuery[];
queryFormulas?: IBuilderFormula[];
panelTypes?: PANEL_TYPES;
yAxisUnit?: string;
columnUnits?: Record<string, string>;
}
interface GetWidgetQueryPropsReturn extends GetWidgetQueryBuilderProps {
description?: string;
nullZeroValues: string;
columnUnits?: Record<string, string>;
}
export const getWidgetQueryBuilder = ({
query,
title = '',
panelTypes,
yAxisUnit = '',
fillSpans = false,
id,
nullZeroValues,
description,
}: GetWidgetQueryPropsReturn): Widgets => ({
description: description || '',
id: id || uuid(),
nullZeroValues: nullZeroValues || '',
opacity: '1',
panelTypes,
query,
timePreferance: 'GLOBAL_TIME',
title,
yAxisUnit,
softMax: null,
softMin: null,
selectedLogFields: [],
selectedTracesFields: [],
fillSpans,
});
export function getWidgetQuery(
props: GetWidgetQueryProps,
): GetWidgetQueryPropsReturn {
const { title, description, panelTypes, yAxisUnit, columnUnits } = props;
return {
title,
yAxisUnit: yAxisUnit || 'none',
panelTypes: panelTypes || PANEL_TYPES.TIME_SERIES,
fillSpans: false,
description,
nullZeroValues: 'zero',
columnUnits,
query: {
queryType: EQueryType.QUERY_BUILDER,
promql: [],
builder: {
queryData: props.queryData,
queryFormulas: (props.queryFormulas as IBuilderFormula[]) || [],
queryTraceOperator: [],
},
clickhouse_sql: [],
id: uuid(),
},
};
}
export const getTotalLogSizeWidgetData = (): Widgets =>
getWidgetQueryBuilder(
getWidgetQuery({
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
key: 'signoz.meter.log.size',
id: 'signoz.meter.log.size--float64--Sum--true',
type: 'Sum',
},
aggregateOperator: 'increase',
dataSource: DataSource.METRICS,
source: 'meter',
disabled: false,
expression: 'A',
filters: { items: [], op: 'AND' },
functions: [],
groupBy: [],
having: [],
legend: 'count',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.SUM,
spaceAggregation: 'sum',
stepInterval: null,
timeAggregation: 'increase',
},
],
title: 'Total size of log records ingested',
description: '',
panelTypes: PANEL_TYPES.VALUE,
yAxisUnit: 'bytes',
}),
);
export const getTotalTraceSizeWidgetData = (): Widgets =>
getWidgetQueryBuilder(
getWidgetQuery({
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
key: 'signoz.meter.span.size',
id: 'signoz.meter.span.size--float64--Sum--true',
type: 'Sum',
},
aggregateOperator: 'increase',
dataSource: DataSource.METRICS,
source: 'meter',
disabled: false,
expression: 'A',
filters: { items: [], op: 'AND' },
functions: [],
groupBy: [],
having: [],
legend: 'count',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.SUM,
spaceAggregation: 'sum',
stepInterval: null,
timeAggregation: 'increase',
},
],
title: 'Total size of spans ingested',
description: '',
panelTypes: PANEL_TYPES.VALUE,
yAxisUnit: 'bytes',
}),
);
export const getTotalMetricDatapointCountWidgetData = (): Widgets =>
getWidgetQueryBuilder(
getWidgetQuery({
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
key: 'signoz.meter.metric.datapoint.count',
id: 'signoz.meter.metric.datapoint.count--float64--Sum--true',
type: 'Sum',
},
aggregateOperator: 'increase',
dataSource: DataSource.METRICS,
source: 'meter',
disabled: false,
expression: 'A',
filters: { items: [], op: 'AND' },
functions: [],
groupBy: [],
having: [],
legend: 'count',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.SUM,
spaceAggregation: 'sum',
stepInterval: null,
timeAggregation: 'increase',
},
],
title: 'Total metric datapoints ingested',
description: '',
panelTypes: PANEL_TYPES.VALUE,
yAxisUnit: 'short',
}),
);
export const getLogCountWidgetData = (): Widgets =>
getWidgetQueryBuilder(
getWidgetQuery({
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
key: 'signoz.meter.log.count',
id: 'signoz.meter.log.count--float64--Sum--true',
type: 'Sum',
},
aggregateOperator: 'increase',
dataSource: DataSource.METRICS,
source: 'meter',
disabled: false,
expression: 'A',
filters: { items: [], op: 'AND' },
functions: [],
groupBy: [],
having: [],
legend: 'count',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: null,
timeAggregation: 'increase',
},
],
title: 'Count of log records ingested',
description: '',
panelTypes: PANEL_TYPES.BAR,
yAxisUnit: 'short',
}),
);
export const getLogSizeWidgetData = (): Widgets =>
getWidgetQueryBuilder(
getWidgetQuery({
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
key: 'signoz.meter.log.size',
id: 'signoz.meter.log.size--float64--Sum--true',
type: 'Sum',
},
aggregateOperator: 'increase',
dataSource: DataSource.METRICS,
source: 'meter',
disabled: false,
expression: 'A',
filters: { items: [], op: 'AND' },
functions: [],
groupBy: [],
having: [],
legend: 'size',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: null,
timeAggregation: 'increase',
},
],
title: 'Size of log records ingested',
description: '',
panelTypes: PANEL_TYPES.BAR,
yAxisUnit: 'bytes',
}),
);
export const getSpanCountWidgetData = (): Widgets =>
getWidgetQueryBuilder(
getWidgetQuery({
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
key: 'signoz.meter.span.count',
id: 'signoz.meter.span.count--float64--Sum--true',
type: 'Sum',
},
aggregateOperator: 'increase',
dataSource: DataSource.METRICS,
source: 'meter',
disabled: false,
expression: 'A',
filters: { items: [], op: 'AND' },
functions: [],
groupBy: [],
having: [],
legend: 'count',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: null,
timeAggregation: 'increase',
},
],
title: 'Count of spans ingested',
description: '',
panelTypes: PANEL_TYPES.BAR,
yAxisUnit: 'short',
}),
);
export const getSpanSizeWidgetData = (): Widgets =>
getWidgetQueryBuilder(
getWidgetQuery({
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
key: 'signoz.meter.span.size',
id: 'signoz.meter.span.size--float64--Sum--true',
type: 'Sum',
},
aggregateOperator: 'increase',
dataSource: DataSource.METRICS,
source: 'meter',
disabled: false,
expression: 'A',
filters: { items: [], op: 'AND' },
functions: [],
groupBy: [],
having: [],
legend: 'size',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: null,
timeAggregation: 'increase',
},
],
title: 'Size of spans ingested',
description: '',
panelTypes: PANEL_TYPES.BAR,
yAxisUnit: 'bytes',
}),
);
export const getMetricCountWidgetData = (): Widgets =>
getWidgetQueryBuilder(
getWidgetQuery({
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
key: 'signoz.meter.metric.datapoint.count',
id: 'signoz.meter.metric.datapoint.count--float64--Sum--true',
type: 'Sum',
},
aggregateOperator: 'increase',
dataSource: DataSource.METRICS,
source: 'meter',
disabled: false,
expression: 'A',
filters: { items: [], op: 'AND' },
functions: [],
groupBy: [],
having: [],
legend: 'count',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: null,
timeAggregation: 'increase',
},
],
title: 'Count of metric datapoints ingested',
description: '',
panelTypes: PANEL_TYPES.BAR,
yAxisUnit: 'short',
}),
);

View File

@@ -0,0 +1,7 @@
.errorState {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-8);
}

View File

@@ -0,0 +1,61 @@
import { Typography } from '@signozhq/ui/typography';
import Spinner from 'components/Spinner';
import PermissionDeniedCallout from 'lib/authz/components/PermissionDeniedCallout/PermissionDeniedCallout';
import { buildDashboardReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
import DashboardContainer from 'pages/DashboardPage/DashboardContainer';
import type { DashboardControlsInput } from 'pages/DashboardPage/DashboardContainer/controls/types';
import type { SystemDashboardHost } from './systemDashboardHost';
import { useSystemDashboard } from './useSystemDashboard';
import styles from './SystemDashboard.module.scss';
interface SystemDashboardProps {
host: SystemDashboardHost;
controls?: DashboardControlsInput;
}
/** A shipped, read-only system dashboard, embedded in a feature page. */
function SystemDashboard({
host,
controls,
}: SystemDashboardProps): JSX.Element {
const { dashboard, isLoading, isError, error, refetch } =
useSystemDashboard(host);
if (isLoading) {
return <Spinner tip="Loading dashboard..." />;
}
if (error?.response?.status === 403) {
return (
<div className={styles.errorState} data-testid="system-dashboard-forbidden">
<PermissionDeniedCallout
deniedPermissions={[buildDashboardReadPermission('*')]}
/>
</div>
);
}
if (isError || !dashboard) {
return (
<div className={styles.errorState} data-testid="system-dashboard-error">
<Typography.Title>Failed to load dashboard</Typography.Title>
<Typography.Text>
{error?.response?.data?.error?.message ?? error?.message}
</Typography.Text>
</div>
);
}
return (
<DashboardContainer
dashboard={dashboard}
refetch={refetch}
source={host.source}
readOnly
controls={controls}
/>
);
}
export default SystemDashboard;

View File

@@ -0,0 +1,104 @@
import {
DashboardtypesSourceDTO,
type GetSystemDashboard200,
} from 'api/generated/services/sigNoz.schemas';
import { rest, server } from 'mocks-server/server';
import { render, screen } from 'tests/test-utils';
import SystemDashboard from '../SystemDashboard';
import { createSystemDashboardHost } from '../systemDashboardHost';
const HOST = createSystemDashboardHost('test-overview', 'test-overview-id');
const ENDPOINT = '*/api/v2/dashboards/system/test-overview';
jest.mock('pages/DashboardPage/DashboardContainer', () => ({
__esModule: true,
default: ({
dashboard,
readOnly,
}: {
dashboard: { id: string; spec: { display: { name: string } } };
readOnly: boolean;
}): JSX.Element => (
<div
data-testid="dashboard-container"
data-dashboard-id={dashboard.id}
data-read-only={String(readOnly)}
>
{dashboard.spec.display.name}
</div>
),
}));
const response: GetSystemDashboard200 = {
status: 'success',
data: {
orgId: 'org',
locked: true,
name: 'signoz---test-overview',
schemaVersion: 'v6',
source: DashboardtypesSourceDTO.system,
tags: [],
spec: {
display: { name: 'Test overview' },
variables: [],
panels: {},
layouts: [],
},
},
};
describe('SystemDashboard', () => {
afterEach(() => server.resetHandlers());
it('mounts the named system dashboard read-only under the host id', async () => {
server.use(rest.get(ENDPOINT, (_req, res, ctx) => res(ctx.json(response))));
render(<SystemDashboard host={HOST} />);
const container = await screen.findByTestId('dashboard-container');
expect(container).toHaveTextContent('Test overview');
expect(container).toHaveAttribute('data-dashboard-id', 'test-overview-id');
expect(container).toHaveAttribute('data-read-only', 'true');
});
it('tells a user without dashboard read access why it is missing', async () => {
server.use(
rest.get(ENDPOINT, (_req, res, ctx) =>
res(
ctx.status(403),
ctx.json({
status: 'error',
error: { code: 'forbidden', message: 'forbidden' },
}),
),
),
);
render(<SystemDashboard host={HOST} />);
await expect(
screen.findByTestId('system-dashboard-forbidden'),
).resolves.toHaveTextContent('is not authorized to perform read:dashboard:*');
});
it('shows why the dashboard failed to load', async () => {
server.use(
rest.get(ENDPOINT, (_req, res, ctx) =>
res(
ctx.status(404),
ctx.json({
status: 'error',
error: { code: 'not_found', message: 'not provisioned' },
}),
),
),
);
render(<SystemDashboard host={HOST} />);
await expect(
screen.findByTestId('system-dashboard-error'),
).resolves.toHaveTextContent('not provisioned');
});
});

View File

@@ -0,0 +1,36 @@
import { getGetSystemDashboardQueryKey } from 'api/generated/services/dashboard';
import type {
DashboardtypesGettableDashboardV2DTO,
GetSystemDashboard200,
} from 'api/generated/services/sigNoz.schemas';
import type { DashboardQuerySource } from 'pages/DashboardPage/DashboardContainer/store/slices/editContextSlice';
export interface SystemDashboardHost {
/** Definition name without the `signoz---` prefix. */
name: string;
select: (
response: GetSystemDashboard200,
) => DashboardtypesGettableDashboardV2DTO;
source: DashboardQuerySource;
}
/**
* Call at module level: `select` and `source` must stay referentially stable.
* System dashboards carry no `id`; `id` keys the store's per-dashboard state
* (saved variable values), so keep it stable across releases.
*/
export function createSystemDashboardHost(
name: string,
id: string,
): SystemDashboardHost {
const select = (
response: GetSystemDashboard200,
): DashboardtypesGettableDashboardV2DTO =>
({ ...response.data, id }) as DashboardtypesGettableDashboardV2DTO;
return {
name,
select,
source: { queryKey: getGetSystemDashboardQueryKey({ name }), select },
};
}

View File

@@ -0,0 +1,41 @@
import { useCallback } from 'react';
import { useGetSystemDashboard } from 'api/generated/services/dashboard';
import type { GetSystemDashboardQueryError } from 'api/generated/services/dashboard';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import type { SystemDashboardHost } from './systemDashboardHost';
export interface UseSystemDashboardResult {
dashboard: DashboardtypesGettableDashboardV2DTO | undefined;
isLoading: boolean;
isError: boolean;
error: GetSystemDashboardQueryError | null;
refetch: () => void;
}
export function useSystemDashboard(
host: SystemDashboardHost,
): UseSystemDashboardResult {
const {
data: dashboard,
isLoading,
isError,
error,
refetch,
} = useGetSystemDashboard(
{ name: host.name },
{
query: {
select: host.select,
staleTime: Infinity,
refetchOnMount: false,
},
},
);
const refetchDashboard = useCallback((): void => {
void refetch();
}, [refetch]);
return { dashboard, isLoading, isError, error, refetch: refetchDashboard };
}

View File

@@ -175,6 +175,7 @@ function AutoRefresh({
}
>
<ButtonContainer
data-testid="auto-refresh-trigger"
title="Set auto refresh"
type={showAutoRefreshBtnPrimary ? 'primary' : 'default'}
>

View File

@@ -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));
});
});

View File

@@ -90,7 +90,7 @@ export const useHandleExplorerTabChange = (): {
},
redirectToUrl,
undefined,
{ newTab },
newTab,
);
},
[panelType, getUpdateQuery, redirectWithQueryBuilderData, viewKey],

View File

@@ -140,7 +140,7 @@ export const useSafeNavigate = (
const navigationOptions = {
...options,
replace: options?.replace ?? isDefaultParamsNavigation,
replace: isDefaultParamsNavigation || options?.replace,
};
if (typeof to === 'string') {

View File

@@ -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';

View File

@@ -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' });
/**

View File

@@ -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();
};

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { FullScreenHandle } from 'react-full-screen';
import { buildRoutePath } from 'lib/router/buildRoutePath';
import {
@@ -26,19 +26,21 @@ import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import AuthZDropdown from 'lib/authz/components/AuthZDropdown/AuthZDropdown';
import type { AuthZDropdownItemType } from 'lib/authz/components/AuthZDropdown/types';
import type {
AuthZDropdownItemType,
AuthZGroupChildType,
} from 'lib/authz/components/AuthZDropdown/types';
import { blockedBy } from 'lib/authz/components/AuthZDropdown/utils';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { DashboardCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
import DashboardSettings from '../../DashboardSettings';
import { useAddSection } from '../../PanelsAndSectionsLayout/Section/hooks/useAddSection';
import SectionTitleModal from '../../PanelsAndSectionsLayout/Section/SectionTitleModal';
import JsonEditorDrawer from '../JsonEditorDrawer/JsonEditorDrawer';
import SettingsDrawer from '../SettingsDrawer';
import styles from './DashboardActions.module.scss';
import { useDeleteDashboardAction } from './useDeleteDashboardAction';
import { useDashboardStore } from '../../store/useDashboardStore';
import { useDashboardEditContext } from '../../hooks/useDashboardEditContext';
import { useDashboardControlGroup } from '../../controls/useDashboardControlGroup';
interface DashboardActionsProps {
title: string;
@@ -66,13 +68,14 @@ function DashboardActions({
deleteChecks,
deleteDisabledTooltip,
} = useDashboardEditContext();
const settingsRequest = useDashboardStore((s) => s.settingsRequest);
const clearSettingsRequest = useDashboardStore((s) => s.clearSettingsRequest);
const show = useDashboardControlGroup('dashboardActions');
const requestSettings = useDashboardStore((s) => s.requestSettings);
const { safeNavigate } = useSafeNavigate();
const { showErrorModal } = useErrorModal();
const { disabledTooltip: lockDisabledTooltip } = useDashboardLockPermission({
dashboardId: dashboard.id,
source: dashboard.source,
enabled: show.lock,
});
// Cloning creates a new dashboard, so it needs `create` too, and no lock gate.
@@ -81,8 +84,6 @@ function DashboardActions({
[editChecks],
);
const [isSettingsDrawerOpen, setIsSettingsDrawerOpen] =
useState<boolean>(false);
const [isJsonEditorOpen, setIsJsonEditorOpen] = useState<boolean>(false);
const [isCloning, setIsCloning] = useState<boolean>(false);
const [isNewSectionOpen, setIsNewSectionOpen] = useState<boolean>(false);
@@ -94,14 +95,6 @@ function DashboardActions({
panelCount: Object.keys(dashboard.spec.panels).length,
});
// Open the settings drawer when something in the tree requests it (e.g. the
// variables bar's "Add variable" button).
useEffect(() => {
if (settingsRequest) {
setIsSettingsDrawerOpen(true);
}
}, [settingsRequest]);
const { addSection, isSaving: isAddingSection } = useAddSection({
layouts: dashboard.spec.layouts,
});
@@ -143,8 +136,8 @@ function DashboardActions({
void logEvent(DashboardDetailEvents.SettingsOpened, {
dashboardId: dashboard.id,
});
setIsSettingsDrawerOpen(true);
}, [dashboard.id]);
requestSettings({ tab: 'Overview' });
}, [dashboard.id, requestSettings]);
const handleOpenJsonEditor = useCallback((): void => {
void logEvent(DashboardDetailEvents.JsonEditorOpened, {
@@ -162,61 +155,68 @@ function DashboardActions({
void handle.enter();
}, [dashboard.id, handle]);
// Unavailable items stay in the menu, carrying the reason.
const menuItems = useMemo<AuthZDropdownItemType[]>(
() => [
{
type: 'group',
value: 'group-dashboard',
label: 'Dashboard',
items: [
{
type: 'item',
value: 'rename',
testId: 'dashboard-action-rename',
label: 'Rename',
prefix: <PenLine size={14} />,
checks: editChecks,
...blockedBy(editDisabledTooltip),
onClick: onOpenRename,
},
// Clone creates a new dashboard, so it's not lock-gated.
{
type: 'item',
value: 'clone',
testId: 'dashboard-action-clone',
label: 'Clone dashboard',
prefix: <Copy size={14} />,
checks: cloneChecks,
loading: isCloning,
loadingTooltip: 'Cloning dashboard',
onClick: (): void => void handleClone(),
},
{
type: 'item',
value: 'lock',
testId: 'dashboard-action-lock',
label: isDashboardLocked ? 'Unlock dashboard' : 'Lock dashboard',
prefix: <LockKeyhole size={14} />,
checks: editChecks,
...blockedBy(lockDisabledTooltip),
onClick: onLockToggle,
},
{
type: 'item',
value: 'fullscreen',
testId: 'dashboard-action-fullscreen',
label: 'Full screen',
prefix: <Fullscreen size={14} />,
onClick: handleEnterFullScreen,
},
],
},
{
type: 'group',
value: 'group-layout',
label: 'Layout',
items: [
// Unavailable items stay in the menu, carrying the reason; hidden ones are dropped.
const menuItems = useMemo<AuthZDropdownItemType[]>(() => {
const dashboardItems: AuthZGroupChildType[] = [
...(show.rename
? [
{
type: 'item' as const,
value: 'rename',
testId: 'dashboard-action-rename',
label: 'Rename',
prefix: <PenLine size={14} />,
checks: editChecks,
...blockedBy(editDisabledTooltip),
onClick: onOpenRename,
},
]
: []),
// Clone creates a new dashboard, so it's not lock-gated.
...(show.clone
? [
{
type: 'item' as const,
value: 'clone',
testId: 'dashboard-action-clone',
label: 'Clone dashboard',
prefix: <Copy size={14} />,
checks: cloneChecks,
loading: isCloning,
loadingTooltip: 'Cloning dashboard',
onClick: (): void => void handleClone(),
},
]
: []),
...(show.lock
? [
{
type: 'item' as const,
value: 'lock',
testId: 'dashboard-action-lock',
label: isDashboardLocked ? 'Unlock dashboard' : 'Lock dashboard',
prefix: <LockKeyhole size={14} />,
checks: editChecks,
...blockedBy(lockDisabledTooltip),
onClick: onLockToggle,
},
]
: []),
...(show.fullscreen
? [
{
type: 'item' as const,
value: 'fullscreen',
testId: 'dashboard-action-fullscreen',
label: 'Full screen',
prefix: <Fullscreen size={14} />,
onClick: handleEnterFullScreen,
},
]
: []),
];
const layoutItems: AuthZGroupChildType[] = show.newSection
? [
{
type: 'item',
value: 'new-section',
@@ -227,101 +227,124 @@ function DashboardActions({
...blockedBy(editDisabledTooltip),
onClick: (): void => setIsNewSectionOpen(true),
},
],
},
{ type: 'separator', value: 'divider-danger' },
{
type: 'item',
value: 'delete',
testId: 'dashboard-action-delete',
label: 'Delete dashboard',
prefix: <Trash2 size={14} />,
danger: true,
checks: deleteChecks,
// Independent of read/update, but a locked dashboard can't be removed.
...blockedBy(deleteDisabledTooltip),
onClick: confirmDeleteDashboard,
},
],
[
editChecks,
editDisabledTooltip,
deleteChecks,
deleteDisabledTooltip,
cloneChecks,
isCloning,
lockDisabledTooltip,
isDashboardLocked,
onOpenRename,
handleClone,
onLockToggle,
handleEnterFullScreen,
confirmDeleteDashboard,
],
);
]
: [];
return [
...(dashboardItems.length
? [
{
type: 'group' as const,
value: 'group-dashboard',
label: 'Dashboard',
items: dashboardItems,
},
]
: []),
...(layoutItems.length
? [
{
type: 'group' as const,
value: 'group-layout',
label: 'Layout',
items: layoutItems,
},
]
: []),
...(show.delete
? [
{ type: 'separator' as const, value: 'divider-danger' },
{
type: 'item' as const,
value: 'delete',
testId: 'dashboard-action-delete',
label: 'Delete dashboard',
prefix: <Trash2 size={14} />,
danger: true,
checks: deleteChecks,
// Independent of read/update, but a locked dashboard can't be removed.
...blockedBy(deleteDisabledTooltip),
onClick: confirmDeleteDashboard,
},
]
: []),
];
}, [
show,
editChecks,
editDisabledTooltip,
deleteChecks,
deleteDisabledTooltip,
cloneChecks,
isCloning,
lockDisabledTooltip,
isDashboardLocked,
onOpenRename,
handleClone,
onLockToggle,
handleEnterFullScreen,
confirmDeleteDashboard,
]);
return (
<div className={styles.dashboardActionsContainer}>
<AuthZDropdown items={menuItems} nativeButton align="end" side="bottom">
{menuItems.length > 0 && (
<AuthZDropdown items={menuItems} nativeButton align="end" side="bottom">
<Button
variant="solid"
color="secondary"
size="md"
prefix={<Grid3X3 size="md" />}
testId="options"
>
Actions
</Button>
</AuthZDropdown>
)}
{show.configure && (
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
<Button
disabledTooltip={undefined}
variant="solid"
color="secondary"
prefix={<Configure size="md" />}
testId="show-drawer"
disabled={!isEditable}
onClick={handleOpenSettings}
size="md"
>
Configure
</Button>
</AuthZTooltip>
)}
{show.json && (
<Button
variant="solid"
color="secondary"
size="md"
prefix={<Grid3X3 size="md" />}
testId="options"
>
Actions
</Button>
</AuthZDropdown>
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
<Button
disabledTooltip={undefined}
variant="solid"
color="secondary"
prefix={<Configure size="md" />}
testId="show-drawer"
disabled={!isEditable}
onClick={handleOpenSettings}
prefix={<Braces size="md" />}
testId="edit-json"
onClick={handleOpenJsonEditor}
size="md"
>
Configure
JSON
</Button>
</AuthZTooltip>
<SettingsDrawer
drawerTitle="Dashboard Configuration"
isOpen={isSettingsDrawerOpen}
destroyOnClose
onClose={(): void => {
setIsSettingsDrawerOpen(false);
clearSettingsRequest();
}}
>
<DashboardSettings dashboard={dashboard} />
</SettingsDrawer>
<Button
variant="solid"
color="secondary"
prefix={<Braces size="md" />}
testId="edit-json"
onClick={handleOpenJsonEditor}
size="md"
>
JSON
</Button>
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
<Button
disabledTooltip={undefined}
variant="solid"
color="primary"
onClick={onAddPanel}
prefix={<Plus size="md" />}
testId="add-panel-header"
disabled={!isEditable}
size="md"
>
New Panel
</Button>
</AuthZTooltip>
)}
{show.newPanel && (
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
<Button
disabledTooltip={undefined}
variant="solid"
color="primary"
onClick={onAddPanel}
prefix={<Plus size="md" />}
testId="add-panel-header"
disabled={!isEditable}
size="md"
>
New Panel
</Button>
</AuthZTooltip>
)}
<JsonEditorDrawer
dashboard={dashboard}
isOpen={isJsonEditorOpen}

View File

@@ -24,9 +24,11 @@ import { openInNewTab } from 'utils/navigation';
import styles from './DashboardInfo.module.scss';
import TagsOverflowTooltip from './TagsOverflowTooltip';
import { getLockTooltip } from './utils';
import { DASHBOARD_NAME_MAX_LENGTH } from '../../constants';
import { useDashboardStore } from '../../store/useDashboardStore';
import { useDashboardEditContext } from '../../hooks/useDashboardEditContext';
import { useDashboardControlGroup } from '../../controls/useDashboardControlGroup';
// The tag cluster keeps a fixed footprint so a long title ellipsizes around it
// instead of collapsing the tags: show up to two tags, then a `+N` overflow badge.
@@ -75,27 +77,19 @@ function DashboardInfo({
}: DashboardInfoProps): JSX.Element {
const { isEditable: canEdit } = useDashboardEditContext();
const dashboardId = useDashboardStore((s) => s.dashboardId);
const show = useDashboardControlGroup('title');
const hasTags = tags.length > 0;
const hasDescription = !isEmpty(description);
const hasTags = show.tags && tags.length > 0;
const hasDescription = show.description && !isEmpty(description);
const visibleTags = tags.slice(0, MAX_VISIBLE_TAGS);
const remainingTags = tags.slice(MAX_VISIBLE_TAGS);
// Without a toggle, say why it can't be toggled rather than only restating the
// lock state.
let lockTooltip: string;
if (onToggleLock) {
lockTooltip = isDashboardLocked
? 'Locked — click to unlock'
: 'Unlocked — click to lock';
} else if (lockDisabledTooltip) {
lockTooltip = lockDisabledTooltip;
} else {
lockTooltip = isDashboardLocked
? 'This dashboard is locked'
: 'This dashboard is unlocked';
}
const lockTooltip = getLockTooltip({
isLocked: isDashboardLocked,
canToggle: !!onToggleLock,
disabledReason: lockDisabledTooltip,
});
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
if (event.key === 'Enter') {
@@ -128,66 +122,69 @@ function DashboardInfo({
return (
<div className={styles.dashboardInfo}>
<img src={image} alt={title} className={styles.dashboardImage} />
{isEditing ? (
<div className={styles.dashboardTitleEditor} onBlur={onEditorBlur}>
<Input
autoFocus
value={draft}
testId="dashboard-title-input"
maxLength={DASHBOARD_NAME_MAX_LENGTH}
className={styles.dashboardTitleInput}
onChange={(e): void => onDraftChange(e.target.value)}
onKeyDown={onKeyDown}
/>
<Button
type="button"
variant="solid"
color="primary"
size="sm"
icon
aria-label="Save title"
testId="dashboard-title-save"
onClick={onCommit}
>
<Check size={14} />
</Button>
<Button
type="button"
variant="outlined"
color="secondary"
size="sm"
icon
aria-label="Cancel title edit"
testId="dashboard-title-cancel"
onClick={onCancel}
>
<X size={14} />
</Button>
</div>
) : (
<>
<Tooltip title={title}>
<Typography.Text
className={cx(styles.dashboardTitle, {
[styles.dashboardTitleHover]: canEdit,
})}
data-testid="dashboard-title"
onClick={canEdit ? onStartEdit : undefined}
>
{title}
</Typography.Text>
</Tooltip>
<CopyButton
value={dashboardId}
ariaLabel="Copy dashboard ID"
testId="dashboard-copy-id"
onCopy={handleCopyId}
/>
</>
{show.name && (
<img src={image} alt={title} className={styles.dashboardImage} />
)}
{show.name &&
(isEditing ? (
<div className={styles.dashboardTitleEditor} onBlur={onEditorBlur}>
<Input
autoFocus
value={draft}
testId="dashboard-title-input"
maxLength={DASHBOARD_NAME_MAX_LENGTH}
className={styles.dashboardTitleInput}
onChange={(e): void => onDraftChange(e.target.value)}
onKeyDown={onKeyDown}
/>
<Button
type="button"
variant="solid"
color="primary"
size="sm"
icon
aria-label="Save title"
testId="dashboard-title-save"
onClick={onCommit}
>
<Check size={14} />
</Button>
<Button
type="button"
variant="outlined"
color="secondary"
size="sm"
icon
aria-label="Cancel title edit"
testId="dashboard-title-cancel"
onClick={onCancel}
>
<X size={14} />
</Button>
</div>
) : (
<>
<Tooltip title={title}>
<Typography.Text
className={cx(styles.dashboardTitle, {
[styles.dashboardTitleHover]: canEdit,
})}
data-testid="dashboard-title"
onClick={canEdit ? onStartEdit : undefined}
>
{title}
</Typography.Text>
</Tooltip>
<CopyButton
value={dashboardId}
ariaLabel="Copy dashboard ID"
testId="dashboard-copy-id"
onCopy={handleCopyId}
/>
</>
))}
{hasDescription && (
<Tooltip
side="bottom"
@@ -205,7 +202,7 @@ function DashboardInfo({
</Tooltip>
)}
{isPublicDashboard && (
{show.publicBadge && isPublicDashboard && (
<Tooltip title="This dashboard is publicly accessible. Click to open the public page.">
<Button
type="button"
@@ -222,7 +219,7 @@ function DashboardInfo({
</Tooltip>
)}
{showLockToggle && (
{show.lock && showLockToggle && (
<Tooltip title={onToggleLock ? lockTooltip : undefined}>
<Button
disabledTooltip={lockTooltip}

View File

@@ -0,0 +1,19 @@
// Without a toggle, say why it can't be toggled rather than only restating the
// lock state.
export function getLockTooltip({
isLocked,
canToggle,
disabledReason,
}: {
isLocked: boolean;
canToggle: boolean;
disabledReason?: string;
}): string {
if (canToggle) {
return isLocked ? 'Locked — click to unlock' : 'Unlocked — click to lock';
}
if (disabledReason) {
return disabledReason;
}
return isLocked ? 'This dashboard is locked' : 'This dashboard is unlocked';
}

View File

@@ -0,0 +1,144 @@
import type { FullScreenHandle } from 'react-full-screen';
import { useDashboardLockPermission } from 'hooks/dashboards/useDashboardLockPermission';
import { render, screen } from 'tests/test-utils';
import { dashboardResponse } from '../../../stories/__story_mockdata__/dashboard';
import type { DashboardControlsInput } from '../../controls/types';
import { usePublicDashboardMeta } from '../../DashboardSettings/PublicDashboard/usePublicDashboardMeta';
import { useDashboardStore } from '../../store/useDashboardStore';
import DashboardPageToolbar from '../index';
const { data: dashboard } = dashboardResponse({
panels: 0,
sectioned: false,
variables: [],
locked: true,
});
const handle: FullScreenHandle = {
active: false,
enter: jest.fn(),
exit: jest.fn(),
node: { current: null },
};
jest.mock('../DashboardActions/DashboardActions', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="dashboard-actions" />,
}));
jest.mock('../DashboardInfo/DashboardInfo', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="dashboard-info" />,
}));
jest.mock('../../VariablesBar/VariablesBar', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="variables-bar" />,
}));
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="time-selection" />,
}));
jest.mock(
'../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeSelectionModal',
() => ({ __esModule: true, default: (): null => null }),
);
jest.mock('../../hooks/useCreatePanel', () => ({
useCreatePanel: (): unknown => ({
isPickerOpen: false,
openPicker: jest.fn(),
closePicker: jest.fn(),
createPanel: jest.fn(),
targetLayoutIndex: 0,
}),
}));
jest.mock('../../hooks/useOptimisticPatch', () => ({
useOptimisticPatch: (): unknown => ({ patchAsync: jest.fn() }),
}));
jest.mock('hooks/dashboards/useToggleDashboardLock', () => ({
useToggleDashboardLock: (): unknown => ({ toggleLock: jest.fn() }),
}));
jest.mock('hooks/dashboards/useDashboardLockPermission', () => ({
useDashboardLockPermission: jest.fn(() => ({
canToggleLock: false,
isLoading: false,
disabledTooltip: '',
})),
}));
jest.mock(
'../../DashboardSettings/PublicDashboard/usePublicDashboardMeta',
() => ({
usePublicDashboardMeta: jest.fn(() => ({
isPublic: false,
publicMeta: undefined,
})),
}),
);
function renderToolbar(
ctx: { readOnly?: boolean; controls?: DashboardControlsInput } = {},
): void {
useDashboardStore
.getState()
.setEditContext({ dashboardId: dashboard.id, refetch: jest.fn(), ...ctx });
render(<DashboardPageToolbar dashboard={dashboard} handle={handle} />);
}
describe('DashboardPageToolbar - controls', () => {
afterEach(() => {
useDashboardStore
.getState()
.setEditContext({ dashboardId: '', refetch: jest.fn() });
jest.clearAllMocks();
});
it('renders every row and checks lock + share by default', () => {
renderToolbar();
expect(screen.getByTestId('dashboard-info')).toBeInTheDocument();
expect(screen.getByTestId('dashboard-actions')).toBeInTheDocument();
expect(screen.getByTestId('time-selection')).toBeInTheDocument();
expect(jest.mocked(useDashboardLockPermission)).toHaveBeenCalledWith(
expect.objectContaining({ enabled: true }),
);
expect(jest.mocked(usePublicDashboardMeta)).toHaveBeenCalledWith(
dashboard.id,
{
enabled: true,
},
);
});
it('skips the lock permission check on a read-only mount', () => {
renderToolbar({ readOnly: true });
expect(jest.mocked(useDashboardLockPermission)).toHaveBeenCalledWith(
expect.objectContaining({ enabled: false }),
);
});
it('drops the title row and actions the host hides, and their requests', () => {
renderToolbar({
readOnly: true,
controls: { title: false, dashboardActions: false },
});
expect(screen.queryByTestId('dashboard-info')).not.toBeInTheDocument();
expect(screen.queryByTestId('dashboard-actions')).not.toBeInTheDocument();
expect(jest.mocked(usePublicDashboardMeta)).toHaveBeenCalledWith(
dashboard.id,
{
enabled: false,
},
);
});
it('hides the time range and keeps the variables bar mounted but hidden', () => {
renderToolbar({
controls: { toolbar: { timeRange: false, variables: false } },
});
expect(screen.queryByTestId('time-selection')).not.toBeInTheDocument();
expect(screen.getByTestId('variables-bar')).not.toBeVisible();
});
});

View File

@@ -17,6 +17,7 @@ import { getAbsoluteUrl } from 'utils/basePath';
import { useCreatePanel } from '../hooks/useCreatePanel';
import { useOptimisticPatch } from '../hooks/useOptimisticPatch';
import { useDashboardControlGroup } from '../controls/useDashboardControlGroup';
import { usePublicDashboardMeta } from '../DashboardSettings/PublicDashboard/usePublicDashboardMeta';
import PanelTypeSelectionModal from '../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
import DashboardActions from './DashboardActions/DashboardActions';
@@ -56,6 +57,11 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
[dashboard.tags],
);
const showTitle = useDashboardControlGroup('title');
const showActions = useDashboardControlGroup('dashboardActions');
const showToolbar = useDashboardControlGroup('toolbar');
const hasTitleRow = Object.values(showTitle).some(Boolean);
const hasActions = Object.values(showActions).some(Boolean);
const { showErrorModal } = useErrorModal();
const { patchAsync } = useOptimisticPatch();
const {
@@ -71,10 +77,13 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
useDashboardLockPermission({
dashboardId: id,
source: dashboard.source,
enabled: showTitle.lock || showActions.lock,
});
// Public-sharing meta (deduped react-query read); drives the header globe.
const { isPublic, publicMeta } = usePublicDashboardMeta(id);
const { isPublic, publicMeta } = usePublicDashboardMeta(id, {
enabled: showTitle.publicBadge,
});
const publicUrl = getAbsoluteUrl(publicMeta?.publicPath ?? '');
// Shared with the list's row menu — it owns the API call, the toast and the
@@ -155,48 +164,66 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
return (
<section className={styles.dashboardPageToolbarContainer}>
<div className={styles.dashboardInfoWithActions}>
<DashboardInfo
title={title}
image={image}
tags={tags}
description={description}
isPublicDashboard={isPublic}
publicUrl={publicUrl}
isDashboardLocked={isDashboardLocked}
showLockToggle={showLockToggle}
onToggleLock={
canToggleLock
? (): void => handleLockDashboardToggle('header')
: undefined
}
lockDisabledTooltip={lockDisabledTooltip}
isEditing={isEditing}
draft={draft}
onDraftChange={setDraft}
onStartEdit={startEdit}
onCommit={commit}
onCancel={cancel}
/>
<DashboardActions
title={title}
dashboard={dashboard}
handle={handle}
isDashboardLocked={isDashboardLocked}
onAddPanel={onAddPanel}
onLockToggle={(): void => handleLockDashboardToggle('menu')}
onOpenRename={startEdit}
/>
</div>
{(hasTitleRow || hasActions) && (
<div className={styles.dashboardInfoWithActions}>
{hasTitleRow && (
<DashboardInfo
title={title}
image={image}
tags={tags}
description={description}
isPublicDashboard={isPublic}
publicUrl={publicUrl}
isDashboardLocked={isDashboardLocked}
showLockToggle={showLockToggle}
onToggleLock={
canToggleLock
? (): void => handleLockDashboardToggle('header')
: undefined
}
lockDisabledTooltip={lockDisabledTooltip}
isEditing={isEditing}
draft={draft}
onDraftChange={setDraft}
onStartEdit={startEdit}
onCommit={commit}
onCancel={cancel}
/>
)}
{hasActions && (
<DashboardActions
title={title}
dashboard={dashboard}
handle={handle}
isDashboardLocked={isDashboardLocked}
onAddPanel={onAddPanel}
onLockToggle={(): void => handleLockDashboardToggle('menu')}
onOpenRename={startEdit}
/>
)}
</div>
)}
{/* Row 2: the time selector floats top-right (declared first so the
variables bar's content wraps around it); the variables bar
collapses to one line and, when expanded, wraps full-width under it. */}
<div className={styles.toolbarRow}>
<div className={styles.timeCluster}>
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
</div>
<VariablesBar dashboard={dashboard} />
{showToolbar.timeRange && (
<div className={styles.timeCluster}>
<DateTimeSelectionV2
showAutoRefresh={showToolbar.autoRefresh}
hideShareModal
/>
</div>
)}
{showToolbar.variables ? (
<VariablesBar dashboard={dashboard} />
) : (
// Still mounted: the selectors seed and auto-select the values panels query with.
<div hidden>
<VariablesBar dashboard={dashboard} />
</div>
)}
</div>
<PanelTypeSelectionModal
open={isPickerOpen}

View File

@@ -26,9 +26,13 @@ const PUBLIC_META_STALE_TIME = 5 * 60 * 1000;
*/
export function usePublicDashboardMeta(
dashboardId: string,
options?: { enabled?: boolean },
): UsePublicDashboardMetaReturn {
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
const enabled = !!dashboardId && (isCloudUser || isEnterpriseSelfHostedUser);
const enabled =
(options?.enabled ?? true) &&
!!dashboardId &&
(isCloudUser || isEnterpriseSelfHostedUser);
const { data, isLoading, isFetching, error, refetch } = useGetPublicDashboard(
{ id: dashboardId },

View File

@@ -2,33 +2,17 @@ import { renderHook } from '@testing-library/react';
import { useContextLinkVariables } from '../useContextLinkVariables';
const mockUseGetDashboardV2 = jest.fn();
const mockUseDashboardVariableNames = jest.fn();
const mockUseQueryBuilder = jest.fn();
jest.mock(
'pages/DashboardPage/DashboardContainer/store/useDashboardStore',
() => ({
useDashboardStore: (
selector: (s: { dashboardId: string }) => unknown,
): unknown => selector({ dashboardId: 'dash-1' }),
}),
);
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): unknown => mockUseQueryBuilder(),
}));
jest.mock('api/generated/services/dashboard', () => ({
useGetDashboardV2: (): unknown => mockUseGetDashboardV2(),
}));
// dtoToFormModel is exercised by its own suite; here we only need it to surface a name.
jest.mock(
'pages/DashboardPage/DashboardContainer/DashboardSettings/Variables/variableAdapters',
'pages/DashboardPage/DashboardContainer/hooks/useDashboardVariableNames',
() => ({
dtoToFormModel: (dto: { name: string }): { name: string } => ({
name: dto.name,
}),
useDashboardVariableNames: (): unknown => mockUseDashboardVariableNames(),
}),
);
@@ -45,11 +29,7 @@ describe('useContextLinkVariables', () => {
},
},
});
mockUseGetDashboardV2.mockReturnValue({
data: {
data: { spec: { variables: [{ name: 'region' }, { name: 'tier' }] } },
},
});
mockUseDashboardVariableNames.mockReturnValue(['region', 'tier']);
});
it('orders globals, then _prefixed query fields (deduped), then dashboard vars', () => {
@@ -69,9 +49,7 @@ describe('useContextLinkVariables', () => {
mockUseQueryBuilder.mockReturnValue({
currentQuery: { builder: { queryData: [] } },
});
// Loaded dashboard with no variables — useDashboardFetchRequired guarantees the
// dashboard is present, so the empty case is an empty spec, not `undefined`.
mockUseGetDashboardV2.mockReturnValue({ data: { data: { spec: {} } } });
mockUseDashboardVariableNames.mockReturnValue([]);
const { result } = renderHook(() => useContextLinkVariables());

View File

@@ -11,6 +11,7 @@ import { useDashboardStore } from '../../store/useDashboardStore';
import PanelTypeSelectionModal from '../Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
import styles from './DashboardEmptyState.module.scss';
import { useDashboardEditContext } from '../../hooks/useDashboardEditContext';
import { useDashboardControl } from '../../controls/useDashboardControl';
interface DashboardEmptyStateProps {
canAddPanel: boolean;
@@ -24,9 +25,11 @@ function DashboardEmptyState({
const { isPickerOpen, openPicker, closePicker, createPanel } =
useCreatePanel();
const requestSettings = useDashboardStore((s) => s.requestSettings);
const showConfigure = useDashboardControl('dashboardActions.configure');
const showNewPanel = useDashboardControl('dashboardActions.newPanel');
return (
<section className={styles.emptyState}>
<section className={styles.emptyState} data-testid="dashboard-empty-state">
<div className={styles.content}>
<div className={styles.heading}>
<img src={dashboardEmojiUrl} alt="" className={styles.emoji} />
@@ -39,62 +42,66 @@ function DashboardEmptyState({
</div>
<div className={styles.steps}>
<div className={styles.step}>
<div className={styles.stepText}>
<Configure size={14} className={styles.stepIcon} />
<div className={styles.stepCopy}>
<Typography.Text className={styles.stepTitle}>
Configure your new dashboard
</Typography.Text>
<Typography.Text className={styles.stepInfo}>
Give it a name, add description, tags and variables
</Typography.Text>
{showConfigure && (
<div className={styles.step}>
<div className={styles.stepText}>
<Configure size={14} className={styles.stepIcon} />
<div className={styles.stepCopy}>
<Typography.Text className={styles.stepTitle}>
Configure your new dashboard
</Typography.Text>
<Typography.Text className={styles.stepInfo}>
Give it a name, add description, tags and variables
</Typography.Text>
</div>
</div>
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
<Button
disabledTooltip={undefined}
size="md"
variant="solid"
color="secondary"
width="140px"
prefix={<Configure size="md" />}
disabled={!isEditable}
onClick={(): void => requestSettings({ tab: 'Overview' })}
testId="empty-configure"
>
Configure
</Button>
</AuthZTooltip>
</div>
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
<Button
disabledTooltip={undefined}
size="md"
variant="solid"
color="secondary"
width="140px"
prefix={<Configure size="md" />}
disabled={!isEditable}
onClick={(): void => requestSettings({ tab: 'Overview' })}
testId="empty-configure"
>
Configure
</Button>
</AuthZTooltip>
</div>
)}
<div className={styles.step}>
<div className={styles.stepText}>
<img src={landscapeUrl} alt="" className={styles.stepIcon} />
<div className={styles.stepCopy}>
<Typography.Text className={styles.stepTitle}>
Add panels
</Typography.Text>
<Typography.Text className={styles.stepInfo}>
Add panels to visualize your data
</Typography.Text>
{showNewPanel && (
<div className={styles.step}>
<div className={styles.stepText}>
<img src={landscapeUrl} alt="" className={styles.stepIcon} />
<div className={styles.stepCopy}>
<Typography.Text className={styles.stepTitle}>
Add panels
</Typography.Text>
<Typography.Text className={styles.stepInfo}>
Add panels to visualize your data
</Typography.Text>
</div>
</div>
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
<Button
disabledTooltip={undefined}
size="md"
variant="solid"
color="primary"
prefix={<Plus size="md" />}
disabled={!canAddPanel}
onClick={(): void => openPicker()}
testId="add-panel"
>
New Panel
</Button>
</AuthZTooltip>
</div>
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
<Button
disabledTooltip={undefined}
size="md"
variant="solid"
color="primary"
prefix={<Plus size="md" />}
disabled={!canAddPanel}
onClick={(): void => openPicker()}
testId="add-panel"
>
New Panel
</Button>
</AuthZTooltip>
</div>
)}
</div>
</div>
<PanelTypeSelectionModal

View File

@@ -10,6 +10,7 @@ import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import type { DashboardSection } from '../../../../utils';
import { useDashboardStore } from '../../../../store/useDashboardStore';
import { usePanelActionItems } from '../usePanelActionItems';
/** Keys of the disabled items, in order. */
@@ -494,4 +495,43 @@ describe('usePanelActionItems', () => {
click(actionRow(result.current.items, 'create-alert'));
expect(mockCreateAlert).toHaveBeenCalledWith(mockPanel, 'panel-1');
});
describe('host controls', () => {
afterEach(() => {
useDashboardStore
.getState()
.setEditContext({ dashboardId: '', refetch: jest.fn() });
});
it('drops every writing action on a read-only mount', () => {
useDashboardStore.getState().setEditContext({
dashboardId: 'dash-1',
refetch: jest.fn(),
readOnly: true,
});
const { result } = renderHook(() => usePanelActionItems(baseArgs));
expect(itemKeys(result.current)).toStrictEqual([
'view-panel',
'separator',
'download',
'create-alert',
]);
});
it('drops the actions the host hides', () => {
useDashboardStore.getState().setEditContext({
dashboardId: 'dash-1',
refetch: jest.fn(),
controls: { panelActions: { createAlert: false, delete: false } },
});
const { result } = renderHook(() => usePanelActionItems(baseArgs));
expect(itemKeys(result.current)).not.toContain('create-alert');
expect(itemKeys(result.current)).not.toContain('delete-panel');
expect(itemKeys(result.current)).toContain('edit-panel');
});
});
});

View File

@@ -29,6 +29,7 @@ import type { AuthZDropdownItemType } from 'lib/authz/components/AuthZDropdown/t
import { blockedBy } from 'lib/authz/components/AuthZDropdown/utils';
import { getBlockedPanelActions } from '../utils/getBlockedPanelActions';
import { useDashboardEditContext } from '../../../hooks/useDashboardEditContext';
import { useDashboardControlGroup } from '../../../controls/useDashboardControlGroup';
// Stable fallback so renders without layout context don't churn the mutation
// hooks' deps (a fresh [] each render would re-create their callbacks).
@@ -66,6 +67,7 @@ export function usePanelActionItems({
const blocked = useMemo(() => getBlockedPanelActions(panel), [panel]);
const { isEditable, editChecks, editDisabledTooltip } =
useDashboardEditContext();
const show = useDashboardControlGroup('panelActions');
const openPanelEditor = useOpenPanelEditor();
const createAlert = useCreateAlertFromPanel();
const { openView } = useViewPanel();
@@ -103,7 +105,7 @@ export function usePanelActionItems({
const editGate = { checks: editChecks, ...blockedBy(editDisabledTooltip) };
const panelGroup: AuthZDropdownItemType[] = [];
if (panelCapabilities.view) {
if (show.view && panelCapabilities.view) {
panelGroup.push({
type: 'item',
value: 'view-panel',
@@ -112,7 +114,7 @@ export function usePanelActionItems({
onClick: (): void => openView(panelId, panel),
});
}
if (panelCapabilities.edit) {
if (show.edit && panelCapabilities.edit) {
panelGroup.push({
type: 'item',
value: 'edit-panel',
@@ -122,7 +124,7 @@ export function usePanelActionItems({
onClick: (): void => openPanelEditor(panelId, { panel }),
});
}
if (panelCapabilities.clone) {
if (show.clone && panelCapabilities.clone) {
// Needs section context to place the copy; disabled without it.
panelGroup.push({
type: 'item',
@@ -142,13 +144,13 @@ export function usePanelActionItems({
}
const dataGroup: AuthZDropdownItemType[] = [];
if (downloadItem) {
if (show.download && downloadItem) {
dataGroup.push(downloadItem);
}
// Create Alerts opens a new tab and never mutates the dashboard, so —
// unlike edit/clone — it isn't gated on editability (V1 parity).
if (panelCapabilities.createAlert) {
if (show.createAlert && panelCapabilities.createAlert) {
dataGroup.push({
type: 'item',
value: 'create-alert',
@@ -159,8 +161,9 @@ export function usePanelActionItems({
});
}
const moveGroup: AuthZDropdownItemType[] =
isEditable && panelActions
const moveGroup: AuthZDropdownItemType[] = !show.move
? []
: isEditable && panelActions
? buildMoveItems({
sections,
currentLayoutIndex: panelActions.currentLayoutIndex,
@@ -177,17 +180,19 @@ export function usePanelActionItems({
},
];
const deleteGroup: AuthZDropdownItemType[] = [
{
type: 'item',
value: 'delete-panel',
label: 'Delete panel',
prefix: <Trash2 size={14} />,
danger: true,
...editGate,
onClick: (): void => requestDelete(),
},
];
const deleteGroup: AuthZDropdownItemType[] = !show.delete
? []
: [
{
type: 'item',
value: 'delete-panel',
label: 'Delete panel',
prefix: <Trash2 size={14} />,
danger: true,
...editGate,
onClick: (): void => requestDelete(),
},
];
return [panelGroup, dataGroup, moveGroup, deleteGroup]
.filter((group) => group.length > 0)
@@ -197,6 +202,7 @@ export function usePanelActionItems({
: [{ type: 'separator', value: `separator-${index}` }, ...group],
);
}, [
show,
isEditable,
editChecks,
editDisabledTooltip,

View File

@@ -14,6 +14,7 @@ import { EQueryType } from 'types/common/dashboard';
import styles from './ViewPanelModal.module.scss';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { useDashboardEditContext } from '../../../hooks/useDashboardEditContext';
import { useDashboardControl } from '../../../controls/useDashboardControl';
interface ViewPanelModalHeaderBaseProps {
onSwitchToEdit: () => void;
@@ -70,6 +71,7 @@ function ViewPanelModalHeader(props: ViewPanelModalHeaderProps): JSX.Element {
editChecks,
editDisabledTooltip,
} = useDashboardEditContext();
const showSwitchToEdit = useDashboardControl('viewModal.switchToEdit');
// Same capabilities-guarded options as the editor's PanelTypeSwitcher, so the two
// selectors disable the same kinds (e.g. List under PromQL, metrics-only kinds).
const panelTypeItems = usePanelTypeSelectItems({
@@ -87,20 +89,22 @@ function ViewPanelModalHeader(props: ViewPanelModalHeaderProps): JSX.Element {
onChange={onChangePanelKind}
/>
</div>
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
<Button
disabledTooltip={undefined}
size="md"
variant="outlined"
color="secondary"
prefix={<PenLine />}
disabled={!canSwitchToEdit}
onClick={onSwitchToEdit}
testId="view-panel-switch-to-edit"
>
Switch to Edit Mode
</Button>
</AuthZTooltip>
{showSwitchToEdit && (
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
<Button
disabledTooltip={undefined}
size="md"
variant="outlined"
color="secondary"
prefix={<PenLine />}
disabled={!canSwitchToEdit}
onClick={onSwitchToEdit}
testId="view-panel-switch-to-edit"
>
Switch to Edit Mode
</Button>
</AuthZTooltip>
)}
{query && (
<Button
size="md"

View File

@@ -0,0 +1,71 @@
import { render, screen } from 'tests/test-utils';
import { useDashboardStore } from '../../../../store/useDashboardStore';
import ViewPanelModalHeader from '../ViewPanelModalHeader';
import type { DashboardControlsInput } from '../../../../controls/types';
jest.mock(
'pages/DashboardPage/DashboardContainer/hooks/useDashboardEditContext',
() => ({
useDashboardEditContext: (): unknown => ({
isEditable: true,
editChecks: [],
editDisabledTooltip: '',
}),
}),
);
jest.mock(
'pages/DashboardPage/DashboardContainer/PanelEditor/ConfigPane/PanelTypeSwitcher/usePanelTypeSelectItems',
() => ({ usePanelTypeSelectItems: (): unknown[] => [] }),
);
function renderHeader(
readOnly: boolean,
controls?: DashboardControlsInput,
): void {
useDashboardStore.getState().setEditContext({
dashboardId: 'dash-1',
refetch: jest.fn(),
readOnly,
controls,
});
render(
<ViewPanelModalHeader
mode="static"
panelKind="signoz/TimeSeriesPanel"
onChangePanelKind={jest.fn()}
onSwitchToEdit={jest.fn()}
/>,
);
}
describe('ViewPanelModalHeader - controls', () => {
afterEach(() => {
useDashboardStore
.getState()
.setEditContext({ dashboardId: '', refetch: jest.fn() });
});
it('offers Switch to Edit Mode on an editable mount', () => {
renderHeader(false);
expect(screen.getByTestId('view-panel-switch-to-edit')).toBeInTheDocument();
});
it('hides Switch to Edit Mode on a read-only mount', () => {
renderHeader(true);
expect(screen.getByTestId('view-panel-type-selector')).toBeInTheDocument();
expect(
screen.queryByTestId('view-panel-switch-to-edit'),
).not.toBeInTheDocument();
});
it('hides Switch to Edit Mode when the host hides it', () => {
renderHeader(false, { viewModal: false });
expect(
screen.queryByTestId('view-panel-switch-to-edit'),
).not.toBeInTheDocument();
});
});

View File

@@ -24,11 +24,16 @@ const mockPatchAsync = jest.fn().mockResolvedValue(undefined);
const DYNAMIC_KIND = 'signoz/DynamicVariable';
const QUERY_KIND = 'signoz/QueryVariable';
jest.mock('api/generated/services/dashboard', () => ({
useGetDashboardV2: (): unknown => ({
data: { data: { spec: { variables: mockVariables } } },
jest.mock(
'pages/DashboardPage/DashboardContainer/hooks/useDashboardFetchRequired',
() => ({
useDashboardFetchRequired: (): unknown => ({
dashboard: { spec: { variables: mockVariables } },
variables: mockVariables,
refetch: jest.fn(),
}),
}),
}));
);
jest.mock(
'pages/DashboardPage/DashboardContainer/hooks/useOptimisticPatch',
() => ({

View File

@@ -0,0 +1,51 @@
import { safeNavigateMock } from '__tests__/safeNavigateMock';
import { AllTheProviders, renderHook } from 'tests/test-utils';
import { CHARTS_ONLY } from '../../../../controls/presets';
import type { DashboardControlsInput } from '../../../../controls/types';
import { useDashboardStore } from '../../../../store/useDashboardStore';
import { usePanelInteractions } from '../usePanelInteractions';
function dragSelect(controls?: DashboardControlsInput): void {
useDashboardStore
.getState()
.setEditContext({ dashboardId: 'd1', refetch: jest.fn(), controls });
const { result } = renderHook(() => usePanelInteractions(), {
wrapper: AllTheProviders,
});
result.current.onDragSelect(1_000, 2_000);
}
describe('usePanelInteractions drag-to-zoom', () => {
afterEach(() => {
useDashboardStore
.getState()
.setEditContext({ dashboardId: '', refetch: jest.fn() });
});
it('writes the dragged range to the URL', () => {
dragSelect();
expect(safeNavigateMock).toHaveBeenCalledWith(
expect.stringContaining('startTime=1000&endTime=2000'),
);
});
it('does nothing when the host turns zoom off', () => {
dragSelect({ panelActions: { zoom: false } });
expect(safeNavigateMock).not.toHaveBeenCalled();
});
it('still zooms when the host hides the toolbar but leaves zoom on', () => {
dragSelect({ toolbar: false });
expect(safeNavigateMock).toHaveBeenCalled();
});
it('still zooms on a charts-only mount', () => {
dragSelect(CHARTS_ONLY);
expect(safeNavigateMock).toHaveBeenCalled();
});
});

View File

@@ -33,6 +33,7 @@ import { useDrilldownCoordinates } from './useDrilldownCoordinates';
import { useDrilldownDashboardVariables } from './useDrilldownDashboardVariables';
import { useDrilldownFilter } from './useDrilldownFilter';
import { useResolvedDrilldownQuery } from './useResolvedDrilldownQuery';
import { useDashboardControl } from 'pages/DashboardPage/DashboardContainer/controls/useDashboardControl';
import { useViewPanel } from './useViewPanel';
/** Which menu the popover shows; extend as submenus are added (e.g. dashboard variables). */
@@ -84,12 +85,14 @@ export function useDrilldown(
// Drilldown only for builder-authored panels with a query to drill into (PromQL /
// ClickHouse have no builder context to refine).
const showDrilldown = useDashboardControl('panelActions.drilldown');
const enableDrillDown = useMemo(
() =>
showDrilldown &&
getPanelDefinition(kind).actions.drilldown &&
getPanelQueryType(panel) === EQueryType.QUERY_BUILDER &&
getBuilderQueries(queries).length > 0,
[kind, panel, queries],
[showDrilldown, kind, panel, queries],
);
const v1Query = useMemo(

View File

@@ -13,6 +13,7 @@ import useUrlQuery from 'hooks/useUrlQuery';
import logEvent from 'api/common/logEvent';
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
import { UpdateTimeInterval } from 'store/actions';
import { useDashboardControl } from 'pages/DashboardPage/DashboardContainer/controls/useDashboardControl';
export interface PanelInteractions {
/** Drag-select a chart range → write it to the URL + global time so every panel re-fetches the same range. */
@@ -43,8 +44,12 @@ export function usePanelInteractions(): PanelInteractions {
[syncMode, syncFilterMode, dashboardId],
);
const canZoom = useDashboardControl('panelActions.zoom');
const onDragSelect = useCallback(
(start: number, end: number): void => {
if (!canZoom) {
return;
}
const startTimestamp = Math.trunc(start);
const endTimestamp = Math.trunc(end);
@@ -57,7 +62,7 @@ export function usePanelInteractions(): PanelInteractions {
void logEvent(DashboardDetailEvents.PanelZoomed, { context: 'panel' });
}
},
[dispatch, pathname, safeNavigate, urlQuery],
[canZoom, dispatch, pathname, safeNavigate, urlQuery],
);
return { onDragSelect, dashboardPreference };

View File

@@ -8,12 +8,12 @@ import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { useCreatePanel } from '../../../hooks/useCreatePanel';
import type { DashboardSection } from '../../../utils';
import PanelTypeSelectionModal from '../../Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
import { useCloneSection } from '../hooks/useCloneSection';
import { useDeleteSection } from '../hooks/useDeleteSection';
import { usePanelPickerHighlight } from '../hooks/usePanelPickerHighlight';
import { usePanelPickerReveal } from '../hooks/usePanelPickerReveal';
import { useRenameSection } from '../hooks/useRenameSection';
import { useScrollIntoView } from '../hooks/useScrollIntoView';
import { useSectionHeaderActions } from '../hooks/useSectionHeaderActions';
import { useToggleSectionCollapse } from '../hooks/useToggleSectionCollapse';
import SectionTitleModal from '../SectionTitleModal';
import SectionGrid from '../SectionGrid/SectionGrid';
@@ -66,7 +66,14 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
setIsDeleteOpen(false);
}, [deleteSection]);
const cloneSection = useCloneSection();
const openRename = useCallback((): void => setIsRenaming(true), []);
const openDelete = useCallback((): void => setIsDeleteOpen(true), []);
const actions = useSectionHeaderActions({
section,
openPicker,
openRename,
openDelete,
});
const sectionRef = useRef<HTMLDivElement>(null);
useScrollIntoView(section.id, sectionRef);
@@ -116,35 +123,30 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
dragHandle={dragHandle}
disabledChecks={editChecks}
disabledTooltip={editDisabledTooltip}
actions={{
onRename: (): void => setIsRenaming(true),
onAddPanel: (): void => openPicker(section.layoutIndex),
onCloneSection: (): void => void cloneSection(section),
onDeleteSection: (): void => setIsDeleteOpen(true),
}}
actions={actions}
/>
{open &&
(section.items.length > 0 || pickerTarget ? (
grid
) : (
<div className={styles.emptySection}>
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
<Button
size="md"
type="button"
variant="dashed"
color="secondary"
prefix={<Plus size="md" />}
onClick={
isEditable ? (): void => openPicker(section.layoutIndex) : undefined
}
testId={`section-add-panel-${section.id}`}
>
New Panel
</Button>
</AuthZTooltip>
</div>
))}
(section.items.length > 0 || pickerTarget
? grid
: actions.onAddPanel && (
<div className={styles.emptySection}>
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
<Button
size="md"
type="button"
variant="dashed"
color="secondary"
prefix={<Plus size="md" />}
onClick={
isEditable ? (): void => openPicker(section.layoutIndex) : undefined
}
testId={`section-add-panel-${section.id}`}
>
New Panel
</Button>
</AuthZTooltip>
</div>
))}
<SectionTitleModal
open={isRenaming}
heading="Rename section"

View File

@@ -25,7 +25,7 @@ function SectionActionsMenu({
onRename,
onCloneSection,
onDeleteSection,
}: SectionActionsMenuProps): JSX.Element {
}: SectionActionsMenuProps): JSX.Element | null {
const items = useMemo<AuthZDropdownItemType[]>(() => {
const gate = { checks: disabledChecks, ...blockedBy(disabledTooltip) };
const result: AuthZDropdownItemType[] = [];
@@ -83,6 +83,10 @@ function SectionActionsMenu({
onDeleteSection,
]);
if (items.length === 0) {
return null;
}
return (
<AuthZDropdown
items={items}

View File

@@ -16,12 +16,12 @@ export interface SectionDragHandle {
setActivatorNodeRef: (element: HTMLElement | null) => void;
}
/** Editable-mode section actions — present together or not at all. */
/** Section actions; an absent one isn't offered. */
export interface SectionHeaderActions {
onRename: () => void;
onAddPanel: () => void;
onCloneSection: () => void;
onDeleteSection: () => void;
onRename?: () => void;
onAddPanel?: () => void;
onCloneSection?: () => void;
onDeleteSection?: () => void;
}
interface SectionHeaderProps {

View File

@@ -0,0 +1,36 @@
import { useMemo } from 'react';
import { useDashboardControlGroup } from '../../../controls/useDashboardControlGroup';
import type { DashboardSection } from '../../../utils';
import type { SectionHeaderActions } from '../SectionHeader/SectionHeader';
import { useCloneSection } from './useCloneSection';
/** The section menu's actions, minus the ones the host hides (`sectionActions`). */
export function useSectionHeaderActions({
section,
openPicker,
openRename,
openDelete,
}: {
section: DashboardSection;
openPicker: (layoutIndex: number) => void;
openRename: () => void;
openDelete: () => void;
}): SectionHeaderActions {
const show = useDashboardControlGroup('sectionActions');
const cloneSection = useCloneSection();
return useMemo(
() => ({
onRename: show.rename ? openRename : undefined,
onAddPanel: show.addPanel
? (): void => openPicker(section.layoutIndex)
: undefined,
onCloneSection: show.clone
? (): void => void cloneSection(section)
: undefined,
onDeleteSection: show.delete ? openDelete : undefined,
}),
[show, openRename, openPicker, section, cloneSection, openDelete],
);
}

View File

@@ -16,6 +16,7 @@ import { resolveDefaultSelection } from './utils/resolveVariableSelection';
import VariableSelector from './components/VariableSelector/VariableSelector';
import styles from './VariablesBar.module.scss';
import { useDashboardEditContext } from '../hooks/useDashboardEditContext';
import { useDashboardControl } from '../controls/useDashboardControl';
interface VariablesBarProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
@@ -39,6 +40,7 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
// Persisted per dashboard so the full/collapsed view survives reloads.
const expanded = useDashboardStore(selectVariablesExpanded(dashboardId));
const setVariablesExpanded = useDashboardStore((s) => s.setVariablesExpanded);
const showAddVariable = useDashboardControl('toolbar.addVariable');
const { containerRef, visibleCount, overflowCount } = useInlineOverflowCount({
itemCount: variables.length,
gap: 8,
@@ -49,7 +51,7 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
// Editors can add a variable even before any exist; viewers with no variables
// have nothing to show.
if (variables.length === 0 && !isEditable) {
if (variables.length === 0 && !(isEditable && showAddVariable)) {
return null;
}
@@ -131,13 +133,15 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
{/* After the more/less trigger, in every state. Kept inline (not block)
so the row still flows under the floated time selector, and always
mounted so measuring never toggles it. */}
<span className={styles.addSlot}>
<AddVariableIcon
checks={editChecks}
disabledTooltip={editDisabledTooltip}
isEditable={isEditable}
/>
</span>
{showAddVariable && (
<span className={styles.addSlot}>
<AddVariableIcon
checks={editChecks}
disabledTooltip={editDisabledTooltip}
isEditable={isEditable}
/>
</span>
)}
</div>
</div>
);

View File

@@ -0,0 +1,74 @@
import { render, screen } from 'tests/test-utils';
import { dashboardResponse } from '../../../stories/__story_mockdata__/dashboard';
import { useDashboardStore } from '../../store/useDashboardStore';
import VariablesBar from '../VariablesBar';
import type { DashboardControlsInput } from '../../controls/types';
const { data: dashboard } = dashboardResponse({
panels: 0,
sectioned: false,
variables: [],
locked: false,
});
jest.mock('../../hooks/useDashboardEditContext', () => ({
useDashboardEditContext: (): unknown => ({
isEditable: false,
editChecks: [],
editDisabledTooltip: '',
}),
}));
jest.mock('../hooks/useVariableSelection', () => ({
useVariableSelection: (): unknown => ({
variables: [{ name: 'env' }],
selection: {},
setSelection: jest.fn(),
autoSelect: jest.fn(),
}),
}));
jest.mock('../components/VariableSelector/VariableSelector', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="variable-selector" />,
}));
function renderBar(readOnly: boolean, controls?: DashboardControlsInput): void {
useDashboardStore.getState().setEditContext({
dashboardId: dashboard.id,
refetch: jest.fn(),
readOnly,
controls,
});
render(<VariablesBar dashboard={dashboard} />);
}
describe('VariablesBar - controls', () => {
afterEach(() => {
useDashboardStore
.getState()
.setEditContext({ dashboardId: '', refetch: jest.fn() });
});
it('offers the add-variable trigger, disabled, to a viewer', () => {
renderBar(false);
expect(screen.getByTestId('dashboard-variables-add')).toBeInTheDocument();
});
it('renders the selectors but no add-variable trigger', () => {
renderBar(true);
expect(screen.getByTestId('variable-selector')).toBeInTheDocument();
expect(
screen.queryByTestId('dashboard-variables-add'),
).not.toBeInTheDocument();
});
it('renders no add-variable trigger when the host hides it', () => {
renderBar(false, { toolbar: { addVariable: false } });
expect(
screen.queryByTestId('dashboard-variables-add'),
).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,100 @@
import { useQuery } from 'react-query';
import type {
DashboardtypesGettableDashboardV2DTO,
GetDashboardV2200,
} from 'api/generated/services/sigNoz.schemas';
import { render, screen, waitFor } from 'tests/test-utils';
import { dashboardResponse } from '../../stories/__story_mockdata__/dashboard';
import { DEFAULT_CONTROLS } from '../controls/dashboardControls';
import type { DashboardControlsInput } from '../controls/types';
import DashboardContainer from '../index';
import type { DashboardQuerySource } from '../store/slices/editContextSlice';
const QUERY_KEY = ['lockdown-dashboard'];
const ALL_CONTROLS_OFF = Object.fromEntries(
Object.keys(DEFAULT_CONTROLS).map((group) => [group, false]),
) as DashboardControlsInput;
const select = (
response: GetDashboardV2200,
): DashboardtypesGettableDashboardV2DTO => response.data;
const SOURCE: DashboardQuerySource = { queryKey: QUERY_KEY, select };
/**
* Interactive elements a host can't turn off: they read, never write, and the
* page is unusable without them. Anything else here must be a control.
*/
const ALLOWED_TEST_ID_PREFIXES = [
// Collapses a section in this browser only; it also carries the section title.
'dashboard-section-toggle-',
];
const INTERACTIVE =
'button, a[href], input, select, textarea, [role="button"], [role="menuitem"], [role="combobox"], [role="switch"], [role="checkbox"]';
function LockdownHost({ panels }: { panels: number }): JSX.Element | null {
const { data } = useQuery(QUERY_KEY, () =>
dashboardResponse({
panels,
sectioned: panels > 0,
variables: ['custom'],
locked: false,
}),
);
if (!data) {
return null;
}
return (
<DashboardContainer
dashboard={data.data}
refetch={jest.fn()}
source={SOURCE}
readOnly
controls={ALL_CONTROLS_OFF}
/>
);
}
function visibleInteractive(): Element[] {
return Array.from(document.body.querySelectorAll(INTERACTIVE)).filter(
(element) => !element.closest('[hidden]'),
);
}
function describeElement(element: Element): string {
return (
element.getAttribute('data-testid') ??
`<${element.tagName.toLowerCase()} aria-label="${element.getAttribute('aria-label') ?? ''}">${element.textContent?.trim().slice(0, 40) ?? ''}`
);
}
function leakedInteractive(): string[] {
return visibleInteractive()
.map(describeElement)
.filter(
(id) => !ALLOWED_TEST_ID_PREFIXES.some((prefix) => id.startsWith(prefix)),
);
}
describe('DashboardContainer lockdown', () => {
// A control a host can't switch off, or one read-only doesn't remove, fails here.
it('renders nothing interactive with every control off on a read-only mount', async () => {
render(<LockdownHost panels={3} />);
await waitFor(() =>
expect(screen.getAllByTestId(/^panel-/).length).toBeGreaterThan(0),
);
expect(leakedInteractive()).toStrictEqual([]);
});
it('renders nothing interactive on an empty dashboard', async () => {
render(<LockdownHost panels={0} />);
await screen.findByTestId('dashboard-empty-state');
expect(leakedInteractive()).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,61 @@
import { useQuery } from 'react-query';
import type {
DashboardtypesGettableDashboardV2DTO,
GetDashboardV2200,
} from 'api/generated/services/sigNoz.schemas';
import { act, render, screen } from 'tests/test-utils';
import { dashboardResponse } from '../../stories/__story_mockdata__/dashboard';
import DashboardContainer from '../index';
import type { DashboardQuerySource } from '../store/slices/editContextSlice';
import { useDashboardStore } from '../store/useDashboardStore';
jest.mock('../DashboardSettings', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="dashboard-settings" />,
}));
const QUERY_KEY = ['settings-drawer-dashboard'];
const select = (
response: GetDashboardV2200,
): DashboardtypesGettableDashboardV2DTO => response.data;
const SOURCE: DashboardQuerySource = { queryKey: QUERY_KEY, select };
function Host(): JSX.Element | null {
const { data } = useQuery(QUERY_KEY, () =>
dashboardResponse({
panels: 0,
sectioned: false,
variables: [],
locked: false,
}),
);
if (!data) {
return null;
}
return (
<DashboardContainer
dashboard={data.data}
refetch={jest.fn()}
source={SOURCE}
controls={{ dashboardActions: false }}
/>
);
}
describe('DashboardContainer settings drawer', () => {
it('opens on request when the host hides the dashboard actions', async () => {
render(<Host />);
await screen.findByTestId('dashboard-empty-state');
act(() => {
useDashboardStore
.getState()
.requestSettings({ tab: 'Variables', addVariable: true });
});
await expect(
screen.findByTestId('dashboard-settings'),
).resolves.toBeInTheDocument();
});
});

View File

@@ -1,6 +1,7 @@
import { memo } from 'react';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import { useDashboardControlGroup } from '../../controls/useDashboardControlGroup';
import DashboardPageBreadcrumbs from './DashboardPageBreadcrumbs';
import { useShareVariablesOption } from './useShareVariablesOption';
@@ -16,13 +17,16 @@ function DashboardPageHeader({
image,
}: DashboardPageHeaderProps): JSX.Element {
const shareVariablesOption = useShareVariablesOption();
const show = useDashboardControlGroup('header');
return (
<div className={styles.dashboardPageHeader}>
<DashboardPageBreadcrumbs title={title} image={image} />
{show.breadcrumbs && (
<DashboardPageBreadcrumbs title={title} image={image} />
)}
<HeaderRightSection
enableAnnouncements={false}
enableShare
enableFeedback
enableShare={show.share}
enableFeedback={show.feedback}
shareModalExtraOption={shareVariablesOption}
/>
</div>

View File

@@ -0,0 +1,33 @@
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import DashboardSettings from '../../DashboardSettings';
import SettingsDrawer from '../../DashboardPageToolbar/SettingsDrawer';
import { useDashboardStore } from '../../store/useDashboardStore';
interface DashboardSettingsDrawerProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
}
/**
* Opens on any `requestSettings` in the tree. Mounted by the container, so it
* works whichever controls a host hides.
*/
function DashboardSettingsDrawer({
dashboard,
}: DashboardSettingsDrawerProps): JSX.Element {
const settingsRequest = useDashboardStore((s) => s.settingsRequest);
const clearSettingsRequest = useDashboardStore((s) => s.clearSettingsRequest);
return (
<SettingsDrawer
drawerTitle="Dashboard Configuration"
isOpen={!!settingsRequest}
destroyOnClose
onClose={clearSettingsRequest}
>
<DashboardSettings dashboard={dashboard} />
</SettingsDrawer>
);
}
export default DashboardSettingsDrawer;

View File

@@ -0,0 +1,57 @@
import {
CONTROL_WRITES,
DEFAULT_CONTROLS,
isControlVisible,
resolveControls,
} from '../dashboardControls';
import type { DashboardControlId } from '../types';
const ALL_IDS = Object.keys(CONTROL_WRITES) as DashboardControlId[];
describe('resolveControls', () => {
it('shows every control when the host passes nothing', () => {
expect(resolveControls(undefined, false)).toStrictEqual(DEFAULT_CONTROLS);
});
it('hides a whole group from one boolean', () => {
const controls = resolveControls({ header: false }, false);
expect(controls.header).toStrictEqual({
breadcrumbs: false,
share: false,
feedback: false,
});
expect(controls.title.name).toBe(true);
});
it('hides single items and keeps the rest of the group', () => {
const controls = resolveControls(
{ panelActions: { createAlert: false } },
false,
);
expect(controls.panelActions.createAlert).toBe(false);
expect(controls.panelActions.download).toBe(true);
});
it('hides every writing control on a read-only mount, whatever the host asked', () => {
const controls = resolveControls(
{ dashboardActions: true, panelActions: { edit: true } },
true,
);
ALL_IDS.forEach((id) => {
expect([id, isControlVisible(controls, id)]).toStrictEqual([
id,
!CONTROL_WRITES[id],
]);
});
});
it('still lets the host hide non-writing controls on a read-only mount', () => {
const controls = resolveControls({ panelActions: { download: false } }, true);
expect(controls.panelActions.download).toBe(false);
expect(controls.panelActions.view).toBe(true);
});
});

View File

@@ -0,0 +1,48 @@
import { resolveControls } from '../dashboardControls';
import { CHARTS_ONLY, EMBEDDED, withControls } from '../presets';
describe('withControls', () => {
it('adds an override group the preset leaves alone', () => {
expect(
withControls(EMBEDDED, { toolbar: { autoRefresh: false } }),
).toStrictEqual({
header: false,
title: false,
dashboardActions: false,
toolbar: { autoRefresh: false },
});
});
it('turns single items back on inside a group the preset hides', () => {
const merged = withControls(CHARTS_ONLY, { panelActions: { view: true } });
const controls = resolveControls(merged, false);
expect(controls.panelActions.view).toBe(true);
expect(controls.panelActions.download).toBe(false);
expect(controls.panelActions.drilldown).toBe(false);
});
it('lets a later whole-group boolean replace earlier items', () => {
expect(
withControls(
{ panelActions: { createAlert: false } },
{ panelActions: true },
),
).toStrictEqual({ panelActions: true });
});
it('lets later items win over earlier ones', () => {
expect(
withControls(
{ toolbar: { timeRange: false, autoRefresh: false } },
{ toolbar: { timeRange: true } },
),
).toStrictEqual({ toolbar: { timeRange: true, autoRefresh: false } });
});
it('cannot turn a writing control back on for a read-only mount', () => {
const merged = withControls(EMBEDDED, { dashboardActions: { delete: true } });
expect(resolveControls(merged, true).dashboardActions.delete).toBe(false);
});
});

View File

@@ -0,0 +1,136 @@
import type {
DashboardControlGroup,
DashboardControlId,
DashboardControls,
DashboardControlsInput,
} from './types';
export const DEFAULT_CONTROLS: DashboardControls = {
header: { breadcrumbs: true, share: true, feedback: true },
title: {
name: true,
description: true,
tags: true,
lock: true,
publicBadge: true,
},
toolbar: {
timeRange: true,
autoRefresh: true,
variables: true,
addVariable: true,
},
dashboardActions: {
fullscreen: true,
rename: true,
clone: true,
lock: true,
newSection: true,
delete: true,
configure: true,
json: true,
newPanel: true,
},
sectionActions: { addPanel: true, rename: true, clone: true, delete: true },
panelActions: {
view: true,
edit: true,
clone: true,
download: true,
createAlert: true,
move: true,
delete: true,
drilldown: true,
zoom: true,
},
viewModal: { switchToEdit: true },
};
/**
* Whether each control writes to the dashboard. A read-only mount hides every
* `true` here regardless of what the host asked for. Exhaustive, so a new
* control can't ship without deciding.
*/
export const CONTROL_WRITES: Record<DashboardControlId, boolean> = {
'header.breadcrumbs': false,
'header.share': false,
'header.feedback': false,
'title.name': false,
'title.description': false,
'title.tags': false,
'title.lock': true,
'title.publicBadge': false,
'toolbar.timeRange': false,
'toolbar.autoRefresh': false,
'toolbar.variables': false,
'toolbar.addVariable': true,
'dashboardActions.fullscreen': false,
'dashboardActions.rename': true,
'dashboardActions.clone': true,
'dashboardActions.lock': true,
'dashboardActions.newSection': true,
'dashboardActions.delete': true,
'dashboardActions.configure': true,
// Opens read-only on a non-editable mount.
'dashboardActions.json': false,
'dashboardActions.newPanel': true,
'sectionActions.addPanel': true,
'sectionActions.rename': true,
'sectionActions.clone': true,
'sectionActions.delete': true,
'panelActions.view': false,
'panelActions.edit': true,
'panelActions.clone': true,
'panelActions.download': false,
'panelActions.createAlert': false,
'panelActions.move': true,
'panelActions.delete': true,
'panelActions.drilldown': false,
'panelActions.zoom': false,
'viewModal.switchToEdit': true,
};
function resolveGroup<G extends DashboardControlGroup>(
group: G,
input: DashboardControlsInput | undefined,
readOnly: boolean,
): DashboardControls[G] {
const defaults: Record<string, boolean> = DEFAULT_CONTROLS[group];
const override = input?.[group] as
| boolean
| Partial<Record<string, boolean>>
| undefined;
const items = Object.keys(defaults).map((item): [string, boolean] => {
const requested =
typeof override === 'boolean'
? override
: (override?.[item] ?? defaults[item]);
const writes = CONTROL_WRITES[`${group}.${item}` as DashboardControlId];
return [item, requested && !(readOnly && writes)];
});
return Object.fromEntries(items) as DashboardControls[G];
}
/** Host input over the defaults, then every writing control off when read-only. */
export function resolveControls(
input: DashboardControlsInput | undefined,
readOnly: boolean,
): DashboardControls {
return {
header: resolveGroup('header', input, readOnly),
title: resolveGroup('title', input, readOnly),
toolbar: resolveGroup('toolbar', input, readOnly),
dashboardActions: resolveGroup('dashboardActions', input, readOnly),
sectionActions: resolveGroup('sectionActions', input, readOnly),
panelActions: resolveGroup('panelActions', input, readOnly),
viewModal: resolveGroup('viewModal', input, readOnly),
};
}
export function isControlVisible(
controls: DashboardControls,
id: DashboardControlId,
): boolean {
const [group, item] = id.split('.') as [DashboardControlGroup, string];
return (controls[group] as Record<string, boolean>)[item];
}

View File

@@ -0,0 +1,74 @@
import { DEFAULT_CONTROLS } from './dashboardControls';
import type {
DashboardControlGroup,
DashboardControls,
DashboardControlsInput,
} from './types';
/**
* The host page owns the header, the title row and the dashboard-level actions.
* Layout presets only: `readOnly` stays a separate switch, so no preset can turn
* a writing control back on.
*/
export const EMBEDDED: DashboardControlsInput = {
header: false,
title: false,
dashboardActions: false,
};
/** Embedded, and panels are just charts: no panel menu, no drilldown. Charts still zoom. */
export const CHARTS_ONLY: DashboardControlsInput = {
...EMBEDDED,
panelActions: {
view: false,
edit: false,
clone: false,
download: false,
createAlert: false,
move: false,
delete: false,
drilldown: false,
},
};
type GroupInput<G extends DashboardControlGroup> =
| boolean
| Partial<DashboardControls[G]>;
function mergeGroup<G extends DashboardControlGroup>(
group: G,
base: GroupInput<G> | undefined,
next: GroupInput<G> | undefined,
): GroupInput<G> | undefined {
if (next === undefined) {
return base;
}
if (typeof next === 'boolean' || base === undefined) {
return next;
}
// A whole-group boolean underneath expands to its items before the override.
const expanded: Partial<DashboardControls[G]> =
typeof base === 'boolean'
? (Object.fromEntries(
Object.keys(DEFAULT_CONTROLS[group]).map((item) => [item, base]),
) as Partial<DashboardControls[G]>)
: base;
return { ...expanded, ...next };
}
/** Merges presets and overrides; later inputs win, item by item. */
export function withControls(
...inputs: DashboardControlsInput[]
): DashboardControlsInput {
const groups = Object.keys(DEFAULT_CONTROLS) as DashboardControlGroup[];
return inputs.reduce<DashboardControlsInput>((merged, input) => {
const result: DashboardControlsInput = { ...merged };
groups.forEach((group) => {
const value = mergeGroup(group, merged[group], input[group]);
if (value !== undefined) {
(result as Record<string, unknown>)[group] = value;
}
});
return result;
}, {});
}

View File

@@ -0,0 +1,53 @@
import type { PanelActionId } from '../PanelsAndSectionsLayout/Panel/PanelActionsMenu/panelActionMeta';
/** Every control a host can show or hide, grouped by where it renders. */
export interface DashboardControls {
header: { breadcrumbs: boolean; share: boolean; feedback: boolean };
title: {
name: boolean;
description: boolean;
tags: boolean;
lock: boolean;
publicBadge: boolean;
};
toolbar: {
timeRange: boolean;
autoRefresh: boolean;
variables: boolean;
addVariable: boolean;
};
dashboardActions: {
fullscreen: boolean;
rename: boolean;
clone: boolean;
lock: boolean;
newSection: boolean;
delete: boolean;
configure: boolean;
json: boolean;
newPanel: boolean;
};
sectionActions: {
addPanel: boolean;
rename: boolean;
clone: boolean;
delete: boolean;
};
// Keyed by the panel menu's own ids, so a new panel action needs a control.
// `drilldown` (click) and `zoom` (drag-select a time range) act on the chart itself.
panelActions: Record<PanelActionId | 'drilldown' | 'zoom', boolean>;
viewModal: { switchToEdit: boolean };
}
export type DashboardControlGroup = keyof DashboardControls;
export type DashboardControlId = {
[
G in DashboardControlGroup
]: `${G}.${Extract<keyof DashboardControls[G], string>}`;
}[DashboardControlGroup];
/** A whole group as one boolean, or single items; omitted means shown. */
export type DashboardControlsInput = {
[G in DashboardControlGroup]?: boolean | Partial<DashboardControls[G]>;
};

View File

@@ -0,0 +1,12 @@
import { useDashboardStore } from '../store/useDashboardStore';
import { isControlVisible } from './dashboardControls';
import type { DashboardControlId } from './types';
/**
* Whether a control renders on this mount: the host's `controls`, with every
* writing control off when read-only. Lock and permission state stay with
* `useDashboardEditContext`, which decides disabled + reason.
*/
export function useDashboardControl(id: DashboardControlId): boolean {
return useDashboardStore((s) => isControlVisible(s.controls, id));
}

View File

@@ -0,0 +1,9 @@
import { useDashboardStore } from '../store/useDashboardStore';
import type { DashboardControlGroup, DashboardControls } from './types';
/** Every control in a group, resolved for this mount (see `useDashboardControl`). */
export function useDashboardControlGroup<G extends DashboardControlGroup>(
group: G,
): DashboardControls[G] {
return useDashboardStore((s) => s.controls[group]);
}

View File

@@ -11,6 +11,7 @@ import {
buildDashboardUpdatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
import { useDashboardStore } from '../../store/useDashboardStore';
import { useDashboardEditContext } from '../useDashboardEditContext';
const DASHBOARD_ID = 'dash-1';
@@ -44,6 +45,23 @@ describe('useDashboardEditContext - AuthZ', () => {
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
useDashboardStore.setState({ readOnly: false });
});
// A host-forced view-only mount outranks every grant.
it('is read-only on a read-only mount despite full permissions', async () => {
server.use(setupAuthzAdmin());
useDashboardStore.setState({ readOnly: true });
const { result } = renderGuard();
await waitFor(() =>
expect(result.current.editDisabledTooltip).toBe('dashboard_read_only_here'),
);
expect(result.current.isEditable).toBe(false);
expect(result.current.canEditDashboard).toBe(false);
expect(result.current.canDeleteDashboard).toBe(false);
expect(result.current.deleteDisabledTooltip).toBe('dashboard_read_only_here');
});
describe('permission granted', () => {

View File

@@ -1,57 +1,115 @@
import { renderHook } from '@testing-library/react';
import type { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { renderHook } from 'tests/test-utils';
import {
type DashboardtypesGettableDashboardV2DTO,
type DashboardtypesVariableDTO,
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTOKind as TextVariableKind,
} from 'api/generated/services/sigNoz.schemas';
import type { DashboardQuerySource } from '../../store/slices/editContextSlice';
import { useDashboardFetchRequired } from '../useDashboardFetchRequired';
const mockUseGetDashboardV2 = jest.fn();
const QUERY_KEY = ['/api/v2/dashboards/dash-1'];
const mockRefetch = jest.fn();
let mockSource: DashboardQuerySource | null;
jest.mock(
'pages/DashboardPage/DashboardContainer/store/useDashboardStore',
() => ({
useDashboardStore: (
selector: (s: { dashboardId: string }) => unknown,
): unknown => selector({ dashboardId: 'dash-1' }),
selector: (s: {
dashboardId: string;
dashboardQuerySource: DashboardQuerySource | null;
refetch: () => void;
}) => unknown,
): unknown =>
selector({
dashboardId: 'dash-1',
dashboardQuerySource: mockSource,
refetch: mockRefetch,
}),
}),
);
jest.mock('api/generated/services/dashboard', () => ({
useGetDashboardV2: (): unknown => mockUseGetDashboardV2(),
}));
function textVariable(name: string): DashboardtypesVariableDTO {
return {
kind: TextVariableKind.TextVariable,
spec: { name, display: { name }, value: '' },
};
}
function dashboardWith(
spec: Partial<DashboardtypesGettableDashboardV2DTO['spec']>,
): DashboardtypesGettableDashboardV2DTO {
return { id: 'dash-1', spec } as DashboardtypesGettableDashboardV2DTO;
}
function renderWithCache(
cached?: unknown,
): ReturnType<
typeof renderHook<ReturnType<typeof useDashboardFetchRequired>, void>
> {
const queryClient = new QueryClient();
if (cached !== undefined) {
queryClient.setQueryData(mockSource?.queryKey ?? QUERY_KEY, cached);
}
return renderHook(() => useDashboardFetchRequired(), {
wrapper: ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
),
});
}
describe('useDashboardFetchRequired', () => {
it('returns the loaded dashboard and its variables', () => {
const dashboard = {
spec: { variables: [{ name: 'region' }, { name: 'tier' }] },
};
mockUseGetDashboardV2.mockReturnValue({ data: { data: dashboard } });
beforeEach(() => {
mockSource = null;
});
const { result } = renderHook(() => useDashboardFetchRequired());
it('reads the dashboards API entry when the host sets no source', () => {
const variables = [textVariable('region'), textVariable('tier')];
const dashboard = dashboardWith({ variables });
const { result } = renderWithCache({ data: dashboard, status: 'success' });
expect(result.current.dashboard).toBe(dashboard);
expect(result.current.variables).toStrictEqual([
{ name: 'region' },
{ name: 'tier' },
]);
expect(result.current.variables).toBe(variables);
expect(result.current.refetch).toBe(mockRefetch);
});
it("reads a host's own cache entry through its select", () => {
const systemDashboard = { name: 'ai-o11y-overview', spec: {} };
mockSource = {
queryKey: ['/api/v2/dashboards/system/ai-o11y-overview'],
select: (response: { data: typeof systemDashboard }) =>
({
...response.data,
id: 'system-id',
}) as DashboardtypesGettableDashboardV2DTO,
};
const { result } = renderWithCache({ data: systemDashboard });
expect(result.current.dashboard).toStrictEqual({
name: 'ai-o11y-overview',
spec: {},
id: 'system-id',
});
});
it('normalizes variables to an empty array when the spec has none', () => {
mockUseGetDashboardV2.mockReturnValue({ data: { data: { spec: {} } } });
const { result } = renderHook(() => useDashboardFetchRequired());
const { result } = renderWithCache({ data: dashboardWith({}) });
expect(result.current.variables).toStrictEqual([]);
});
it('throws when used outside a loaded dashboard subtree', () => {
mockUseGetDashboardV2.mockReturnValue({ data: undefined });
it('throws when the source entry is empty', () => {
// Suppress React's expected render-error logging for this case.
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
expect(() => renderHook(() => useDashboardFetchRequired())).toThrow(
/outside a loaded dashboard subtree/,
);
expect(() => renderWithCache()).toThrow(/outside a loaded dashboard subtree/);
consoleError.mockRestore();
});

View File

@@ -111,3 +111,32 @@ describe('useGetQueryRangeV5 enabled gating', () => {
expect(mockQueryRangeV5).toHaveBeenCalledTimes(2);
});
});
describe('useGetQueryRangeV5 host fetch', () => {
it('fetches through the host fetch instead of query_range', async () => {
mockQueryRangeV5.mockReset();
const fetch = jest
.fn()
.mockResolvedValue({ status: 'success', data: {} } as QueryRangeV5200);
const wrapper = ({ children }: PropsWithChildren): JSX.Element => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
);
renderHook(
() =>
useGetQueryRangeV5({
requestPayload: REQUEST,
queryKey: KEY_A,
enabled: true,
fetch,
}),
{ wrapper },
);
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
expect(fetch.mock.calls[0][0]).toBe(REQUEST);
expect(mockQueryRangeV5).not.toHaveBeenCalled();
});
});

View File

@@ -19,10 +19,12 @@ jest.mock('api/generated/services/dashboard', () => ({
getGetDashboardV2QueryKey: jest.fn(() => ['/api/v2/dashboards/dash-1']),
}));
let mockReadOnly = false;
jest.mock('../../store/useDashboardStore', () => ({
useDashboardStore: jest.fn(
(selector: (s: { dashboardId: string; isEditable: boolean }) => unknown) =>
selector({ dashboardId: 'dash-1', isEditable: true }),
(selector: (s: { dashboardId: string; readOnly: boolean }) => unknown) =>
selector({ dashboardId: 'dash-1', readOnly: mockReadOnly }),
),
}));
@@ -35,6 +37,7 @@ const queryClient = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let captured: { fn: (ops: any) => unknown; options: any };
let mockMutateAsync: jest.Mock;
function dashboardEnvelope(name: string): GetDashboardV2200 {
return {
@@ -50,10 +53,12 @@ const replaceNameOp = {
beforeEach(() => {
jest.clearAllMocks();
mockReadOnly = false;
mockMutateAsync = jest.fn().mockResolvedValue(undefined);
(useQueryClient as jest.Mock).mockReturnValue(queryClient);
(useMutation as jest.Mock).mockImplementation((fn, options) => {
captured = { fn, options };
return { mutateAsync: jest.fn(), isLoading: false };
return { mutateAsync: mockMutateAsync, isLoading: false };
});
renderHook(() => useOptimisticPatch());
});
@@ -119,4 +124,22 @@ describe('useOptimisticPatch', () => {
// No follow-up GET (that could read stale data and flicker the UI back).
expect(queryClient.invalidateQueries).not.toHaveBeenCalled();
});
it('patchAsync rejects without sending on a read-only mount', async () => {
mockReadOnly = true;
const { result } = renderHook(() => useOptimisticPatch());
await expect(result.current.patchAsync([replaceNameOp])).rejects.toThrow(
'dashboard_read_only_here',
);
expect(mockMutateAsync).not.toHaveBeenCalled();
});
it('patchAsync sends the ops when the mount is editable', async () => {
const { result } = renderHook(() => useOptimisticPatch());
await result.current.patchAsync([replaceNameOp]);
expect(mockMutateAsync).toHaveBeenCalledWith([replaceNameOp]);
});
});

View File

@@ -11,6 +11,7 @@ import {
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
} from 'constants/queryCacheTime';
import { useDashboardStore } from '../../store/useDashboardStore';
import { usePanelQuery } from '../usePanelQuery';
import { useGetQueryRangeV5 } from '../useGetQueryRangeV5';
@@ -598,4 +599,79 @@ describe('usePanelQuery', () => {
expect(cacheTime).toBe(DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED);
});
});
describe('with a host query runner', () => {
const runner = {
key: 'public:abc',
run: jest.fn().mockResolvedValue({ status: 'success', data: {} }),
};
beforeEach(() => {
useDashboardStore.getState().setEditContext({
dashboardId: 'd1',
refetch: jest.fn(),
queryRunner: runner,
});
});
afterEach(() => {
useDashboardStore
.getState()
.setEditContext({ dashboardId: '', refetch: jest.fn() });
});
it('keys the cache by the runner and runs the query through it with the panel id', async () => {
renderHook(() =>
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
}),
);
const [{ queryKey, fetch, requestPayload }] =
mockUseGetQueryRangeV5.mock.calls[0];
expect(queryKey).toContain('public:abc');
await fetch(requestPayload);
expect(runner.run).toHaveBeenCalledWith({
panelId: 'p1',
requestPayload,
signal: undefined,
});
});
});
it('fetches a list panel unpaged when the host runner cannot page', () => {
useDashboardStore.getState().setEditContext({
dashboardId: 'd1',
refetch: jest.fn(),
queryRunner: { key: 'public:abc', paginates: false, run: jest.fn() },
});
const { result } = renderHook(() =>
usePanelQuery({
panel: panelWith('signoz/ListPanel', { name: 'A', signal: 'logs' }),
panelId: 'p1',
queryCapabilities: LIST_PANEL_CAPABILITIES,
}),
);
useDashboardStore
.getState()
.setEditContext({ dashboardId: '', refetch: jest.fn() });
expect(result.current.pagination).toBeUndefined();
});
it('runs on query_range when no host runner is set', () => {
renderHook(() =>
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPABILITIES,
}),
);
const [{ fetch }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(fetch).toBeUndefined();
});
});

View File

@@ -17,6 +17,7 @@ import { useDashboardFetchRequired } from './useDashboardFetchRequired';
*/
export function useDashboardEditContext(): DashboardEditContext {
const { dashboard } = useDashboardFetchRequired();
const readOnly = useDashboardStore((s) => s.readOnly);
const {
canRead,
canEdit,
@@ -24,12 +25,8 @@ export function useDashboardEditContext(): DashboardEditContext {
editChecks,
deletePermission,
areOtherPermissionsLoading,
} = useDashboardPermissions(dashboard.id);
} = useDashboardPermissions(dashboard.id, { enabled: !readOnly });
const { t } = useTranslation('dashboard');
const readOnlyOverride = useDashboardStore(
(s) => s.canEditDashboardOverride === false,
);
const deleteChecks = useMemo(() => [deletePermission], [deletePermission]);
return deriveEditContext({
@@ -44,6 +41,6 @@ export function useDashboardEditContext(): DashboardEditContext {
locked: t('dashboard_locked'),
readOnly: t('dashboard_read_only_here'),
},
readOnlyOverride,
readOnlyOverride: readOnly,
});
}

View File

@@ -1,34 +1,52 @@
import { useMemo } from 'react';
import { useQuery } from 'react-query';
import { getGetDashboardV2QueryKey } from 'api/generated/services/dashboard';
import type {
DashboardtypesGettableDashboardV2DTO,
DashboardtypesVariableDTO,
GetDashboardV2200,
} from 'api/generated/services/sigNoz.schemas';
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
import { useDashboardFetch } from './useDashboardFetch';
import type { UseDashboardFetchResult } from './useDashboardFetch';
export interface UseDashboardFetchRequiredResult {
/** Guaranteed present — the hook throws otherwise. */
dashboard: DashboardtypesGettableDashboardV2DTO;
/** Always an array, so callers skip `?? []` guards. */
variables: DashboardtypesVariableDTO[];
refetch: UseDashboardFetchResult['refetch'];
refetch: () => void;
}
const selectDashboardV2 = (
response: GetDashboardV2200,
): DashboardtypesGettableDashboardV2DTO => response.data;
/**
* Dashboard accessor for components rendered inside a loaded dashboard subtree. The
* root pages own the fetch and gate on a resolved dashboard before mounting their
* subtree, so this reuses the shared react-query cache entry and throws if the
* dashboard is missing — turning a silent `undefined` into a loud programmer error. It
* also derives spec collections (`variables`) since the dashboard is guaranteed here.
* root owns the fetch and publishes where it cached the dashboard
* (`dashboardQuerySource`); this observes that entry without fetching, and throws if
* it's empty — turning a silent `undefined` into a loud programmer error. It also
* derives spec collections (`variables`) since the dashboard is guaranteed here.
*
* Prefer this over `useDashboardFetch` outside the two root pages; the
* `signoz/no-dashboard-fetch-outside-root` lint rule enforces the split.
*/
export function useDashboardFetchRequired(): UseDashboardFetchRequiredResult {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const { dashboard, refetch } = useDashboardFetch(dashboardId);
const source = useDashboardStore((s) => s.dashboardQuerySource);
const queryKey =
source?.queryKey ?? getGetDashboardV2QueryKey({ id: dashboardId });
const select = source?.select ?? selectDashboardV2;
const refetch = useDashboardStore((s) => s.refetch);
// Observe only: the root owns the request, so this never fetches.
const { data: dashboard } = useQuery<
unknown,
unknown,
DashboardtypesGettableDashboardV2DTO | undefined
>({
queryKey,
select: select as (response: unknown) => DashboardtypesGettableDashboardV2DTO,
enabled: false,
});
const variables = useMemo(
() => dashboard?.spec?.variables ?? [],
@@ -39,7 +57,7 @@ export function useDashboardFetchRequired(): UseDashboardFetchRequiredResult {
throw new Error(
'useDashboardFetchRequired was used outside a loaded dashboard subtree — the ' +
'dashboard is undefined. Only call it below a root page that gates rendering ' +
'on useDashboardFetch (DashboardPage / PanelEditorPage).',
'on its fetch and seeds the edit context (DashboardContainer / PanelEditorPage).',
);
}

View File

@@ -21,6 +21,7 @@ interface UseDashboardStaleCheck {
export function useDashboardStaleCheck(
dashboard: DashboardtypesGettableDashboardV2DTO,
refetch: () => void,
enabled = true,
): UseDashboardStaleCheck {
const {
id: dashboardId,
@@ -34,7 +35,7 @@ export function useDashboardStaleCheck(
const [dismissedAt, setDismissedAt] = useState<string | null>(null);
useEffect(() => {
if (!dashboardId) {
if (!dashboardId || !enabled) {
return undefined;
}
const probe = async (): Promise<void> => {
@@ -56,7 +57,7 @@ export function useDashboardStaleCheck(
document.removeEventListener('visibilitychange', probe);
window.removeEventListener('focus', probe);
};
}, [dashboardId]);
}, [dashboardId, enabled]);
const serverUpdatedAt = server?.updatedAt;

View File

@@ -15,6 +15,11 @@ export interface UseGetQueryRangeV5Args {
keepPreviousData?: boolean;
/** Unused-entry TTL; callers drop to 0 under auto-refresh to bound cache growth (V1 parity). */
cacheTime?: number;
/** Replaces the `query_range` call; the caller keys the cache for it. */
fetch?: (
requestPayload: Querybuildertypesv5QueryRangeRequestDTO,
signal?: AbortSignal,
) => Promise<QueryRangeV5200>;
}
/**
@@ -49,6 +54,7 @@ export function useGetQueryRangeV5({
enabled,
keepPreviousData,
cacheTime,
fetch,
}: UseGetQueryRangeV5Args): UseQueryResult<QueryRangeV5200, Error> {
const queryClient = useQueryClient();
// An errored key has no data, so it is always stale; keep it enabled so
@@ -57,7 +63,7 @@ export function useGetQueryRangeV5({
return useQuery<QueryRangeV5200, Error>({
queryKey,
queryFn: ({ signal }) => queryRangeV5(requestPayload, signal),
queryFn: ({ signal }) => (fetch ?? queryRangeV5)(requestPayload, signal),
enabled: enabled || hasErrored,
retry: retryUnlessClientError,
keepPreviousData,

View File

@@ -39,6 +39,7 @@ export function useOptimisticPatch(
const storeDashboardId = useDashboardStore((s) => s.dashboardId);
const dashboardId = dashboardIdOverride ?? storeDashboardId;
const { t } = useTranslation('dashboard');
const readOnly = useDashboardStore((s) => s.readOnly);
const queryClient = useQueryClient();
const queryKey = getGetDashboardV2QueryKey({ id: dashboardId });
@@ -78,6 +79,10 @@ export function useOptimisticPatch(
const { mutateAsync } = mutation;
const patchAsync = useCallback(
(ops: DashboardtypesJSONPatchOperationDTO[]): Promise<unknown> => {
// The UI renders no edit affordance on a read-only mount, so reaching here is a bug.
if (readOnly) {
return Promise.reject(new Error(t('dashboard_read_only_here')));
}
// Defense-in-depth against a lock, read straight from the cache this hook
// already owns. Permission is enforced by the UI and, definitively, by the
// backend — checking it here would drag authz into every mutation.
@@ -87,7 +92,7 @@ export function useOptimisticPatch(
}
return mutateAsync(ops);
},
[queryClient, queryKey, mutateAsync],
[readOnly, queryClient, queryKey, mutateAsync, t],
);
return {

View File

@@ -2,7 +2,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQueryClient } from 'react-query';
// eslint-disable-next-line no-restricted-imports -- TODO: migrate global time selector off redux
import { useSelector } from 'react-redux';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import type {
DashboardtypesPanelDTO,
Querybuildertypesv5QueryRangeRequestDTO,
QueryRangeV5200,
} from 'api/generated/services/sigNoz.schemas';
import {
DASHBOARD_CACHE_TIME,
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
@@ -99,7 +103,6 @@ export function usePanelQuery({
() => !!getBuilderQueries(queries)[0]?.limit,
[queries],
);
const isPaginated = queryCapabilities.serverPaginated && !hasExplicitLimit;
const [pageSize, setPageSize] = useState(DEFAULT_LIST_PAGE_SIZE);
const [offset, setOffset] = useState(0);
@@ -126,6 +129,11 @@ export function usePanelQuery({
// use it (V1 parity). Names come from the fetch context (all variables, even
// unresolved ones); null before the variable bar initializes it.
const dashboardId = useDashboardStore((s) => s.dashboardId);
const queryRunner = useDashboardStore((s) => s.queryRunner);
const isPaginated =
queryCapabilities.serverPaginated &&
!hasExplicitLimit &&
queryRunner?.paginates !== false;
const variables = useDashboardStore(selectResolvedVariables(dashboardId));
const fetchContext = useDashboardStore((s) => s.variableFetchContext);
@@ -233,6 +241,7 @@ export function usePanelQuery({
// Only the variables this panel references re-key the cache, so an unrelated
// variable change doesn't refetch it.
scopedVariables,
queryRunner?.key ?? null,
],
[
panelId,
@@ -249,10 +258,24 @@ export function usePanelQuery({
offset,
pageSize,
scopedVariables,
queryRunner?.key,
],
);
const fetch = useMemo(
() =>
queryRunner
? (
payload: Querybuildertypesv5QueryRangeRequestDTO,
signal?: AbortSignal,
): Promise<QueryRangeV5200> =>
queryRunner.run({ panelId, requestPayload: payload, signal })
: undefined,
[queryRunner, panelId],
);
const response = useGetQueryRangeV5({
fetch,
requestPayload,
queryKey,
enabled: enabled && runnable && !isWaitingOnVariable,

View File

@@ -9,30 +9,40 @@ import PanelsAndSectionsLayout from './PanelsAndSectionsLayout';
import { useResolvedVariables } from './hooks/useResolvedVariables';
import { useSyncVariablesForSuggestions } from './hooks/useSyncVariablesForSuggestions';
import { useDashboardStore } from './store/useDashboardStore';
import type {
DashboardQuerySource,
QueryRunner,
} from './store/slices/editContextSlice';
import type { DashboardControlsInput } from './controls/types';
import { useDashboardControlGroup } from './controls/useDashboardControlGroup';
import styles from './DashboardContainer.module.scss';
import DashboardPageHeader from './components/DashboardPageHeader/DashboardPageHeader';
import LockedIndicator from './components/LockedIndicator/LockedIndicator';
import DashboardChangedDialog from './components/DashboardChangedDialog/DashboardChangedDialog';
import DashboardSettingsDrawer from './components/DashboardSettingsDrawer/DashboardSettingsDrawer';
import { useDashboardStaleCheck } from './hooks/useDashboardStaleCheck';
import { resolveDashboardImage } from 'pages/DashboardPage/DashboardContainer/dashboardIcons';
interface DashboardContainerProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
refetch: () => void;
/**
* @deprecated
* `canEditDashboardOverride` is a temporary solution to allow the dashboard to be view only.
* This is only used for LLM Observability.
* It will be removed in the future.
* TODO: @Ashwin / @Abhi — remove when the final solution is implemented.
*/
canEditDashboardOverride?: boolean;
/** Where the host cached `dashboard`; defaults to the dashboards API entry. */
source?: DashboardQuerySource;
/** View-only mount: no edit affordance, no write, no server freshness probe. */
readOnly?: boolean;
/** What renders; omitted controls are shown. Writing controls are off when read-only. */
controls?: DashboardControlsInput;
/** Runs panel queries; omitted runs them on `query_range`. */
queryRunner?: QueryRunner;
}
function DashboardContainer({
dashboard,
refetch,
canEditDashboardOverride,
source,
readOnly = false,
controls,
queryRunner,
}: DashboardContainerProps): JSX.Element {
const spec = dashboard.spec;
const image = resolveDashboardImage(dashboard.image);
@@ -55,7 +65,10 @@ function DashboardContainer({
setEditContext({
dashboardId: dashboard.id,
refetch,
canEditDashboardOverride,
source,
readOnly,
controls,
queryRunner,
});
// Resolve the variable selection into the V5 query payload and publish it to
@@ -66,7 +79,10 @@ function DashboardContainer({
// suggests them ($variable) in the panel editor and dashboards-page builder.
useSyncVariablesForSuggestions(dashboard);
const staleCheck = useDashboardStaleCheck(dashboard, refetch);
const staleCheck = useDashboardStaleCheck(dashboard, refetch, !readOnly);
const showHeader = Object.values(useDashboardControlGroup('header')).some(
Boolean,
);
// In full screen show only the sections and panels — the header/toolbar chrome
// is hidden for a clean presentation view (exit with Esc).
@@ -78,12 +94,13 @@ function DashboardContainer({
<AutoRefreshTicker />
) : (
<>
<DashboardPageHeader title={name} image={image} />
{showHeader && <DashboardPageHeader title={name} image={image} />}
<DashboardPageToolbar dashboard={dashboard} handle={fullScreenHandle} />
</>
)}
<PanelsAndSectionsLayout layouts={spec.layouts} panels={spec.panels} />
{!!dashboard.locked && <LockedIndicator />}
{!!dashboard.locked && !readOnly && <LockedIndicator />}
{!readOnly && <DashboardSettingsDrawer dashboard={dashboard} />}
<DashboardChangedDialog
open={staleCheck.showPrompt}
onReload={staleCheck.reload}

View File

@@ -0,0 +1,119 @@
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { useDashboardStore } from '../../useDashboardStore';
import type { DashboardQuerySource } from '../editContextSlice';
const refetch = (): void => undefined;
const systemSource: DashboardQuerySource = {
queryKey: ['/api/v2/dashboards/system/ai-o11y-overview'],
select: (response: { data: DashboardtypesGettableDashboardV2DTO }) =>
response.data,
};
function store(): ReturnType<typeof useDashboardStore.getState> {
return useDashboardStore.getState();
}
describe('editContextSlice.setEditContext', () => {
beforeEach(() => {
store().setEditContext({ dashboardId: '', refetch });
});
it('defaults to the dashboards API entry and an editable mount', () => {
store().setEditContext({ dashboardId: 'dash-1', refetch });
expect(store().dashboardQuerySource).toBeNull();
expect(store().readOnly).toBe(false);
});
it("stores a host's source and read-only flag", () => {
store().setEditContext({
dashboardId: 'system',
refetch,
source: systemSource,
readOnly: true,
});
expect(store().dashboardQuerySource).toBe(systemSource);
expect(store().readOnly).toBe(true);
});
it('clears read-only when the next mount does not ask for it', () => {
store().setEditContext({
dashboardId: 'system',
refetch,
source: systemSource,
readOnly: true,
});
store().setEditContext({ dashboardId: 'dash-1', refetch });
expect(store().readOnly).toBe(false);
expect(store().dashboardQuerySource).toBeNull();
});
// Called during render: a set on every call would re-render subscribers in a loop.
it('is a no-op when called again with equal values', () => {
store().setEditContext({ dashboardId: 'dash-1', refetch });
const listener = jest.fn();
const unsubscribe = useDashboardStore.subscribe(listener);
store().setEditContext({ dashboardId: 'dash-1', refetch });
expect(listener).not.toHaveBeenCalled();
unsubscribe();
});
it('is a no-op for an equal inline controls object', () => {
store().setEditContext({
dashboardId: 'dash-1',
refetch,
controls: { header: false },
});
const listener = jest.fn();
const unsubscribe = useDashboardStore.subscribe(listener);
store().setEditContext({
dashboardId: 'dash-1',
refetch,
controls: { header: false },
});
expect(listener).not.toHaveBeenCalled();
expect(store().controls.header.share).toBe(false);
unsubscribe();
});
it('resolves controls against read-only', () => {
store().setEditContext({ dashboardId: 'dash-1', refetch, readOnly: true });
expect(store().controls.panelActions.edit).toBe(false);
expect(store().controls.panelActions.view).toBe(true);
});
it('treats a runner with the same key as unchanged, a new key as a change', () => {
const run = jest.fn();
store().setEditContext({
dashboardId: 'dash-1',
refetch,
queryRunner: { key: 'public:a', run },
});
const listener = jest.fn();
const unsubscribe = useDashboardStore.subscribe(listener);
store().setEditContext({
dashboardId: 'dash-1',
refetch,
queryRunner: { key: 'public:a', run: jest.fn() },
});
expect(listener).not.toHaveBeenCalled();
store().setEditContext({
dashboardId: 'dash-1',
refetch,
queryRunner: { key: 'public:b', run },
});
expect(store().queryRunner?.key).toBe('public:b');
unsubscribe();
});
});

View File

@@ -1,24 +1,67 @@
import { isEqual } from 'lodash-es';
import type { QueryKey } from 'react-query';
import type { StateCreator } from 'zustand';
import type {
DashboardtypesGettableDashboardV2DTO,
Querybuildertypesv5QueryRangeRequestDTO,
QueryRangeV5200,
} from 'api/generated/services/sigNoz.schemas';
import { resolveControls } from '../../controls/dashboardControls';
import type {
DashboardControls,
DashboardControlsInput,
} from '../../controls/types';
import type { DashboardStore } from '../useDashboardStore';
/**
* The one piece of page context the subtree can't derive for itself: which
* dashboard is open, and how to refetch it. Editability is deliberately absent —
* `useDashboardEditContext` derives it from the caches.
* The react-query entry a host loaded the dashboard into, and how to read the
* dashboard out of it. Must be referentially stable (module-level).
*/
export interface DashboardQuerySource {
queryKey: QueryKey;
// `never` accepts any host's response type; the one caller casts at the boundary.
select: (response: never) => DashboardtypesGettableDashboardV2DTO | undefined;
}
/**
* Runs a panel's query, when not via `POST /api/v5/query_range`. `key` joins
* every panel cache key, so results from different runners never mix.
*/
export interface QueryRunner {
key: string;
/** False when `run` ignores offset and limit: list panels then fetch one unpaged result. */
paginates?: boolean;
run: (request: {
panelId: string;
requestPayload: Querybuildertypesv5QueryRangeRequestDTO;
signal?: AbortSignal;
}) => Promise<QueryRangeV5200>;
}
/**
* The page context the subtree can't derive for itself: which dashboard is open,
* where its cached copy lives, how to refetch it, and whether the host forces it
* view-only. Editability is deliberately absent — `useDashboardEditContext`
* derives it from the caches.
*/
export interface EditContextSlice {
dashboardId: string;
/** `null` reads the dashboards API entry for `dashboardId`. */
dashboardQuerySource: DashboardQuerySource | null;
refetch: () => void;
/**
* @deprecated Forces a view-only mount regardless of permissions. Used only by
* LLM Observability; see SigNoz/pulse-pod#283.
*/
canEditDashboardOverride?: boolean;
readOnly: boolean;
/** Resolved: the host's `controls` over the defaults, writing ones off when read-only. */
controls: DashboardControls;
/** `null` runs panel queries on `query_range`. */
queryRunner: QueryRunner | null;
setEditContext: (ctx: {
dashboardId: string;
refetch: () => void;
canEditDashboardOverride?: boolean;
source?: DashboardQuerySource;
readOnly?: boolean;
controls?: DashboardControlsInput;
queryRunner?: QueryRunner;
}) => void;
}
@@ -29,22 +72,37 @@ export const createEditContextSlice: StateCreator<
EditContextSlice
> = (set, get) => ({
dashboardId: '',
dashboardQuerySource: null,
refetch: (): void => undefined,
canEditDashboardOverride: undefined,
readOnly: false,
controls: resolveControls(undefined, false),
queryRunner: null,
// Idempotent (no-op when unchanged) so it's safe to call during render.
setEditContext: (ctx): void => {
const prev = get();
const source = ctx.source ?? null;
const readOnly = ctx.readOnly ?? false;
// Hosts pass `controls` inline, so compare by value, not reference.
const controls = resolveControls(ctx.controls, readOnly);
const queryRunner = ctx.queryRunner ?? null;
if (
prev.dashboardId === ctx.dashboardId &&
prev.refetch === ctx.refetch &&
prev.canEditDashboardOverride === ctx.canEditDashboardOverride
prev.readOnly === readOnly &&
prev.dashboardQuerySource === source &&
isEqual(prev.controls, controls) &&
// The key identifies a runner, so an inline one doesn't re-seed on every render.
prev.queryRunner?.key === queryRunner?.key
) {
return;
}
set({
dashboardId: ctx.dashboardId,
dashboardQuerySource: source,
refetch: ctx.refetch,
canEditDashboardOverride: ctx.canEditDashboardOverride,
readOnly,
controls,
queryRunner,
});
},
});

View File

@@ -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(

View File

@@ -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 },
};

View File

@@ -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 },
);
};

View File

@@ -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,

View File

@@ -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');
},
};

View File

@@ -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');
};

View File

@@ -3,7 +3,6 @@
* Do not hand-edit: regenerate instead.
*/
import { StatusCodes } from 'http-status-codes';
import ROUTES from 'constants/routes';
import { rest } from 'msw';
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
@@ -16,15 +15,12 @@ import {
LLM_MODELS,
LLM_SERVICES,
llmPanelResponse,
llmSystemDashboardResponse,
} from './__story_mockdata__/llmOverview';
const PANELS = 'AI overview · panels';
/**
* The dashboard itself is bundled with the app rather than fetched, so the only
* thing left to answer is the panels, the variables and the share state its
* header reads.
*/
/** The system dashboard definition, its panels and its variables. */
export const llmOverviewMocks = defineStoryMocks({
controls: {
series: countControl('Breakdown series', {
@@ -36,6 +32,10 @@ export const llmOverviewMocks = defineStoryMocks({
}),
},
handlers: (values, response) => [
rest.get(
'http://localhost/api/v2/dashboards/system/ai-o11y-overview',
response.json(() => llmSystemDashboardResponse()),
),
rest.post(
'http://localhost/api/v5/query_range',
response.json(async (req) =>
@@ -58,22 +58,6 @@ export const llmOverviewMocks = defineStoryMocks({
};
}),
),
// The header reads the share state on every load, so it answers even while
// the panels are held in the loading or failed state.
rest.get('http://localhost/api/v1/dashboards/:id/public', (_req, res, ctx) =>
res(
ctx.status(StatusCodes.NOT_FOUND),
ctx.json({
status: 'error',
error: {
code: 'public_dashboard_not_found',
message: "the AI observability overview isn't public",
url: '',
errors: [],
},
}),
),
),
],
config: () => ({ route: ROUTES.AI_OBSERVABILITY_OVERVIEW }),
});

View File

@@ -11,8 +11,8 @@ type LLMOverviewArgs = PageStoryArgs<typeof llmOverviewMocks>;
const pageStory = storyMocks(llmOverviewMocks, { layout: 'app' });
/**
* The AI observability overview: cost, token and latency panels over
* `query_range`.
* The AI observability overview: the read-only system dashboard, with cost, token
* and latency panels over `query_range`.
*
* Route: `/ai-observability/overview`.
*/
@@ -20,8 +20,7 @@ const meta = {
title: 'Pages/AI Observability/Overview',
component: LLMObservabilityPage,
...pageStory,
// The header's public-dashboard lookup is expected to 404, so every story opts out.
parameters: { ...pageStory.parameters, allowConsoleErrors: true },
parameters: { ...pageStory.parameters },
} satisfies Meta<LLMOverviewArgs>;
export default meta;
@@ -29,7 +28,7 @@ export default meta;
type Story = StoryObj<LLMOverviewArgs>;
/**
* The bundled overview dashboard: spend, tokens, latency and errors across
* The system overview dashboard: spend, tokens, latency and errors across
* every model the workspace calls, scoped by the model, environment and
* service variables above it.
*/

View File

@@ -3,6 +3,13 @@
* Do not hand-edit: regenerate instead.
*/
import {
DashboardtypesSourceDTO,
type DashboardtypesGettableSystemDashboardDTO,
type GetSystemDashboard200,
} from 'api/generated/services/sigNoz.schemas';
// The backend's own definition, so the story renders what the server serves.
import aiOverviewDefinition from '../../../../../../../pkg/modules/dashboard/impldashboard/fs/definitions/ai-o11y-overview.json';
import type {
MetricRangePayloadV5,
QueryRangeRequestV5,
@@ -161,3 +168,19 @@ export const llmPanelResponse = (
},
]);
};
export function llmSystemDashboardResponse(): GetSystemDashboard200 {
const { definition } = aiOverviewDefinition;
return {
status: 'success',
data: {
orgId: 'storybook-org',
locked: true,
source: DashboardtypesSourceDTO.system,
name: definition.name,
schemaVersion: definition.schemaVersion,
tags: [],
spec: definition.spec as DashboardtypesGettableSystemDashboardDTO['spec'],
},
};
}

View File

@@ -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>;

View File

@@ -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(),
);
},
};

View File

@@ -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');
};

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