mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-15 04:40:43 +00:00
Compare commits
29 Commits
feat/distr
...
test/compo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aab0d86f24 | ||
|
|
27f7e4ef2d | ||
|
|
7acebd8e4e | ||
|
|
101dc51eeb | ||
|
|
2de8bc7e66 | ||
|
|
542e0bfdd9 | ||
|
|
90fdef2991 | ||
|
|
0f75302284 | ||
|
|
f7e17e55fb | ||
|
|
d88f8e9b2f | ||
|
|
0c069c71e7 | ||
|
|
80232c03d6 | ||
|
|
b56c2c6350 | ||
|
|
ca7daf8ea9 | ||
|
|
d038407475 | ||
|
|
da3719ec7e | ||
|
|
99745b7dba | ||
|
|
fb60ae1594 | ||
|
|
d099467285 | ||
|
|
445e3cb088 | ||
|
|
0fbd56058f | ||
|
|
0f598c49ad | ||
|
|
b780551de6 | ||
|
|
8bc4c5f79c | ||
|
|
198230f064 | ||
|
|
0791306e52 | ||
|
|
792de1376d | ||
|
|
20e6d44289 | ||
|
|
9fef8eea69 |
@@ -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, row count, hidden header). A value the page supports must never fail the play.
|
||||
- **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.
|
||||
- **Mocks answer every request a reachable state makes.** Selecting a row, opening a view or switching a tab that fetches more hits a handler, never the 501 catch-all.
|
||||
- **Already covered, say so.** When the existing story and its controls already reach the changed state, no story edit is needed; state that in the PR description.
|
||||
- **Opt out only when there is no visual diff.** Skip stories only when the change renders nothing differently: types, api clients, hooks with no UI branch, tests. Deleting a page deletes its stories.
|
||||
|
||||
@@ -106,6 +106,27 @@ 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.
|
||||
@@ -116,7 +137,7 @@ await expect(toggle.getAttribute('aria-checked')).toBe(before);
|
||||
- **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. 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. 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).
|
||||
- **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
|
||||
@@ -143,7 +164,7 @@ await expect(toggle.getAttribute('aria-checked')).toBe(before);
|
||||
- [ ] 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, and the story file holds no play body
|
||||
- [ ] 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 request a reachable state makes (a selected row, an opened view, a switched tab) has a handler
|
||||
- [ ] console clean: no `[storybook] no msw handler`, no 501, no msw unhandled request, no React warning
|
||||
- [ ] no navigation overlay on mount
|
||||
|
||||
@@ -67,7 +67,7 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
telemetrystore-migrator:
|
||||
image: signoz/signoz-otel-collector:v0.144.6
|
||||
image: signoz/signoz-otel-collector:v0.144.15
|
||||
container_name: telemetrystore-migrator
|
||||
environment:
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
signoz-otel-collector:
|
||||
image: signoz/signoz-otel-collector:v0.142.0
|
||||
image: signoz/signoz-otel-collector:v0.144.15
|
||||
container_name: signoz-otel-collector-dev
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
|
||||
50
.github/workflows/build-staging.yaml
vendored
50
.github/workflows/build-staging.yaml
vendored
@@ -18,25 +18,25 @@ env:
|
||||
jobs:
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ contains(github.event.label.name, 'staging:') || github.event.ref == 'refs/heads/main' }}
|
||||
if: ${{ github.event_name == 'push' || startsWith(github.event.label.name, 'staging:') }}
|
||||
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 }}
|
||||
deployment: ${{ steps.build-info.outputs.deployment }}
|
||||
path: ${{ steps.build-info.outputs.path }}
|
||||
steps:
|
||||
- name: self-checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- id: token
|
||||
name: github-token-gen
|
||||
uses: actions/create-github-app-token@v1
|
||||
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
|
||||
with:
|
||||
app-id: ${{ secrets.PRIMUS_APP_ID }}
|
||||
private-key: ${{ secrets.PRIMUS_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
- name: primus-checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: signoz/primus
|
||||
ref: main
|
||||
@@ -44,22 +44,29 @@ jobs:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: build-info
|
||||
id: build-info
|
||||
env:
|
||||
LABEL: ${{ github.event.label.name }}
|
||||
run: |
|
||||
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
|
||||
|
||||
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"
|
||||
# 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
|
||||
echo "error: not able to determine deployment - please verify the PR label or the branch"
|
||||
# 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 "deployment=${deployment}" >> $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}"
|
||||
- name: create-dotenv
|
||||
run: |
|
||||
mkdir -p frontend
|
||||
@@ -71,7 +78,7 @@ jobs:
|
||||
echo 'VITE_ENVIRONMENT="staging"' >> frontend/.env
|
||||
echo 'VITE_VERSION="${{ steps.build-info.outputs.version }}"' >> frontend/.env
|
||||
- name: cache-dotenv
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: frontend/.env
|
||||
key: staging-dotenv-${{ github.sha }}
|
||||
@@ -115,7 +122,6 @@ 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]
|
||||
@@ -123,6 +129,6 @@ jobs:
|
||||
PRIMUS_REF: main
|
||||
GITHUB_ENVIRONMENT: staging
|
||||
GITHUB_SILENT: true
|
||||
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 }}"}'
|
||||
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 }}"}'
|
||||
|
||||
2
.github/workflows/e2eci.yaml
vendored
2
.github/workflows/e2eci.yaml
vendored
@@ -88,7 +88,7 @@ jobs:
|
||||
postgres:15 \
|
||||
clickhouse/clickhouse-server:25.12.5 \
|
||||
clickhouse/clickhouse-keeper:25.12.5 \
|
||||
signoz/signoz-schema-migrator:v0.144.9 \
|
||||
signoz/signoz-otel-collector:v0.144.16-rc.1 \
|
||||
wiremock/wiremock:2.35.1-1 \
|
||||
| xargs -P 0 -n 1 docker pull -q
|
||||
- name: python
|
||||
|
||||
7
.github/workflows/integrationci.yaml
vendored
7
.github/workflows/integrationci.yaml
vendored
@@ -40,6 +40,7 @@ jobs:
|
||||
suite:
|
||||
- alertmanager
|
||||
- alertmanagerrotation
|
||||
- auditquerier
|
||||
- basepath
|
||||
- callbackauthn
|
||||
- cloudintegrations
|
||||
@@ -83,8 +84,8 @@ jobs:
|
||||
- wal
|
||||
clickhouse-version:
|
||||
- 25.12.5
|
||||
schema-migrator-version:
|
||||
- v0.144.6
|
||||
signoz-otel-collector-version:
|
||||
- v0.144.16-rc.1
|
||||
postgres-version:
|
||||
- 15
|
||||
if: |
|
||||
@@ -155,4 +156,4 @@ jobs:
|
||||
--sqlite-mode ${{matrix.sqlite-mode}} \
|
||||
--postgres-version ${{matrix.postgres-version}} \
|
||||
--clickhouse-version ${{matrix.clickhouse-version}} \
|
||||
--schema-migrator-version ${{matrix.schema-migrator-version}}
|
||||
--signoz-otel-collector-version ${{matrix.signoz-otel-collector-version}}
|
||||
|
||||
@@ -25613,7 +25613,6 @@ paths:
|
||||
low (z-score {{$value}}).
|
||||
summary: Span ingestion anomaly
|
||||
condition:
|
||||
algorithm: standard
|
||||
compositeQuery:
|
||||
panelType: graph
|
||||
queries:
|
||||
@@ -26280,9 +26279,23 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- rule:create
|
||||
- rule:attach
|
||||
- notification-channel:attach
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
- rule:create
|
||||
- rule:attach
|
||||
- notification-channel:attach
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
summary: Create alert rule
|
||||
tags:
|
||||
- rules
|
||||
@@ -26327,9 +26340,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- rule:delete
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
- rule:delete
|
||||
summary: Delete alert rule
|
||||
tags:
|
||||
- rules
|
||||
@@ -26385,9 +26398,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- rule:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- rule:read
|
||||
summary: Get alert rule by ID
|
||||
tags:
|
||||
- rules
|
||||
@@ -26569,7 +26582,6 @@ paths:
|
||||
low (z-score {{$value}}).
|
||||
summary: Span ingestion anomaly
|
||||
condition:
|
||||
algorithm: standard
|
||||
compositeQuery:
|
||||
panelType: graph
|
||||
queries:
|
||||
@@ -27242,9 +27254,27 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- rule:update
|
||||
- rule:attach
|
||||
- rule:detach
|
||||
- notification-channel:attach
|
||||
- notification-channel:detach
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
- rule:update
|
||||
- rule:attach
|
||||
- rule:detach
|
||||
- notification-channel:attach
|
||||
- notification-channel:detach
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
summary: Patch alert rule
|
||||
tags:
|
||||
- rules
|
||||
@@ -27426,7 +27456,6 @@ paths:
|
||||
low (z-score {{$value}}).
|
||||
summary: Span ingestion anomaly
|
||||
condition:
|
||||
algorithm: standard
|
||||
compositeQuery:
|
||||
panelType: graph
|
||||
queries:
|
||||
@@ -28087,9 +28116,27 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- rule:update
|
||||
- rule:attach
|
||||
- rule:detach
|
||||
- notification-channel:attach
|
||||
- notification-channel:detach
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
- rule:update
|
||||
- rule:attach
|
||||
- rule:detach
|
||||
- notification-channel:attach
|
||||
- notification-channel:detach
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
summary: Update alert rule
|
||||
tags:
|
||||
- rules
|
||||
@@ -28156,9 +28203,25 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- rule:read
|
||||
- rule:create
|
||||
- rule:attach
|
||||
- notification-channel:attach
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
- rule:read
|
||||
- rule:create
|
||||
- rule:attach
|
||||
- notification-channel:attach
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
summary: Clone alert rule
|
||||
tags:
|
||||
- rules
|
||||
@@ -29045,7 +29108,6 @@ paths:
|
||||
low (z-score {{$value}}).
|
||||
summary: Span ingestion anomaly
|
||||
condition:
|
||||
algorithm: standard
|
||||
compositeQuery:
|
||||
panelType: graph
|
||||
queries:
|
||||
@@ -29712,9 +29774,23 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- rule:create
|
||||
- rule:attach
|
||||
- notification-channel:attach
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
- rule:create
|
||||
- rule:attach
|
||||
- notification-channel:attach
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
summary: Test alert rule
|
||||
tags:
|
||||
- rules
|
||||
@@ -31809,9 +31885,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- rule:list
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- rule:list
|
||||
summary: List alert rules (v3)
|
||||
tags:
|
||||
- rules
|
||||
|
||||
@@ -256,7 +256,7 @@ Tests can be configured using pytest options:
|
||||
- `--sqlite-mode` — SQLite journal mode: `delete` or `wal` (default: `delete`). Only relevant when `--sqlstore-provider=sqlite`.
|
||||
- `--postgres-version` — PostgreSQL version (default: `15`)
|
||||
- `--clickhouse-version` — ClickHouse version, also used for ClickHouse Keeper (default: `25.12.5`)
|
||||
- `--schema-migrator-version` — SigNoz schema migrator version (default: `v0.144.9`)
|
||||
- `--signoz-otel-collector-version`: SigNoz OTel collector version, used to run the ClickHouse migrations (default: `v0.144.16-rc.1`)
|
||||
- `--with-web` — Build the frontend into the SigNoz image (required for e2e)
|
||||
|
||||
Example:
|
||||
|
||||
@@ -11,6 +11,8 @@ 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;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { createAppContextMock } from 'tests/fixtures/appContextMock';
|
||||
import { LicensePlatform } from 'types/api/licensesV3/getActive';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { fn } from 'storybook/test';
|
||||
|
||||
import { storyChangelog } from './__story_mockdata__/changelog';
|
||||
import {
|
||||
showsCloudChangelog,
|
||||
showsSelfHostedChangelog,
|
||||
} from './__story_mockdata__/changelogModal.play';
|
||||
|
||||
import ChangelogModal from '../ChangelogModal';
|
||||
|
||||
const { activeLicense } = createAppContextMock(USER_ROLES.ADMIN);
|
||||
|
||||
/** `AppLayout` opens it one second after load, for a version the user has not seen. */
|
||||
const meta = {
|
||||
title: 'Components/Changelog Modal',
|
||||
component: ChangelogModal,
|
||||
tags: ['play'],
|
||||
parameters: { signoz: {} },
|
||||
args: {
|
||||
changelog: storyChangelog,
|
||||
onClose: fn(),
|
||||
},
|
||||
} satisfies Meta<typeof ChangelogModal>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Cloud workspaces update on their own, so the footer has no update actions. */
|
||||
export const Cloud: Story = {
|
||||
play: showsCloudChangelog,
|
||||
};
|
||||
|
||||
/** Self-hosted workspaces get "Skip for now" and "Update my workspace". */
|
||||
export const SelfHosted: Story = {
|
||||
parameters: {
|
||||
signoz: {
|
||||
appContext: {
|
||||
activeLicense: activeLicense && {
|
||||
...activeLicense,
|
||||
platform: LicensePlatform.SELF_HOSTED,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
play: showsSelfHostedChangelog,
|
||||
};
|
||||
62
frontend/src/components/ChangelogModal/stories/__story_mockdata__/changelog.ts
generated
Normal file
62
frontend/src/components/ChangelogModal/stories/__story_mockdata__/changelog.ts
generated
Normal file
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
ChangelogSchema,
|
||||
DeploymentType,
|
||||
} from 'types/api/changelog/getChangelogByVersion';
|
||||
|
||||
const TIMESTAMP = '2026-11-03T12:00:00Z';
|
||||
|
||||
function feature(
|
||||
id: number,
|
||||
title: string,
|
||||
description: string,
|
||||
): ChangelogSchema['features'][number] {
|
||||
return {
|
||||
id,
|
||||
documentId: `feature-${id}`,
|
||||
title,
|
||||
description,
|
||||
media: null,
|
||||
sort_order: id,
|
||||
createdAt: TIMESTAMP,
|
||||
updatedAt: TIMESTAMP,
|
||||
publishedAt: TIMESTAMP,
|
||||
deployment_type: DeploymentType.ALL,
|
||||
};
|
||||
}
|
||||
|
||||
/** Long enough to scroll, so the "Scroll for more" button shows. */
|
||||
export const storyChangelog: ChangelogSchema = {
|
||||
id: 1,
|
||||
documentId: 'changelog-v0.99.0',
|
||||
version: 'v0.99.0',
|
||||
release_date: '2026-11-03',
|
||||
createdAt: TIMESTAMP,
|
||||
updatedAt: TIMESTAMP,
|
||||
publishedAt: TIMESTAMP,
|
||||
features: [
|
||||
feature(
|
||||
1,
|
||||
'Dashboard sections',
|
||||
'Group panels into **sections** you can collapse, rename and drag. Existing dashboards move into a single section the first time you edit them.',
|
||||
),
|
||||
feature(
|
||||
2,
|
||||
'Service accounts',
|
||||
'Create service accounts with their own API keys and roles. Keys can expire, and you can revoke them without touching the account. See the [docs](https://signoz.io/docs/) for the API.',
|
||||
),
|
||||
feature(
|
||||
3,
|
||||
'Faster log search',
|
||||
'Full-text search over log bodies now uses a token index:\n\n- queries over a day of logs return up to 4x faster\n- `has()` and `hasToken()` use the same index\n- the old behaviour stays behind a setting for a release',
|
||||
),
|
||||
feature(
|
||||
4,
|
||||
'Routing policies',
|
||||
'Send an alert to a channel based on its labels instead of picking the channel in every rule. Policies are evaluated in order and the first match wins.',
|
||||
),
|
||||
],
|
||||
bug_fixes:
|
||||
'- Fixed the time picker losing the selected range after a refresh.\n- Fixed trace waterfall rows overlapping when a span name wraps.\n- Fixed CSV export dropping columns with dots in their names.',
|
||||
maintenance:
|
||||
'- Upgraded the OpenTelemetry collector to the latest release.\n- Reduced the memory used by the query service on large dashboards.',
|
||||
};
|
||||
19
frontend/src/components/ChangelogModal/stories/__story_mockdata__/changelogModal.play.ts
generated
Normal file
19
frontend/src/components/ChangelogModal/stories/__story_mockdata__/changelogModal.play.ts
generated
Normal file
@@ -0,0 +1,19 @@
|
||||
import { expect, screen, waitFor } from 'storybook/test';
|
||||
|
||||
const TITLE = /What’s New ⎯ Changelog : November 3, 2026/;
|
||||
|
||||
export const showsCloudChangelog = async (): Promise<void> => {
|
||||
await screen.findByRole('dialog', { name: TITLE });
|
||||
await expect(
|
||||
screen.queryByRole('button', { name: /Skip for now/ }),
|
||||
).not.toBeInTheDocument();
|
||||
};
|
||||
|
||||
export const showsSelfHostedChangelog = async (): Promise<void> => {
|
||||
await screen.findByRole('dialog', { name: TITLE });
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Update my workspace/ }),
|
||||
).toBeVisible(),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { withCanvas } from '@/storybook/decorators/withCanvas';
|
||||
|
||||
import { QueryBuilderV2 } from '../QueryBuilderV2';
|
||||
import {
|
||||
metricsFewGroupBy,
|
||||
metricsManyGroupBy,
|
||||
tracesFewAddOns,
|
||||
tracesManyAddOns,
|
||||
} from './__story_mockdata__/queryBuilderAddOns';
|
||||
import {
|
||||
expectMetricsGroupBy,
|
||||
expectTracesAddOnsOpen,
|
||||
} from './__story_mockdata__/queryBuilderAddOns.play';
|
||||
import {
|
||||
DEFAULT_ARGS,
|
||||
PANEL_EDITOR_ARGS,
|
||||
PANEL_EDITOR_PATH,
|
||||
routeWith,
|
||||
} from './QueryBuilderV2.stories.mocks';
|
||||
import QueryProbe from './QueryProbe';
|
||||
|
||||
/**
|
||||
* The add-ons a query opens with values already set: group by, having, order
|
||||
* by, limit and legend format side by side, with one value each and with
|
||||
* enough to wrap.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Components/Query Builder/Add-ons',
|
||||
component: QueryBuilderV2,
|
||||
tags: ['play'],
|
||||
decorators: [withCanvas({ maxWidth: 1100 })],
|
||||
args: { ...DEFAULT_ARGS, ...PANEL_EDITOR_ARGS },
|
||||
render: (args): JSX.Element => (
|
||||
<>
|
||||
<QueryBuilderV2 {...args} />
|
||||
<QueryProbe />
|
||||
</>
|
||||
),
|
||||
} satisfies Meta<typeof QueryBuilderV2>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Traces with every add-on open, one value in each. */
|
||||
export const FewValues: Story = {
|
||||
parameters: {
|
||||
signoz: { route: routeWith(PANEL_EDITOR_PATH, tracesFewAddOns) },
|
||||
},
|
||||
play: expectTracesAddOnsOpen(1),
|
||||
};
|
||||
|
||||
/** Traces with every add-on open and filled past one line. */
|
||||
export const ManyValues: Story = {
|
||||
parameters: {
|
||||
signoz: { route: routeWith(PANEL_EDITOR_PATH, tracesManyAddOns) },
|
||||
},
|
||||
play: expectTracesAddOnsOpen(9),
|
||||
};
|
||||
|
||||
/** A histogram grouped by one key, beside the aggregation selects. */
|
||||
export const MetricsFewGroupBy: Story = {
|
||||
parameters: {
|
||||
signoz: { route: routeWith(PANEL_EDITOR_PATH, metricsFewGroupBy) },
|
||||
},
|
||||
play: expectMetricsGroupBy(1),
|
||||
};
|
||||
|
||||
/** A histogram grouped by enough keys to wrap the group-by select. */
|
||||
export const MetricsManyGroupBy: Story = {
|
||||
parameters: {
|
||||
signoz: { route: routeWith(PANEL_EDITOR_PATH, metricsManyGroupBy) },
|
||||
},
|
||||
play: expectMetricsGroupBy(7),
|
||||
};
|
||||
@@ -8,7 +8,11 @@ import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import type { QueryBuilderV2 } from '../QueryBuilderV2';
|
||||
|
||||
@@ -18,6 +22,30 @@ type QueryBuilderProps = Parameters<typeof QueryBuilderV2>[0];
|
||||
export const routeWith = (path: string, query: Query): string =>
|
||||
`${path}?compositeQuery=${encodeURIComponent(JSON.stringify(query))}`;
|
||||
|
||||
/** `query` with its first builder query's fields replaced by `fields`. */
|
||||
export const queryWith = (
|
||||
query: Query,
|
||||
fields: Partial<IBuilderQuery>,
|
||||
): Query => ({
|
||||
...query,
|
||||
builder: {
|
||||
...query.builder,
|
||||
queryData: [{ ...query.builder.queryData[0], ...fields }],
|
||||
},
|
||||
});
|
||||
|
||||
/** A group-by key as the builder stores it: `id` is the select's option value. */
|
||||
export const groupByKey = (
|
||||
key: string,
|
||||
type = 'tag',
|
||||
dataType: DataTypes = DataTypes.String,
|
||||
): BaseAutocompleteData => ({
|
||||
key,
|
||||
dataType,
|
||||
type,
|
||||
id: `${key}--${dataType}--${type}`,
|
||||
});
|
||||
|
||||
export const meterQuery: Query = {
|
||||
...initialQueriesMap.metrics,
|
||||
builder: {
|
||||
|
||||
43
frontend/src/components/QueryBuilderV2/stories/__story_mockdata__/queryBuilderAddOns.play.ts
generated
Normal file
43
frontend/src/components/QueryBuilderV2/stories/__story_mockdata__/queryBuilderAddOns.play.ts
generated
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { expect, waitFor, within } from 'storybook/test';
|
||||
|
||||
const TRACES_ADD_ONS = [
|
||||
'group-by-content',
|
||||
'having-content',
|
||||
'order-by-content',
|
||||
'limit-content',
|
||||
'legend-format-content',
|
||||
];
|
||||
|
||||
const groupByTags = (scope: HTMLElement): string[] =>
|
||||
Array.from(
|
||||
within(scope)
|
||||
.getByTestId('group-by')
|
||||
.querySelectorAll('.ant-select-selection-item'),
|
||||
).map((tag) => tag.textContent ?? '');
|
||||
|
||||
/** Every add-on the seeded query fills opens on mount, group by with all its keys. */
|
||||
export const expectTracesAddOnsOpen =
|
||||
(groupByCount: number) =>
|
||||
async ({ canvasElement }: { canvasElement: HTMLElement }): Promise<void> => {
|
||||
const query = within(canvasElement).getByTestId('query-A');
|
||||
await waitFor(async () => {
|
||||
for (const testId of TRACES_ADD_ONS) {
|
||||
await expect(within(query).getByTestId(testId)).toBeVisible();
|
||||
}
|
||||
});
|
||||
const groupBy = within(query).getByTestId('group-by-content');
|
||||
await waitFor(() => expect(groupByTags(groupBy)).toHaveLength(groupByCount));
|
||||
};
|
||||
|
||||
/** The aggregation row's group by shows every seeded key. */
|
||||
export const expectMetricsGroupBy =
|
||||
(groupByCount: number) =>
|
||||
async ({ canvasElement }: { canvasElement: HTMLElement }): Promise<void> => {
|
||||
const query = within(canvasElement).getByTestId('query-A');
|
||||
await waitFor(() => expect(groupByTags(query)).toHaveLength(groupByCount));
|
||||
};
|
||||
73
frontend/src/components/QueryBuilderV2/stories/__story_mockdata__/queryBuilderAddOns.ts
generated
Normal file
73
frontend/src/components/QueryBuilderV2/stories/__story_mockdata__/queryBuilderAddOns.ts
generated
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { groupByKey, queryWith } from '../QueryBuilderV2.stories.mocks';
|
||||
|
||||
/** Every traces add-on holding one short value. */
|
||||
export const tracesFewAddOns: Query = queryWith(initialQueriesMap.traces, {
|
||||
groupBy: [groupByKey('service.name', 'resource')],
|
||||
having: { expression: 'count() > 10' },
|
||||
orderBy: [{ columnName: 'count()', order: 'desc' }],
|
||||
limit: 10,
|
||||
legend: '{{service.name}}',
|
||||
});
|
||||
|
||||
/** Every traces add-on filled past one line, so group by and order by wrap. */
|
||||
export const tracesManyAddOns: Query = queryWith(initialQueriesMap.traces, {
|
||||
groupBy: [
|
||||
groupByKey('service.name', 'resource'),
|
||||
groupByKey('deployment.environment', 'resource'),
|
||||
groupByKey('host.name', 'resource'),
|
||||
groupByKey('name', ''),
|
||||
groupByKey('http.method'),
|
||||
groupByKey('http.route'),
|
||||
groupByKey('response_status_code'),
|
||||
groupByKey('rpc.method'),
|
||||
groupByKey('hasError', '', DataTypes.bool),
|
||||
],
|
||||
having: {
|
||||
expression: 'count() > 10 AND count() < 5000 AND count() != 250',
|
||||
},
|
||||
orderBy: [
|
||||
{ columnName: 'count()', order: 'desc' },
|
||||
{ columnName: 'service.name', order: 'asc' },
|
||||
{ columnName: 'http.route', order: 'asc' },
|
||||
],
|
||||
limit: 100,
|
||||
legend:
|
||||
'{{service.name}} {{deployment.environment}} {{http.method}} {{http.route}}',
|
||||
});
|
||||
|
||||
const histogramWith = (keys: string[]): Query =>
|
||||
queryWith(initialQueriesMap.metrics, {
|
||||
aggregations: [
|
||||
{
|
||||
metricName: 'signoz_latency_bucket',
|
||||
temporality: '',
|
||||
timeAggregation: '',
|
||||
spaceAggregation: 'p90',
|
||||
reduceTo: undefined,
|
||||
},
|
||||
],
|
||||
groupBy: keys.map((key) => groupByKey(key)),
|
||||
});
|
||||
|
||||
/** A histogram grouped by one key in the aggregation row. */
|
||||
export const metricsFewGroupBy: Query = histogramWith(['service_name']);
|
||||
|
||||
/** A histogram grouped by enough keys to wrap the aggregation row's select. */
|
||||
export const metricsManyGroupBy: Query = histogramWith([
|
||||
'service_name',
|
||||
'operation',
|
||||
'status_code',
|
||||
'le',
|
||||
'resource_deployment.environment',
|
||||
'resource_host.name',
|
||||
'resource_k8s.cluster.name',
|
||||
]);
|
||||
@@ -98,6 +98,15 @@ export const noSavedFiltersHandler = rest.get(
|
||||
),
|
||||
);
|
||||
|
||||
export const noSavedTracesFiltersHandler = rest.get(
|
||||
'http://localhost/api/v2/quick_filters/traces',
|
||||
(_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json(quickFiltersResponse(QuickfiltertypesSourceDTO.traces, [])),
|
||||
),
|
||||
);
|
||||
|
||||
export const loadingFiltersHandlers = [
|
||||
rest.get('http://localhost/api/v2/quick_filters/logs', (_req, res, ctx) =>
|
||||
res(ctx.delay('infinite')),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import type { ComponentProps, ComponentType } from 'react';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
import { withCanvas } from '@/storybook/decorators/withCanvas';
|
||||
import type { GlobalMockArgs } from '@/storybook/globals';
|
||||
@@ -16,8 +17,9 @@ import {
|
||||
queryBuilder,
|
||||
selectedServiceQueryBuilder,
|
||||
noSavedFiltersHandler,
|
||||
noSavedTracesFiltersHandler,
|
||||
} from './QuickFilters.stories.mocks';
|
||||
import { QuickFiltersSource } from '../types';
|
||||
import { FiltersType, QuickFiltersSource } from '../types';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Quick Filters',
|
||||
@@ -110,6 +112,29 @@ export const SelectedExpandedCheckbox: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** The duration min/max row, which a checkbox-only config never reaches. */
|
||||
export const DurationFilter: Story = {
|
||||
args: {
|
||||
quickFilterSource: QuickFiltersSource.TRACES_EXPLORER,
|
||||
config: [
|
||||
...checkboxConfig,
|
||||
{
|
||||
attributeKey: {
|
||||
dataType: DataTypes.Float64,
|
||||
key: 'duration_nano',
|
||||
type: '',
|
||||
},
|
||||
defaultOpen: true,
|
||||
title: 'Duration',
|
||||
type: FiltersType.DURATION,
|
||||
},
|
||||
],
|
||||
},
|
||||
parameters: {
|
||||
msw: { handlers: [noSavedTracesFiltersHandler, ...handlers] },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Every tooltip the panel renders, held open: the reveal on each truncated
|
||||
* filter value. Nothing bounds those values, so the panel is answered with
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { matchRoute } from 'lib/router/matchRoute';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
@@ -26,6 +28,7 @@ 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 {
|
||||
@@ -53,6 +56,7 @@ import {
|
||||
getPanelTypeForRequestType,
|
||||
requestTypeFromActionQuery,
|
||||
} from './utils/getPanelTypeForRequestType';
|
||||
import { getTimeRangeFromActionQuery } from './utils/getTimeRangeFromActionQuery';
|
||||
import { isOnResourcePage } from './utils/isOnResourcePage';
|
||||
import { openSavedViewByKey } from './utils/openSavedView';
|
||||
import {
|
||||
@@ -279,6 +283,7 @@ interface ApplyFilterDeps {
|
||||
typeof useQueryBuilder
|
||||
>['redirectWithQueryBuilderData'];
|
||||
handleSetQueryData: ReturnType<typeof useQueryBuilder>['handleSetQueryData'];
|
||||
setCustomTimeRange: (start: number, end: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -344,18 +349,35 @@ 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,
|
||||
});
|
||||
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 },
|
||||
);
|
||||
return;
|
||||
}
|
||||
const base = explorerRouteForSignal(action.signal);
|
||||
@@ -368,6 +390,10 @@ 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);
|
||||
}
|
||||
@@ -402,6 +428,13 @@ 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
|
||||
@@ -433,9 +466,16 @@ export default function ActionsSection({
|
||||
pathname,
|
||||
redirectWithQueryBuilderData,
|
||||
handleSetQueryData,
|
||||
setCustomTimeRange,
|
||||
});
|
||||
});
|
||||
}, [actions, pathname, redirectWithQueryBuilderData, handleSetQueryData]);
|
||||
}, [
|
||||
actions,
|
||||
pathname,
|
||||
redirectWithQueryBuilderData,
|
||||
handleSetQueryData,
|
||||
setCustomTimeRange,
|
||||
]);
|
||||
|
||||
if (actions.length === 0) {
|
||||
return null;
|
||||
@@ -568,6 +608,7 @@ export default function ActionsSection({
|
||||
pathname,
|
||||
redirectWithQueryBuilderData,
|
||||
handleSetQueryData,
|
||||
setCustomTimeRange,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ 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';
|
||||
|
||||
@@ -24,7 +25,17 @@ 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,
|
||||
@@ -38,13 +49,18 @@ function renderUndoChip(): HTMLElement {
|
||||
}
|
||||
|
||||
// Labels differ per test: auto-applied filters are deduped module-wide by label.
|
||||
function countByServiceAction(label: string): MessageActionDTO {
|
||||
function countByServiceAction(
|
||||
label: string,
|
||||
{ start, end }: { start?: number; end?: number } = {},
|
||||
): MessageActionDTO {
|
||||
return {
|
||||
kind: MessageActionKindDTO.apply_filter,
|
||||
label,
|
||||
signal: ApplyFilterSignalDTO.logs,
|
||||
query: {
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
|
||||
start,
|
||||
end,
|
||||
compositeQuery: {
|
||||
queries: [
|
||||
{
|
||||
@@ -62,10 +78,8 @@ function countByServiceAction(label: string): MessageActionDTO {
|
||||
};
|
||||
}
|
||||
|
||||
function currentPanelTypesParam(): string | null {
|
||||
return new URLSearchParams(getCurrentLocation().search).get(
|
||||
QueryParams.panelTypes,
|
||||
);
|
||||
function currentParam(param: string): string | null {
|
||||
return new URLSearchParams(getCurrentLocation().search).get(param);
|
||||
}
|
||||
|
||||
describe('ActionsSection rollback chip', () => {
|
||||
@@ -139,7 +153,34 @@ describe('ActionsSection apply_filter', () => {
|
||||
await waitFor(() =>
|
||||
expect(getCurrentLocation().pathname).toBe(ROUTES.LOGS_EXPLORER),
|
||||
);
|
||||
expect(currentPanelTypesParam()).toBe(JSON.stringify(PANEL_TYPES.TABLE));
|
||||
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));
|
||||
});
|
||||
|
||||
it('switches the open explorer to the Table view when applied on it', async () => {
|
||||
@@ -167,6 +208,54 @@ 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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
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 };
|
||||
}
|
||||
@@ -630,7 +630,6 @@ describe('Footer utils', () => {
|
||||
expect(props.ruleType).toBe('anomaly_rule');
|
||||
expect(props.alertType).toBe(AlertTypes.METRICS_BASED_ALERT);
|
||||
expect(props.schemaVersion).toBe('v2alpha1');
|
||||
expect(props.condition.algorithm).toBe('standard');
|
||||
expect(props.condition.seasonality).toBe('daily');
|
||||
expect(props.condition.thresholds?.spec[0]).toMatchObject({
|
||||
target: 3,
|
||||
|
||||
@@ -324,7 +324,6 @@ export function buildCreateAnomalyAlertRulePayload(
|
||||
condition: {
|
||||
...payload.condition,
|
||||
thresholds: { kind: 'basic', spec: thresholds },
|
||||
algorithm: thresholdState.algorithm,
|
||||
seasonality: thresholdState.seasonality,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -323,7 +323,6 @@ describe('CreateAlert utils', () => {
|
||||
ruleType: 'anomaly_rule',
|
||||
condition: {
|
||||
...defaultPostableAlertRuleV2.condition,
|
||||
algorithm: 'standard',
|
||||
seasonality: 'weekly',
|
||||
thresholds: {
|
||||
kind: 'basic',
|
||||
@@ -349,7 +348,6 @@ describe('CreateAlert utils', () => {
|
||||
expect(getThresholdStateFromAlertDef(anomalyDef)).toMatchObject({
|
||||
operator: AlertThresholdOperator.IS_BELOW,
|
||||
thresholds: [{ thresholdValue: 3 }],
|
||||
algorithm: 'standard',
|
||||
seasonality: 'weekly',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -274,8 +274,6 @@ export function getThresholdStateFromAlertDef(
|
||||
matchType:
|
||||
alertDef.condition.thresholds?.spec[0].matchType ||
|
||||
AlertThresholdMatchType.AT_LEAST_ONCE,
|
||||
algorithm:
|
||||
alertDef.condition.algorithm || INITIAL_ALERT_THRESHOLD_STATE.algorithm,
|
||||
seasonality:
|
||||
alertDef.condition.seasonality || INITIAL_ALERT_THRESHOLD_STATE.seasonality,
|
||||
};
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
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;
|
||||
|
||||
@@ -23,18 +21,7 @@ interface HomeAlertRules {
|
||||
|
||||
export function useHomeAlertRules(): HomeAlertRules {
|
||||
const firing = useListRulesV3(FIRING_RULES_PARAMS, {
|
||||
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,
|
||||
}),
|
||||
},
|
||||
query: { cacheTime: 0 },
|
||||
});
|
||||
const firingRules = firing.data?.data.rules;
|
||||
const latest = useListRulesV3(
|
||||
|
||||
@@ -16,13 +16,42 @@ import {
|
||||
} from './K8sBaseDetails.stories.mocks';
|
||||
import type { K8sBaseDetailsProps } from './types';
|
||||
import {
|
||||
DrawerHistoryEntry,
|
||||
getDrawerHistoryDepth,
|
||||
pushDrawerHistory,
|
||||
resetDrawerHistory,
|
||||
useDrawerHistoryStore,
|
||||
} from './useDrawerHistoryStore';
|
||||
|
||||
type DrawerArgs = PageStoryArgs<typeof drawerMocks> &
|
||||
K8sBaseDetailsProps<unknown>;
|
||||
K8sBaseDetailsProps<unknown> & { earlierSteps?: number };
|
||||
|
||||
const TRAIL: DrawerHistoryEntry[] = [
|
||||
{
|
||||
category: InfraMonitoringEntity.CLUSTERS,
|
||||
label: 'mgmt',
|
||||
queryKeyPrefix: 'cluster',
|
||||
},
|
||||
{
|
||||
category: InfraMonitoringEntity.NAMESPACES,
|
||||
label: 'generator',
|
||||
queryKeyPrefix: 'namespace',
|
||||
},
|
||||
{
|
||||
category: InfraMonitoringEntity.DEPLOYMENTS,
|
||||
label: 'app-demo-loadgenerator',
|
||||
queryKeyPrefix: 'deployment',
|
||||
},
|
||||
{
|
||||
category: InfraMonitoringEntity.NODES,
|
||||
label: NODE_NAME,
|
||||
queryKeyPrefix: 'node',
|
||||
},
|
||||
].map(({ category, label, queryKeyPrefix }) => ({
|
||||
params: { selectedItem: label, category },
|
||||
category,
|
||||
label,
|
||||
entity: { name: label },
|
||||
queryKeyPrefix,
|
||||
}));
|
||||
|
||||
const route = `/infrastructure-monitoring/kubernetes?${INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY}=pods&${INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM}=${POD_UID}`;
|
||||
|
||||
@@ -33,19 +62,13 @@ const meta = {
|
||||
decorators: [
|
||||
(Story, context): JSX.Element => {
|
||||
// The back control appears only for a resource opened from another
|
||||
const depth = getDrawerHistoryDepth();
|
||||
if (context.args.openedFromANode && depth === 0) {
|
||||
pushDrawerHistory({
|
||||
params: { selectedItem: NODE_NAME, category: InfraMonitoringEntity.NODES },
|
||||
category: InfraMonitoringEntity.NODES,
|
||||
label: NODE_NAME,
|
||||
entity: { nodeName: NODE_NAME },
|
||||
queryKeyPrefix: 'node',
|
||||
const steps =
|
||||
context.args.earlierSteps ?? (context.args.openedFromANode ? 1 : 0);
|
||||
if (getDrawerHistoryDepth() !== steps) {
|
||||
useDrawerHistoryStore.setState({
|
||||
entries: TRAIL.slice(TRAIL.length - steps),
|
||||
});
|
||||
}
|
||||
if (!context.args.openedFromANode && depth > 0) {
|
||||
resetDrawerHistory();
|
||||
}
|
||||
|
||||
return <Story />;
|
||||
},
|
||||
@@ -60,3 +83,19 @@ type Story = StoryObj<DrawerArgs>;
|
||||
export const Default: Story = {
|
||||
args: { ...podEntityConfig.details } as Partial<DrawerArgs> as DrawerArgs,
|
||||
};
|
||||
|
||||
/** A pod drilled through a cluster, namespace, deployment and node, so the back control names the node. */
|
||||
export const DeepTrail: Story = {
|
||||
args: {
|
||||
...podEntityConfig.details,
|
||||
earlierSteps: 4,
|
||||
} as Partial<DrawerArgs> as DrawerArgs,
|
||||
};
|
||||
|
||||
/** A pod opened from the list, so the header shows the close control in place of back. */
|
||||
export const OpenedFromTheList: Story = {
|
||||
args: {
|
||||
...podEntityConfig.details,
|
||||
openedFromANode: false,
|
||||
} as Partial<DrawerArgs> as DrawerArgs,
|
||||
};
|
||||
|
||||
@@ -104,5 +104,5 @@ describe('AI observability Explorer', () => {
|
||||
...defaultOptionsQuery,
|
||||
selectColumns: [],
|
||||
});
|
||||
});
|
||||
}, 15_000);
|
||||
});
|
||||
|
||||
@@ -31,6 +31,8 @@ jest.mock('@floating-ui/dom', () => ({
|
||||
Promise.resolve({ x: 0, y: 0, placement, strategy, middlewareData: {} }),
|
||||
}));
|
||||
|
||||
jest.setTimeout(15_000);
|
||||
|
||||
const VIEW_1_ORDER_BY = JSON.stringify({ columnName: 'name', order: 'asc' });
|
||||
const VIEW_1_QUERY = "severity = 'critical'";
|
||||
|
||||
|
||||
@@ -3,13 +3,11 @@ 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';
|
||||
@@ -35,18 +33,7 @@ export function useAlertRulesData(
|
||||
const hasLoggedEvent = useRef(false);
|
||||
|
||||
const rulesResponse = useListRulesV3(params, {
|
||||
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,
|
||||
}),
|
||||
},
|
||||
query: { keepPreviousData: true },
|
||||
});
|
||||
|
||||
const rules = useMemo(
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
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));
|
||||
});
|
||||
});
|
||||
@@ -90,7 +90,7 @@ export const useHandleExplorerTabChange = (): {
|
||||
},
|
||||
redirectToUrl,
|
||||
undefined,
|
||||
newTab,
|
||||
{ newTab },
|
||||
);
|
||||
},
|
||||
[panelType, getUpdateQuery, redirectWithQueryBuilderData, viewKey],
|
||||
|
||||
@@ -140,7 +140,7 @@ export const useSafeNavigate = (
|
||||
|
||||
const navigationOptions = {
|
||||
...options,
|
||||
replace: isDefaultParamsNavigation || options?.replace,
|
||||
replace: options?.replace ?? isDefaultParamsNavigation,
|
||||
};
|
||||
|
||||
if (typeof to === 'string') {
|
||||
|
||||
@@ -139,6 +139,35 @@ export const AwaitingClarification: Story = {
|
||||
args: { agent: 'awaiting-clarification', contents: BRIEF },
|
||||
};
|
||||
|
||||
/** The severity select open, including the free-text option the form's Select renders. */
|
||||
export const ClarificationSelectOpen: Story = {
|
||||
args: { agent: 'awaiting-clarification', contents: BRIEF },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
// Open from the keyboard, and retry: the message list remounts its items
|
||||
// while it measures the thread, which can close the popup again. The
|
||||
// "Other" checkbox in the Notify field carries the same text, so assert
|
||||
// on the popup's option role.
|
||||
await waitFor(async () => {
|
||||
const severityLabel = canvas.getByText('Severity');
|
||||
const field = severityLabel.closest('div');
|
||||
|
||||
if (!field) {
|
||||
throw new Error('Severity field is not in the clarification form');
|
||||
}
|
||||
|
||||
within(field).getByRole('combobox').focus();
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await screen.findByRole(
|
||||
'option',
|
||||
{ name: 'Other (type your own)' },
|
||||
{ timeout: 1_000 },
|
||||
);
|
||||
}, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/** Reopening the page on a thread that has not come back yet. */
|
||||
export const LoadingThread: Story = {
|
||||
args: { dataState: 'loading' },
|
||||
@@ -231,6 +260,23 @@ export const NegativeFeedback: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Renaming a thread: the title input the row swaps in. */
|
||||
export const ConversationRename: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await clickUntil(async () => {
|
||||
const [actions] = await within(canvasElement).findAllByLabelText(
|
||||
/conversation actions/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
return actions;
|
||||
}, /^Rename$/);
|
||||
await userEvent.click(screen.getByText('Rename'));
|
||||
await within(canvasElement).findByDisplayValue(/checkout p99 regression/i);
|
||||
},
|
||||
};
|
||||
|
||||
/** What a conversation row offers: rename, a link to it, and archiving. */
|
||||
export const ConversationActions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
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 {
|
||||
@@ -12,7 +13,10 @@ 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,
|
||||
@@ -281,3 +285,37 @@ export const runRowAction = async (
|
||||
await userEvent.click(actions);
|
||||
await userEvent.click(await screen.findByRole('menuitem', { name }));
|
||||
};
|
||||
|
||||
export type AlertRulesArgs = PageStoryArgs<typeof alertRulesMocks>;
|
||||
|
||||
export const listedRules = (
|
||||
args: AlertRulesArgs,
|
||||
): RuletypesListableRuleDTO[] => {
|
||||
const params = new URL(listRoute(args.view), 'http://localhost').searchParams;
|
||||
if (
|
||||
args.dataState !== 'loaded' ||
|
||||
(args.invalidQuery !== 'off' && params.get('query'))
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return alertRulesPage(
|
||||
args.rules,
|
||||
{ severity: args.ruleSeverity, state: args.ruleState },
|
||||
params,
|
||||
).data.rules;
|
||||
};
|
||||
|
||||
export const hasRowMenu = (args: AlertRulesArgs): boolean =>
|
||||
listedRules(args).length > 0 &&
|
||||
componentPermission.action.includes(
|
||||
accessFor(args.access, args.permissions, args.revoked).legacyRole,
|
||||
);
|
||||
|
||||
export const showsNoRulesPage = (args: AlertRulesArgs): boolean =>
|
||||
args.view === DEFAULT_RULE_VIEW &&
|
||||
args.dataState === 'loaded' &&
|
||||
args.rules === 0;
|
||||
|
||||
export const listsSavedViews = (args: AlertRulesArgs): boolean =>
|
||||
args.ruleViews > 0 && args.ruleViewsState === 'success';
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { alertRulesMocks } from './AlertRules.stories.mocks';
|
||||
import {
|
||||
alertRulesMocks,
|
||||
type AlertRulesArgs,
|
||||
} from './AlertRules.stories.mocks';
|
||||
import {
|
||||
cloneAlert,
|
||||
copyLabel,
|
||||
@@ -22,8 +24,6 @@ import {
|
||||
import AlertList from '../../index';
|
||||
import { RULE_MAX } from '../../stories/__story_mockdata__/alerts';
|
||||
|
||||
type AlertRulesArgs = PageStoryArgs<typeof alertRulesMocks>;
|
||||
|
||||
const pageStory = storyMocks(alertRulesMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,20 +3,22 @@
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
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 { RuletypesAlertStateDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import {
|
||||
alertRulesMocks,
|
||||
type AlertRulesArgs,
|
||||
hasRowMenu,
|
||||
listedRules,
|
||||
listsSavedViews,
|
||||
runRowAction,
|
||||
showsNoRulesPage,
|
||||
untilLoaded,
|
||||
} from '../AlertRules.stories.mocks';
|
||||
|
||||
interface PlayContext {
|
||||
canvasElement: HTMLElement;
|
||||
args: PageStoryArgs<typeof alertRulesMocks>;
|
||||
args: AlertRulesArgs;
|
||||
}
|
||||
|
||||
// A row's rename and delete only take the pointer while the row is hovered,
|
||||
@@ -25,10 +27,15 @@ 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-actions', undefined, untilLoaded);
|
||||
await canvas.findAllByTestId(/^alert-row-.+-name$/, undefined, untilLoaded);
|
||||
const search = canvas.getByTestId('alert-rules-list-search');
|
||||
await userEvent.click(search.querySelector('.cm-content') ?? search);
|
||||
await screen.findByText('labels.team');
|
||||
@@ -49,7 +56,12 @@ 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',
|
||||
@@ -57,12 +69,17 @@ export const openRenameViewPopover = async ({
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByText('Rename view');
|
||||
await screen.findAllByText('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',
|
||||
@@ -75,7 +92,12 @@ 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,
|
||||
@@ -88,7 +110,12 @@ 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,
|
||||
@@ -96,12 +123,21 @@ export const openDisabledRuleActions = async ({
|
||||
);
|
||||
|
||||
await userEvent.click(actions);
|
||||
await screen.findByText(/^enable$/i);
|
||||
await screen.findByText(
|
||||
listedRules(args)[0].state === RuletypesAlertStateDTO.disabled
|
||||
? /^enable$/i
|
||||
: /^disable$/i,
|
||||
);
|
||||
};
|
||||
|
||||
export const openColumnPicker = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (showsNoRulesPage(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'alert-columns-button',
|
||||
@@ -116,11 +152,7 @@ export const openStatesFilter = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (
|
||||
args.rules === 0 &&
|
||||
args.view === DEFAULT_RULE_VIEW &&
|
||||
args.dataState === 'loaded'
|
||||
) {
|
||||
if (showsNoRulesPage(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -144,6 +176,10 @@ 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(
|
||||
@@ -156,6 +192,10 @@ export const cloneAlert = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!hasRowMenu(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await runRowAction(canvasElement, /^clone$/i);
|
||||
if (args.cloneState === 'success') {
|
||||
await expect(
|
||||
@@ -168,6 +208,10 @@ export const deleteAlert = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!hasRowMenu(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await runRowAction(canvasElement, /^delete$/i);
|
||||
if (args.deleteState === 'success') {
|
||||
await expect(
|
||||
@@ -176,7 +220,11 @@ export const deleteAlert = async ({
|
||||
}
|
||||
};
|
||||
|
||||
export const copyLabel = async (): Promise<void> => {
|
||||
export const copyLabel = async ({ args }: PlayContext): Promise<void> => {
|
||||
if (!args.tooltipsOpen || listedRules(args).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [copy] = await screen.findAllByRole(
|
||||
'button',
|
||||
{ name: 'Copy to clipboard' },
|
||||
@@ -184,5 +232,7 @@ export const copyLabel = async (): Promise<void> => {
|
||||
);
|
||||
|
||||
await userEvent.click(copy);
|
||||
await expect(await screen.findByText(/copied! use in search/i)).toBeVisible();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/copied! use in search/i)).toBeVisible(),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -21,6 +21,10 @@ import {
|
||||
WEBHOOK_AUTHS,
|
||||
type WebhookAuthChoice,
|
||||
} from '../../stories/__story_mockdata__/alerts';
|
||||
import {
|
||||
recordChannelTestRequest,
|
||||
resetChannelTestRequests,
|
||||
} from './__story_mockdata__/channelsEdit';
|
||||
|
||||
const STORY_CHANNEL_ID = '1';
|
||||
|
||||
@@ -87,6 +91,13 @@ export const channelsEditMocks = defineStoryMocks({
|
||||
: resolveChannelActionSuccess,
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v2/notification_channels/test',
|
||||
async (req) => {
|
||||
recordChannelTestRequest(await req.json());
|
||||
},
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v2/notification_channels/test',
|
||||
values.testOutcome === 'fails'
|
||||
@@ -95,4 +106,5 @@ export const channelsEditMocks = defineStoryMocks({
|
||||
),
|
||||
],
|
||||
config: () => ({ route: `/alerts/channels/edit/${STORY_CHANNEL_ID}` }),
|
||||
effect: () => resetChannelTestRequests(),
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { expectEditableForm } from './__story_mockdata__/channelsEdit.play';
|
||||
import { channelsEditMocks } from './ChannelsEdit.stories.mocks';
|
||||
|
||||
import AlertList from '../../index';
|
||||
@@ -36,8 +37,10 @@ type Story = StoryObj<ChannelsEditArgs>;
|
||||
/**
|
||||
* A saved notification channel opened for editing: the name and the type are
|
||||
* fixed, and the integration's own settings are filled from what was stored.
|
||||
* The Channel type control swaps the integration, and typing into any field
|
||||
* unlocks Save.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
export const Default: Story = { play: expectEditableForm };
|
||||
|
||||
/** Mutation: clearing the required webhook URL surfaces the form's validation feedback. */
|
||||
export const InvalidRequiredFields: Story = {
|
||||
@@ -110,21 +113,25 @@ export const SaveFailure: Story = {
|
||||
*/
|
||||
export const PagerDuty: Story = {
|
||||
args: { channelType: 'pagerduty' },
|
||||
play: expectEditableForm,
|
||||
};
|
||||
|
||||
/** The webhook channel, saved with basic auth on the outgoing request. */
|
||||
export const Webhook: Story = {
|
||||
args: { channelType: 'webhook' },
|
||||
play: expectEditableForm,
|
||||
};
|
||||
|
||||
/** The webhook channel saved with a bearer token: only the token field shows. */
|
||||
export const WebhookBearerToken: Story = {
|
||||
args: { channelType: 'webhook', webhookAuth: 'bearer' },
|
||||
play: expectEditableForm,
|
||||
};
|
||||
|
||||
/** The webhook channel saved without credentials: the auth option is None. */
|
||||
export const WebhookWithoutAuth: Story = {
|
||||
args: { channelType: 'webhook', webhookAuth: 'none' },
|
||||
play: expectEditableForm,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -133,6 +140,7 @@ export const WebhookWithoutAuth: Story = {
|
||||
*/
|
||||
export const Opsgenie: Story = {
|
||||
args: { channelType: 'opsgenie' },
|
||||
play: expectEditableForm,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -141,6 +149,7 @@ export const Opsgenie: Story = {
|
||||
*/
|
||||
export const Email: Story = {
|
||||
args: { channelType: 'email' },
|
||||
play: expectEditableForm,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -149,11 +158,13 @@ export const Email: Story = {
|
||||
*/
|
||||
export const MicrosoftTeams: Story = {
|
||||
args: { channelType: 'msteams' },
|
||||
play: expectEditableForm,
|
||||
};
|
||||
|
||||
/** The Google Chat channel, filled from the space's incoming webhook. */
|
||||
export const GoogleChat: Story = {
|
||||
args: { channelType: 'googlechat' },
|
||||
play: expectEditableForm,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -163,7 +174,8 @@ export const GoogleChat: Story = {
|
||||
*/
|
||||
export const Jira: Story = {
|
||||
args: { channelType: 'jira' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await expectEditableForm({ canvasElement, args });
|
||||
// the advanced fields have their own card now, so nothing has to be
|
||||
// expanded before they can be read
|
||||
await expect(
|
||||
@@ -183,7 +195,8 @@ export const Jira: Story = {
|
||||
*/
|
||||
export const JiraSelfHosted: Story = {
|
||||
args: { channelType: 'jira', jiraDeployment: 'selfhosted' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await expectEditableForm({ canvasElement, args });
|
||||
const canvas = within(canvasElement);
|
||||
await expect(
|
||||
await canvas.findByTestId('jira-pat-textbox', undefined, {
|
||||
@@ -202,6 +215,7 @@ export const JiraSelfHosted: Story = {
|
||||
*/
|
||||
export const JiraServiceManagementOps: Story = {
|
||||
args: { channelType: 'jsmops' },
|
||||
play: expectEditableForm,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -210,9 +224,11 @@ export const JiraServiceManagementOps: Story = {
|
||||
*/
|
||||
export const IncidentIO: Story = {
|
||||
args: { channelType: 'incidentio' },
|
||||
play: expectEditableForm,
|
||||
};
|
||||
|
||||
/** A Telegram channel, with its bot token masked behind the password field. */
|
||||
export const Telegram: Story = {
|
||||
args: { channelType: 'telegram' },
|
||||
play: expectEditableForm,
|
||||
};
|
||||
|
||||
146
frontend/src/pages/AlertList/ChannelsEdit/stories/__story_mockdata__/channelsEdit.play.ts
generated
Normal file
146
frontend/src/pages/AlertList/ChannelsEdit/stories/__story_mockdata__/channelsEdit.play.ts
generated
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { expect, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { channelSpec } from '../../../stories/__story_mockdata__/alerts';
|
||||
import { PAGE_LOAD } from '../../../stories/channelAuthz';
|
||||
import type { channelsEditMocks } from '../ChannelsEdit.stories.mocks';
|
||||
import { channelTestRequests } from './channelsEdit';
|
||||
|
||||
interface PlayContext {
|
||||
canvasElement: HTMLElement;
|
||||
args: PageStoryArgs<typeof channelsEditMocks>;
|
||||
}
|
||||
|
||||
/** Native text controls only: the switches and radios keep a hidden input of their own. */
|
||||
const FIELD_SELECTOR =
|
||||
'form input:not([aria-hidden="true"]):not([type="hidden"]), form textarea';
|
||||
|
||||
const allFields = (canvasElement: HTMLElement): HTMLElement[] =>
|
||||
Array.from(canvasElement.querySelectorAll<HTMLElement>(FIELD_SELECTOR));
|
||||
|
||||
/** A saved channel's name is fixed, so it is off whether or not the form is. */
|
||||
const editableFields = (canvasElement: HTMLElement): HTMLElement[] =>
|
||||
allFields(canvasElement).filter((field) => field.id !== 'channel-name');
|
||||
|
||||
const SLACK_ROW_BUTTONS = /^slack-(field|action)-(remove-\d+|add)$/;
|
||||
|
||||
const slackRowButtons = (canvasElement: HTMLElement): HTMLElement[] =>
|
||||
within(canvasElement).queryAllByTestId(SLACK_ROW_BUTTONS);
|
||||
|
||||
export const expectEditableForm = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
const save = await within(canvasElement).findByTestId(
|
||||
'save-channel-button',
|
||||
undefined,
|
||||
PAGE_LOAD,
|
||||
);
|
||||
|
||||
await waitFor(
|
||||
() => expect(editableFields(canvasElement).length).toBeGreaterThan(0),
|
||||
PAGE_LOAD,
|
||||
);
|
||||
for (const field of editableFields(canvasElement)) {
|
||||
await expect(field).toBeEnabled();
|
||||
}
|
||||
|
||||
await expect(save).toHaveAttribute('aria-disabled', 'true');
|
||||
await userEvent.type(editableFields(canvasElement)[0], 'x');
|
||||
await waitFor(
|
||||
() => expect(save).toHaveAttribute('aria-disabled', 'false'),
|
||||
PAGE_LOAD,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The permission check answers after the form has mounted editable, so the
|
||||
* fields are polled until they lock rather than read once.
|
||||
*/
|
||||
export const expectReadOnlyForm = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
const save = await canvas.findByTestId(
|
||||
'save-channel-button',
|
||||
undefined,
|
||||
PAGE_LOAD,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const fields = allFields(canvasElement);
|
||||
|
||||
void expect(fields.length).toBeGreaterThan(1);
|
||||
fields.forEach((field) => {
|
||||
void expect(field).toBeDisabled();
|
||||
});
|
||||
}, PAGE_LOAD);
|
||||
await expect(save).toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
if (args.channelType === 'slack') {
|
||||
const buttons = slackRowButtons(canvasElement);
|
||||
const fieldCount = allFields(canvasElement).length;
|
||||
|
||||
// two field rows, one action row, and an add button for each list
|
||||
await expect(buttons).toHaveLength(5);
|
||||
for (const button of buttons) {
|
||||
await userEvent.click(button);
|
||||
}
|
||||
|
||||
await expect(slackRowButtons(canvasElement)).toHaveLength(5);
|
||||
await expect(allFields(canvasElement)).toHaveLength(fieldCount);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Switching the mode swaps which credential fields render, so a click that
|
||||
* landed would change how many fields the form holds.
|
||||
*/
|
||||
export const expectModeRadioLocked = async (
|
||||
context: PlayContext,
|
||||
radio: RegExp,
|
||||
): Promise<void> => {
|
||||
await expectReadOnlyForm(context);
|
||||
|
||||
const { canvasElement } = context;
|
||||
const fieldCount = allFields(canvasElement).length;
|
||||
|
||||
await userEvent.click(
|
||||
within(canvasElement).getByRole('radio', { name: radio }),
|
||||
);
|
||||
|
||||
await expect(allFields(canvasElement)).toHaveLength(fieldCount);
|
||||
};
|
||||
|
||||
export const expectWebhookAuthLocked = (context: PlayContext): Promise<void> =>
|
||||
expectModeRadioLocked(context, /bearer/i);
|
||||
|
||||
export const expectJiraDeploymentLocked = (
|
||||
context: PlayContext,
|
||||
): Promise<void> => expectModeRadioLocked(context, /self-hosted/i);
|
||||
|
||||
export const sendSavedConfig = async (context: PlayContext): Promise<void> => {
|
||||
const { canvasElement, args } = context;
|
||||
|
||||
await expectReadOnlyForm(context);
|
||||
|
||||
const test = await within(canvasElement).findByTestId('test-channel-button');
|
||||
|
||||
await waitFor(
|
||||
() => expect(test).toHaveAttribute('aria-disabled', 'false'),
|
||||
PAGE_LOAD,
|
||||
);
|
||||
await userEvent.click(test);
|
||||
await waitFor(() => expect(channelTestRequests()).toHaveLength(1), PAGE_LOAD);
|
||||
|
||||
await expect(channelTestRequests()[0].config).toMatchObject({
|
||||
kind: args.channelType,
|
||||
spec: channelSpec(args.channelType, args.webhookAuth, args.jiraDeployment),
|
||||
});
|
||||
};
|
||||
22
frontend/src/pages/AlertList/ChannelsEdit/stories/__story_mockdata__/channelsEdit.ts
generated
Normal file
22
frontend/src/pages/AlertList/ChannelsEdit/stories/__story_mockdata__/channelsEdit.ts
generated
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import type { AlertmanagertypesTestableNotificationChannelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/** Every test send the form made since the story mounted, oldest first. */
|
||||
const testRequests: AlertmanagertypesTestableNotificationChannelDTO[] = [];
|
||||
|
||||
export const recordChannelTestRequest = (
|
||||
request: AlertmanagertypesTestableNotificationChannelDTO,
|
||||
): void => {
|
||||
testRequests.push(request);
|
||||
};
|
||||
|
||||
export const channelTestRequests =
|
||||
(): readonly AlertmanagertypesTestableNotificationChannelDTO[] => testRequests;
|
||||
|
||||
export const resetChannelTestRequests = (): void => {
|
||||
testRequests.length = 0;
|
||||
};
|
||||
@@ -13,6 +13,12 @@ import {
|
||||
READ,
|
||||
UPDATE,
|
||||
} from '../../../stories/channelAuthz';
|
||||
import {
|
||||
expectJiraDeploymentLocked,
|
||||
expectReadOnlyForm,
|
||||
expectWebhookAuthLocked,
|
||||
sendSavedConfig,
|
||||
} from '../__story_mockdata__/channelsEdit.play';
|
||||
import { channelsEditMocks } from '../ChannelsEdit.stories.mocks';
|
||||
|
||||
import AlertList from '../../../index';
|
||||
@@ -56,18 +62,12 @@ export const NoRead: Story = {
|
||||
|
||||
/**
|
||||
* Reading is enough to open the page, so losing `update` alone leaves it
|
||||
* standing read-only: the fields go disabled and Save is locked, not hidden.
|
||||
* standing read-only: every field goes disabled and Save is locked, not hidden.
|
||||
* The Channel type control walks the same check across every integration.
|
||||
*/
|
||||
export const NoUpdate: Story = {
|
||||
args: { revoked: [UPDATE] },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await expectDisabled(
|
||||
await canvas.findByTestId('save-channel-button', undefined, PAGE_LOAD),
|
||||
);
|
||||
await expectDisabled(await canvas.findByTestId('slack-channel-textbox'));
|
||||
},
|
||||
play: expectReadOnlyForm,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -101,17 +101,24 @@ export const NoCreate: Story = {
|
||||
*/
|
||||
export const ReadOnly: Story = {
|
||||
args: { revoked: [UPDATE, CREATE, DELETE] },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
play: async (context) => {
|
||||
await expectReadOnlyForm(context);
|
||||
await expectDisabled(
|
||||
await canvas.findByTestId('save-channel-button', undefined, PAGE_LOAD),
|
||||
await within(context.canvasElement).findByTestId('test-channel-button'),
|
||||
);
|
||||
await expectDisabled(await canvas.findByTestId('test-channel-button'));
|
||||
await expectDisabled(await canvas.findByTestId('slack-channel-textbox'));
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Read-only keeps Test: sending is a create on the collection, so a reader who
|
||||
* cannot edit the channel can still test it. The request carries the saved
|
||||
* config.
|
||||
*/
|
||||
export const NoUpdateTest: Story = {
|
||||
args: { revoked: [UPDATE] },
|
||||
play: sendSavedConfig,
|
||||
};
|
||||
|
||||
/**
|
||||
* The same denial on a Telegram channel. Its fields are the kind the form gained
|
||||
* last, including the only toggle a settings panel renders, so this is where a
|
||||
@@ -145,6 +152,25 @@ export const TelegramReadOnly: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The same denial on a webhook saved with basic auth. The credential fields are
|
||||
* off, and so is the radio that would swap them for a bearer token.
|
||||
*/
|
||||
export const WebhookReadOnly: Story = {
|
||||
args: { channelType: 'webhook', revoked: [UPDATE, CREATE, DELETE] },
|
||||
play: expectWebhookAuthLocked,
|
||||
};
|
||||
|
||||
/**
|
||||
* The same denial on a Jira Cloud channel. Every field is off, including the
|
||||
* advanced ones, and so is the radio that would swap the Atlassian email and API
|
||||
* token for a personal access token.
|
||||
*/
|
||||
export const JiraReadOnly: Story = {
|
||||
args: { channelType: 'jira', revoked: [UPDATE, CREATE, DELETE] },
|
||||
play: expectJiraDeploymentLocked,
|
||||
};
|
||||
|
||||
/** No channel permission at all: the page is denied before the form exists. */
|
||||
export const NoChannelAccess: Story = {
|
||||
args: { revoked: NOTIFICATION_CHANNEL },
|
||||
|
||||
@@ -954,7 +954,7 @@ const CHANNEL_SPECS: Record<ChannelType, Record<string, unknown>> = {
|
||||
},
|
||||
};
|
||||
|
||||
const channelSpec = (
|
||||
export const channelSpec = (
|
||||
type: ChannelType,
|
||||
webhookAuth: WebhookAuthChoice,
|
||||
jiraDeployment: JiraDeploymentChoice,
|
||||
|
||||
@@ -120,17 +120,6 @@ export const QuickFiltersSettingsDirty: Story = {
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
/**
|
||||
* The same panel with a banner above the shell. The banner takes 48px off the
|
||||
* layout, so this is the case where the footer used to be pushed off screen:
|
||||
* the panel is sized from the filters pane rather than the viewport, which
|
||||
* keeps Save changes reachable.
|
||||
*/
|
||||
export const QuickFiltersSettingsWithBanner: Story = {
|
||||
args: { banner: 'trial-expiry' },
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
/** The resource filter opened: every key the exceptions can be narrowed by. */
|
||||
export const FilterKeySuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
|
||||
@@ -182,14 +182,3 @@ export const QuickFiltersSettings: Story = {
|
||||
export const QuickFiltersSettingsDirty: Story = {
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
/**
|
||||
* The same panel with a banner above the shell. The banner takes 48px off the
|
||||
* layout, so this is the case where the footer used to be pushed off screen:
|
||||
* the panel is sized from the filters pane rather than the viewport, which
|
||||
* keeps Save changes reachable.
|
||||
*/
|
||||
export const QuickFiltersSettingsWithBanner: Story = {
|
||||
args: { banner: 'trial-expiry' },
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
@@ -59,28 +59,6 @@ export const AnomalyAlert: Story = {
|
||||
args: { alertMode: 'anomaly' },
|
||||
};
|
||||
|
||||
/**
|
||||
* An anomaly alert handed to routing policies: the channel picker leaves the
|
||||
* condition, and the banner points at the policies that route it instead.
|
||||
*/
|
||||
export const AnomalyAlertRoutingPolicies: Story = {
|
||||
args: { alertMode: 'anomaly' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.click(
|
||||
await canvas.findByTestId('routing-policies-switch', undefined, {
|
||||
timeout: 15_000,
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
canvas.queryByTestId('threshold-notification-channel-select'),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The match-type tooltip on the threshold sentence: a paragraph on what an
|
||||
* aggregated data point is, a worked example over five of them, and the docs
|
||||
|
||||
40
frontend/src/pages/CreateAlert/stories/__story_mockdata__/createAlert.play.ts
generated
Normal file
40
frontend/src/pages/CreateAlert/stories/__story_mockdata__/createAlert.play.ts
generated
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { expect, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import type { createAlertMocks } from '../CreateAlert.stories.mocks';
|
||||
|
||||
type CreateAlertArgs = PageStoryArgs<typeof createAlertMocks>;
|
||||
|
||||
interface PlayContext {
|
||||
canvasElement: HTMLElement;
|
||||
args: Pick<CreateAlertArgs, 'alertMode'>;
|
||||
}
|
||||
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
/** Every rule type carries the banner; only the type picker has no form. */
|
||||
export const enableRoutingPolicies = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (args.alertMode === 'select-type') {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.click(
|
||||
await canvas.findByTestId('routing-policies-switch', undefined, untilLoaded),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
canvas.queryByTestId('threshold-notification-channel-select'),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
};
|
||||
@@ -63,6 +63,20 @@ const expandConfigSections: Story['play'] = async ({ canvasElement }) => {
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/** The ClickHouse query tab, including its info callout. */
|
||||
export const ClickHouseQuery: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByText('ClickHouse Query', undefined, {
|
||||
timeout: 15000,
|
||||
}),
|
||||
);
|
||||
await screen.findByText(/ClickHouse query agent skill/i, undefined, {
|
||||
timeout: 15000,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/** The create route, seeding an unsaved panel of the chosen kind. */
|
||||
export const NewPanel: Story = {
|
||||
args: { panel: 'new' },
|
||||
@@ -83,6 +97,21 @@ export const PieChartPanel: Story = {
|
||||
args: { panel: 'traffic-share' },
|
||||
};
|
||||
|
||||
/** A new area chart: the time series options with the area filled. */
|
||||
export const AreaChartPanel: Story = {
|
||||
args: { panel: 'new', newPanelKind: 'area-chart' },
|
||||
};
|
||||
|
||||
/** A new histogram, where the display options are its buckets. */
|
||||
export const HistogramPanel: Story = {
|
||||
args: { panel: 'new', newPanelKind: 'histogram' },
|
||||
};
|
||||
|
||||
/** A new text panel: a markdown body in place of the query builder. */
|
||||
export const TextPanel: Story = {
|
||||
args: { panel: 'new', newPanelKind: 'text' },
|
||||
};
|
||||
|
||||
/** A list panel, where the config pane is the column editor. */
|
||||
export const ListPanel: Story = {
|
||||
args: { panel: 'recent-logs' },
|
||||
|
||||
@@ -105,6 +105,19 @@ export const ChartAppearance: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Disconnect-values in threshold mode, with an invalid duration showing the inline error. */
|
||||
export const ChartAppearanceDisconnectThreshold: Story = {
|
||||
play: async (context) => {
|
||||
await ChartAppearance.play?.(context);
|
||||
await pickSegment('panel-editor-v2-span-gaps', 'threshold');
|
||||
const input = await screen.findByTestId('panel-editor-v2-span-gaps-value');
|
||||
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, '5x');
|
||||
await screen.findByText(/enter a valid duration/i);
|
||||
},
|
||||
};
|
||||
|
||||
/** Dashed lines under a gradient fill, with the points drawn. */
|
||||
export const ChartAppearanceDashedGradient: Story = {
|
||||
play: async (context) => {
|
||||
|
||||
@@ -191,6 +191,22 @@ export const TooltipsInViewPanelModal: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** A list or table panel's search field, expanded and filled. */
|
||||
export const PanelSearchExpanded: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
// Both the table and the log list render a search trigger; expand the first.
|
||||
const [trigger] = await within(canvasElement).findAllByTestId(
|
||||
'panel-header-search-trigger',
|
||||
{},
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
await userEvent.click(trigger);
|
||||
const input = await screen.findByTestId('panel-header-search-input');
|
||||
|
||||
await userEvent.type(input, 'error');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The dashboard's own Actions menu, open: rename, clone, lock, full screen and
|
||||
* delete, the menu the toolbar's Actions button carries.
|
||||
@@ -243,6 +259,34 @@ export const SectionActionsMenu: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Delete dashboard, the confirm dialog the Actions menu opens. */
|
||||
export const DeleteDashboardDialog: Story = {
|
||||
play: async (context) => {
|
||||
await ActionsMenu.play?.(context);
|
||||
// The row stays inert until its authz checks resolve, so one click can
|
||||
// land too early; keep clicking until the confirm dialog is up.
|
||||
await waitFor(
|
||||
async () => {
|
||||
const item = screen.queryByTestId('dashboard-action-delete');
|
||||
if (item) {
|
||||
await userEvent.click(item);
|
||||
}
|
||||
screen.getByText('This action cannot be undone.');
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** Rename section, the title dialog a section menu opens. */
|
||||
export const RenameSectionDialog: Story = {
|
||||
play: async (context) => {
|
||||
await SectionActionsMenu.play?.(context);
|
||||
await userEvent.click(await screen.findByText('Rename section'));
|
||||
await screen.findByRole('dialog', { name: /rename section/i });
|
||||
},
|
||||
};
|
||||
|
||||
const openNewPanelPicker = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<void> => {
|
||||
|
||||
@@ -16,6 +16,12 @@ 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,
|
||||
@@ -24,7 +30,9 @@ import {
|
||||
waitFor,
|
||||
within,
|
||||
} from 'storybook/test';
|
||||
import { LicensePlatform } from 'types/api/licensesV3/getActive';
|
||||
|
||||
import { grantsAll } from '@/storybook/access/access';
|
||||
import {
|
||||
choiceControl,
|
||||
countControl,
|
||||
@@ -32,6 +40,8 @@ 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,
|
||||
@@ -67,6 +77,8 @@ 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';
|
||||
@@ -528,6 +540,81 @@ 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,
|
||||
@@ -541,7 +628,7 @@ export const openRowActions = async (
|
||||
);
|
||||
|
||||
await userEvent.click(triggers[index]);
|
||||
await screen.findByText('Rename');
|
||||
await screen.findByTestId('dashboard-action-delete');
|
||||
};
|
||||
|
||||
/** Picks a row action, retrying while its permission check still disables it. */
|
||||
@@ -574,10 +661,11 @@ export const TOAST_TIMEOUT = 10000;
|
||||
|
||||
export const openLegacyDialog = async (
|
||||
canvasElement: HTMLElement,
|
||||
index: number,
|
||||
): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'dashboard-title-4',
|
||||
`dashboard-title-${index}`,
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
),
|
||||
@@ -587,8 +675,9 @@ export const openLegacyDialog = async (
|
||||
|
||||
export const retryMigration = async (
|
||||
canvasElement: HTMLElement,
|
||||
index: number,
|
||||
): Promise<void> => {
|
||||
await openLegacyDialog(canvasElement);
|
||||
await openLegacyDialog(canvasElement, index);
|
||||
const retry = await screen.findByTestId('legacy-dashboard-retry-migration');
|
||||
|
||||
await waitFor(
|
||||
|
||||
@@ -1,47 +1,41 @@
|
||||
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,
|
||||
createDashboardFails,
|
||||
createDashboard,
|
||||
deleteDashboard,
|
||||
deleteViewFails,
|
||||
deleteView,
|
||||
duplicateDashboard,
|
||||
importCreateFails,
|
||||
importDashboard,
|
||||
importInvalidImage,
|
||||
importInvalidJson,
|
||||
lockDashboard,
|
||||
migrateDashboard,
|
||||
migrationFails,
|
||||
openCreatedByFilter,
|
||||
openEditTagsDialog,
|
||||
openFirstRowActions,
|
||||
openRenameDialog,
|
||||
openSaveViewPopover,
|
||||
openTemplateTab,
|
||||
pinFails,
|
||||
pinPastLimit,
|
||||
pinDashboard,
|
||||
renameDashboard,
|
||||
renameViewFails,
|
||||
saveViewFails,
|
||||
renameView,
|
||||
saveView,
|
||||
submitTemplateRequest,
|
||||
templateRequestFails,
|
||||
templateRequestRejected,
|
||||
unlockDashboard,
|
||||
unpinFails,
|
||||
unpinDashboard,
|
||||
updateTags,
|
||||
} from './__story_mockdata__/dashboardsList.play';
|
||||
|
||||
import DashboardsListPage from '../DashboardsListPage';
|
||||
|
||||
type DashboardsListArgs = PageStoryArgs<typeof dashboardsListMocks>;
|
||||
|
||||
const pageStory = storyMocks(dashboardsListMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
@@ -182,21 +176,21 @@ export const DashboardDeletedToast: Story = {
|
||||
/** Pinning past the limit: the backend refuses with a 409. */
|
||||
export const PinLimitToast: Story = {
|
||||
args: { pinWrite: 'limit' },
|
||||
play: pinPastLimit,
|
||||
play: pinDashboard,
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
/** A pin the backend failed to write. */
|
||||
export const PinFailedToast: Story = {
|
||||
args: { pinWrite: 'error' },
|
||||
play: pinFails,
|
||||
play: pinDashboard,
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
/** An unpin the backend failed to write, from the first (pinned) row. */
|
||||
export const UnpinFailedToast: Story = {
|
||||
args: { pinWrite: 'error' },
|
||||
play: unpinFails,
|
||||
play: unpinDashboard,
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
@@ -213,14 +207,14 @@ export const DashboardMigratedToast: Story = {
|
||||
/** Retry migration of the legacy dashboard refused by the backend. */
|
||||
export const DashboardMigrationFailedToast: Story = {
|
||||
args: { migrationWrite: 'error' },
|
||||
play: migrationFails,
|
||||
play: migrateDashboard,
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
/** The Blank tab's create failing: the toast raised beside the error modal. */
|
||||
export const CreateDashboardFailedToast: Story = {
|
||||
args: { createWrite: 'error' },
|
||||
play: createDashboardFails,
|
||||
play: createDashboard,
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
@@ -232,7 +226,7 @@ export const ImportJsonInvalidImageToast: Story = {
|
||||
/** A valid dashboard file the create endpoint refuses. */
|
||||
export const ImportJsonCreateFailedToast: Story = {
|
||||
args: { createWrite: 'error' },
|
||||
play: importCreateFails,
|
||||
play: importDashboard,
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
@@ -244,33 +238,33 @@ export const TemplateRequestSubmittedToast: Story = {
|
||||
/** A template request the analytics endpoint answered with a failure. */
|
||||
export const TemplateRequestFailedToast: Story = {
|
||||
args: { templateRequest: 'error' },
|
||||
play: templateRequestFails,
|
||||
play: submitTemplateRequest,
|
||||
};
|
||||
|
||||
/** A template request that threw before an answer came back. */
|
||||
export const TemplateRequestRejectedToast: Story = {
|
||||
args: { templateRequest: 'rejected' },
|
||||
play: templateRequestRejected,
|
||||
play: submitTemplateRequest,
|
||||
};
|
||||
|
||||
/** Saving the current filters as a view, refused by the backend. */
|
||||
export const SaveViewFailedToast: Story = {
|
||||
args: { viewWrite: 'error' },
|
||||
play: saveViewFails,
|
||||
play: saveView,
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
/** Renaming a saved view, refused by the backend. */
|
||||
export const RenameViewFailedToast: Story = {
|
||||
args: { viewWrite: 'error' },
|
||||
play: renameViewFails,
|
||||
play: renameView,
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
/** Deleting a saved view, refused by the backend. */
|
||||
export const DeleteViewFailedToast: Story = {
|
||||
args: { viewWrite: 'error' },
|
||||
play: deleteViewFails,
|
||||
play: deleteView,
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
|
||||
@@ -5,30 +5,68 @@
|
||||
|
||||
import { expect, screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
canCreate,
|
||||
canDelete,
|
||||
canDuplicate,
|
||||
canList,
|
||||
canUpdate,
|
||||
clickRowAction,
|
||||
dashboardsListMocks,
|
||||
type DashboardsListArgs,
|
||||
isCloudTenant,
|
||||
listedDashboards,
|
||||
openLegacyDialog,
|
||||
openRowActions,
|
||||
pickRowAction,
|
||||
requestTemplate,
|
||||
retryMigration,
|
||||
showsWorkspaceEmpty,
|
||||
TOAST_TIMEOUT,
|
||||
uploadDashboardJson,
|
||||
} from '../DashboardsListPage.stories.mocks';
|
||||
import type { TemplateRequest } from './dashboardsList';
|
||||
|
||||
interface PlayContext {
|
||||
canvasElement: HTMLElement;
|
||||
args: PageStoryArgs<typeof dashboardsListMocks>;
|
||||
args: DashboardsListArgs;
|
||||
}
|
||||
|
||||
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',
|
||||
{},
|
||||
@@ -37,18 +75,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.findByRole('checkbox', { checked: true });
|
||||
await screen.findAllByRole('checkbox', { checked: true });
|
||||
};
|
||||
|
||||
export const openTemplateTab = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.click(
|
||||
await canvas.findByTestId('new-dashboard-cta', {}, { timeout: 10000 }),
|
||||
);
|
||||
if (!canCreate(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openNewDashboard(canvasElement);
|
||||
await expect(
|
||||
await screen.findByRole('dialog', {}, { timeout: 10000 }),
|
||||
).toHaveTextContent('New dashboard');
|
||||
@@ -58,12 +96,13 @@ export const openTemplateTab = async ({
|
||||
|
||||
export const importInvalidJson = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
if (!canCreate(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await userEvent.click(
|
||||
await canvas.findByTestId('new-dashboard-cta', {}, { timeout: 10000 }),
|
||||
);
|
||||
await openNewDashboard(canvasElement);
|
||||
await userEvent.click(await screen.findByText('Import JSON'));
|
||||
await userEvent.click(await screen.findByTestId('import-json-submit'));
|
||||
await screen.findByText(/error loading json/i);
|
||||
@@ -73,13 +112,23 @@ 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' });
|
||||
@@ -87,7 +136,12 @@ 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$/ });
|
||||
@@ -95,7 +149,12 @@ 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',
|
||||
@@ -110,6 +169,10 @@ 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');
|
||||
@@ -126,6 +189,10 @@ 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)');
|
||||
@@ -141,11 +208,37 @@ 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 "API latency and errors"',
|
||||
`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',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
@@ -155,29 +248,22 @@ export const duplicateDashboard = async ({
|
||||
export const lockDashboard = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openRowActions(canvasElement, 1);
|
||||
await clickRowAction('dashboard-action-lock');
|
||||
if (args.rowWrite === 'success') {
|
||||
await screen.findByText('Dashboard locked', {}, { timeout: TOAST_TIMEOUT });
|
||||
}
|
||||
};
|
||||
}: PlayContext): Promise<void> => toggleLockAt(canvasElement, args, 1);
|
||||
|
||||
export const unlockDashboard = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openRowActions(canvasElement, 0);
|
||||
await clickRowAction('dashboard-action-lock');
|
||||
if (args.rowWrite === 'success') {
|
||||
await screen.findByText('Dashboard unlocked', {}, { timeout: TOAST_TIMEOUT });
|
||||
}
|
||||
};
|
||||
}: PlayContext): Promise<void> => toggleLockAt(canvasElement, args, 0);
|
||||
|
||||
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');
|
||||
@@ -194,61 +280,73 @@ export const deleteDashboard = async ({
|
||||
}
|
||||
};
|
||||
|
||||
export const pinPastLimit = async ({
|
||||
export const pinDashboard = 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-3',
|
||||
`dashboard-pin-${index}`,
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
),
|
||||
);
|
||||
await screen.findByText(
|
||||
/You can pin up to 10 dashboards/,
|
||||
{},
|
||||
{ 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 },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const pinFails = async ({
|
||||
export const unpinDashboard = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'dashboard-pin-3',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
),
|
||||
);
|
||||
await screen.findByText(
|
||||
'Failed to pin dashboard.',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
};
|
||||
const index = listedDashboards(args).findIndex((row) => row.pinned);
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
export const unpinFails = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'dashboard-pin-0',
|
||||
`dashboard-pin-${index}`,
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
),
|
||||
);
|
||||
await screen.findByText(
|
||||
'Failed to unpin dashboard.',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
if (args.pinWrite === 'error') {
|
||||
await screen.findByText(
|
||||
'Failed to unpin dashboard.',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const copyLegacyDashboardId = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openLegacyDialog(canvasElement);
|
||||
const index = listedDashboards(args).findIndex((row) => row.legacy);
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openLegacyDialog(canvasElement, index);
|
||||
await userEvent.click(await screen.findByTestId('legacy-dashboard-copy-id'));
|
||||
await screen.findByText('Dashboard ID copied', {}, { timeout: TOAST_TIMEOUT });
|
||||
};
|
||||
@@ -257,7 +355,13 @@ export const migrateDashboard = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await retryMigration(canvasElement);
|
||||
const rows = listedDashboards(args);
|
||||
const index = rows.findIndex((row) => row.legacy);
|
||||
if (index < 0 || !canUpdate(args, rows[index])) {
|
||||
return;
|
||||
}
|
||||
|
||||
await retryMigration(canvasElement, index);
|
||||
if (args.migrationWrite === 'success') {
|
||||
await screen.findByText(
|
||||
'Dashboard migrated to the new experience',
|
||||
@@ -265,47 +369,43 @@ export const migrateDashboard = async ({
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const migrationFails = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await retryMigration(canvasElement);
|
||||
await screen.findByText(
|
||||
'Something went wrong',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
};
|
||||
|
||||
export const createDashboardFails = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'new-dashboard-cta',
|
||||
if (args.migrationWrite === 'error') {
|
||||
await screen.findByText(
|
||||
'Something went wrong',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const createDashboard = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!canCreate(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openNewDashboard(canvasElement);
|
||||
await userEvent.click(await screen.findByTestId('create-dashboard-submit'));
|
||||
await screen.findAllByText(
|
||||
/Something went wrong/,
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
if (args.createWrite === 'error') {
|
||||
await screen.findAllByText(
|
||||
/Something went wrong/,
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const importInvalidImage = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'new-dashboard-cta',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
),
|
||||
);
|
||||
if (!canCreate(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openNewDashboard(canvasElement);
|
||||
await uploadDashboardJson({
|
||||
schemaVersion: 'v6',
|
||||
image: 'https://example.com/logo.png',
|
||||
@@ -318,59 +418,52 @@ export const importInvalidImage = async ({
|
||||
);
|
||||
};
|
||||
|
||||
export const importCreateFails = async ({
|
||||
export const importDashboard = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'new-dashboard-cta',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
),
|
||||
);
|
||||
if (!canCreate(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openNewDashboard(canvasElement);
|
||||
await uploadDashboardJson({
|
||||
schemaVersion: 'v6',
|
||||
spec: { display: { name: 'Imported' }, ...IMPORTED_SPEC },
|
||||
});
|
||||
await screen.findAllByText(
|
||||
/Something went wrong/,
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
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(
|
||||
'Dashboard request submitted',
|
||||
TEMPLATE_REQUEST_TOASTS[args.templateRequest],
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
};
|
||||
|
||||
export const templateRequestFails = async ({
|
||||
export const saveView = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await requestTemplate(canvasElement);
|
||||
await screen.findByText('Event rejected', {}, { timeout: TOAST_TIMEOUT });
|
||||
};
|
||||
if (!canList(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
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',
|
||||
@@ -380,16 +473,26 @@ export const saveViewFails = async ({
|
||||
);
|
||||
await userEvent.type(await screen.findByTestId('save-view-name'), 'On-call');
|
||||
await userEvent.click(await screen.findByTestId('save-view-confirm'));
|
||||
await screen.findByText(
|
||||
'Failed to save view.',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
if (args.viewWrite === 'error') {
|
||||
await screen.findByText(
|
||||
'Failed to save view.',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const renameViewFails = async ({
|
||||
const listsSavedViews = (args: DashboardsListArgs): boolean =>
|
||||
canList(args) && args.dataState === 'loaded' && args.savedViews > 0;
|
||||
|
||||
export const renameView = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!listsSavedViews(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [rename] = await within(canvasElement).findAllByRole(
|
||||
'button',
|
||||
{ name: 'Rename view' },
|
||||
@@ -403,16 +506,23 @@ export const renameViewFails = async ({
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, 'Renamed view');
|
||||
await userEvent.click(await screen.findByTestId('rename-view-confirm'));
|
||||
await screen.findByText(
|
||||
'Failed to update view.',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
if (args.viewWrite === 'error') {
|
||||
await screen.findByText(
|
||||
'Failed to update view.',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteViewFails = async ({
|
||||
export const deleteView = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!listsSavedViews(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [remove] = await within(canvasElement).findAllByRole(
|
||||
'button',
|
||||
{ name: 'Delete view' },
|
||||
@@ -425,9 +535,11 @@ export const deleteViewFails = async ({
|
||||
await userEvent.click(
|
||||
await within(dialog).findByRole('button', { name: 'Delete' }),
|
||||
);
|
||||
await screen.findByText(
|
||||
'Failed to delete view.',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
if (args.viewWrite === 'error') {
|
||||
await screen.findByText(
|
||||
'Failed to delete view.',
|
||||
{},
|
||||
{ timeout: TOAST_TIMEOUT },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -97,3 +97,12 @@ export const NavSettingsMenu: Story = {
|
||||
await screen.findByRole('menu');
|
||||
},
|
||||
};
|
||||
|
||||
/** The command palette, opened with the same shortcut the shell listens for. */
|
||||
export const CommandPalette: Story = {
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.keyboard('{Meta>}k{/Meta}');
|
||||
await screen.findByPlaceholderText('Search…', undefined, { timeout: 10000 });
|
||||
await screen.findByText('Go to Home');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -9,6 +9,8 @@ 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';
|
||||
|
||||
@@ -56,6 +58,7 @@ export const podLongNodeName = rest.post(
|
||||
* what puts `?category=` on the route the story starts on.
|
||||
*/
|
||||
export const podsMocks = infraStoryMocks(InfraMonitoringEntity.PODS);
|
||||
export type PodsArgs = PageStoryArgs<typeof podsMocks>;
|
||||
export const nodesMocks = infraStoryMocks(InfraMonitoringEntity.NODES);
|
||||
export const namespacesMocks = infraStoryMocks(
|
||||
InfraMonitoringEntity.NAMESPACES,
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
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 } from './Kubernetes.stories.mocks';
|
||||
import {
|
||||
podLongNodeName,
|
||||
podsMocks,
|
||||
type PodsArgs,
|
||||
} from './Kubernetes.stories.mocks';
|
||||
import {
|
||||
copyPodMetadata,
|
||||
copyPodName,
|
||||
openDrawerLogDetails,
|
||||
openDrawerLogsTab,
|
||||
openTableOptions,
|
||||
suggestFilterKeys,
|
||||
suggestFilterValues,
|
||||
} from './__story_mockdata__/pods.play';
|
||||
|
||||
import InfrastructureMonitoringPage from '../InfrastructureMonitoringPage';
|
||||
|
||||
type PodsArgs = PageStoryArgs<typeof podsMocks>;
|
||||
|
||||
const pageStory = storyMocks(podsMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
@@ -57,38 +61,25 @@ 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: async () => {
|
||||
const drawer = within(
|
||||
await screen.findByRole('dialog', {}, { timeout: 10000 }),
|
||||
);
|
||||
play: openDrawerLogsTab,
|
||||
};
|
||||
|
||||
await userEvent.click(
|
||||
await drawer.findByText('Logs', {}, { timeout: 10000 }),
|
||||
);
|
||||
await drawer.findAllByText(/handled request in/, {}, { timeout: 10000 });
|
||||
},
|
||||
/** A log opened from the pod's logs tab, over the details drawer. */
|
||||
export const DetailsDrawerLogDetails: StoryObj<PodsArgs> = {
|
||||
args: { drawer: true },
|
||||
play: openDrawerLogDetails,
|
||||
};
|
||||
|
||||
/** The copy button beside the pod name in the drawer, which toasts the copy. */
|
||||
export const PodNameCopiedToast: StoryObj<PodsArgs> = {
|
||||
args: { drawer: true },
|
||||
play: async () => {
|
||||
await userEvent.click(
|
||||
await screen.findByTestId('copy-id-button', {}, { timeout: 10000 }),
|
||||
);
|
||||
await screen.findByText('Pod name copied to clipboard');
|
||||
},
|
||||
play: copyPodName,
|
||||
};
|
||||
|
||||
/** A metadata value's copy button in the drawer, which toasts the label it copied. */
|
||||
export const PodMetadataCopiedToast: StoryObj<PodsArgs> = {
|
||||
args: { drawer: true },
|
||||
play: async () => {
|
||||
await userEvent.click(
|
||||
await screen.findByTestId('copy-metadata-namespace', {}, { timeout: 10000 }),
|
||||
);
|
||||
await screen.findByText('NAMESPACE copied to clipboard');
|
||||
},
|
||||
play: copyPodMetadata,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -122,19 +113,7 @@ export const TooltipsInDetailsDrawer: StoryObj<PodsArgs> = {
|
||||
*/
|
||||
export const TooltipsInOptionsPanel: StoryObj<PodsArgs> = {
|
||||
args: { tooltipsOpen: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.click(
|
||||
await canvas.findByTestId(
|
||||
'k8s-table-options-button',
|
||||
{},
|
||||
{ timeout: 10000 },
|
||||
),
|
||||
);
|
||||
|
||||
await screen.findByText('Columns');
|
||||
},
|
||||
play: openTableOptions,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -147,16 +126,11 @@ 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: async ({ canvasElement }): Promise<void> => {
|
||||
await openKeySuggestions(canvasElement, 'k8s.node.name');
|
||||
},
|
||||
play: suggestFilterKeys,
|
||||
};
|
||||
|
||||
/** The pod filter on a namespace: the namespaces the pods run in. */
|
||||
export const FilterValueSuggestions: StoryObj<PodsArgs> = {
|
||||
parameters: heldViewport,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'k8s.namespace.name = ');
|
||||
await findSuggestion(canvasElement, 'kube-system');
|
||||
},
|
||||
play: suggestFilterValues,
|
||||
};
|
||||
|
||||
119
frontend/src/pages/InfrastructureMonitoring/stories/__story_mockdata__/pods.play.ts
generated
Normal file
119
frontend/src/pages/InfrastructureMonitoring/stories/__story_mockdata__/pods.play.ts
generated
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* 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 openDrawerLogDetails = 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 }));
|
||||
const [line] = await drawer.findAllByText(
|
||||
/handled request in/,
|
||||
{},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
|
||||
await userEvent.click(line);
|
||||
await screen.findByTestId('log-detail-drawer', {}, { 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');
|
||||
};
|
||||
@@ -117,6 +117,43 @@ export const ConnectAccount: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Azure's connect dialog, which the AWS regions form does not open. */
|
||||
export const AzureConnectAccount: Story = {
|
||||
args: { provider: 'azure', accounts: 0 },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByText(
|
||||
/integrate now/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByRole('dialog', undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/** The GCP region combobox, opened from the connect drawer. */
|
||||
export const GcpRegionDropdown: Story = {
|
||||
args: { provider: 'gcp', accounts: 0 },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByText(
|
||||
/integrate now/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await userEvent.click(
|
||||
await screen.findByTestId(
|
||||
'gcp-deployment-region-select',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByRole('listbox');
|
||||
},
|
||||
};
|
||||
|
||||
/** The regions an already-connected account collects from. */
|
||||
export const EditAccount: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type GroupSave,
|
||||
MAPPING_GROUP_MAX,
|
||||
spanMapperGroupsResponse,
|
||||
spanMappersResponse,
|
||||
wideConditionGroupsResponse,
|
||||
} from './__story_mockdata__/attributeMapping';
|
||||
|
||||
@@ -28,6 +29,15 @@ export const wideConditionGroup = rest.get(
|
||||
res(ctx.status(200), ctx.json(wideConditionGroupsResponse())),
|
||||
);
|
||||
|
||||
export const groupMappers = rest.get(
|
||||
'http://localhost/api/v1/span_mapper_groups/:groupId/span_mappers',
|
||||
(req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json(spanMappersResponse(String(req.params.groupId))),
|
||||
),
|
||||
);
|
||||
|
||||
export const attributeMappingMocks = defineStoryMocks({
|
||||
controls: {
|
||||
groups: countControl('Mapping groups', {
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
import LLMObservabilityPage from '../../index';
|
||||
import {
|
||||
attributeMappingMocks,
|
||||
groupMappers,
|
||||
wideConditionGroup,
|
||||
} from './AttributeMapping.stories.mocks';
|
||||
|
||||
@@ -79,6 +80,29 @@ export const GroupFormDrawer: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** The drawer that adds a mapping to the first group. */
|
||||
export const AddMappingDrawer: Story = {
|
||||
parameters: { msw: { handlers: [groupMappers] } },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
const [firstGroup] = await canvas.findAllByTestId(
|
||||
/^group-expand-/,
|
||||
{},
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
|
||||
await userEvent.click(firstGroup);
|
||||
await userEvent.click(
|
||||
await canvas.findByRole(
|
||||
'button',
|
||||
{ name: /add mapping/i },
|
||||
{ timeout: 15000 },
|
||||
),
|
||||
);
|
||||
await screen.findByText('New custom mapping');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A group toggled off and saved: the toast announcing the saved changes, raised
|
||||
* once the PATCH and the list refetch answer. Set Save changes to `error`,
|
||||
|
||||
@@ -5,10 +5,15 @@
|
||||
|
||||
import type {
|
||||
ListSpanMapperGroups200,
|
||||
ListSpanMappers200,
|
||||
SpantypesSpanMapperGroupConditionKeyDTO,
|
||||
SpantypesSpanMapperGroupDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { SpantypesSpanMapperOriginDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
SpantypesFieldContextDTO,
|
||||
SpantypesSpanMapperOperationDTO,
|
||||
SpantypesSpanMapperOriginDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
const ORG_ID = 'org-signoz';
|
||||
|
||||
@@ -72,6 +77,38 @@ export const spanMapperGroupsResponse = (
|
||||
data: { items: GROUPS.slice(0, count).map(group) },
|
||||
});
|
||||
|
||||
/** The mappings an expanded group lists, which the groups response does not carry. */
|
||||
export const spanMappersResponse = (groupId: string): ListSpanMappers200 => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
id: `${groupId}-mapper-1`,
|
||||
groupId,
|
||||
name: 'gen_ai.request.model',
|
||||
enabled: true,
|
||||
origin: SpantypesSpanMapperOriginDTO.user,
|
||||
fieldContext: SpantypesFieldContextDTO.attribute,
|
||||
config: {
|
||||
sources: [
|
||||
{
|
||||
key: 'langchain.model',
|
||||
context: SpantypesFieldContextDTO.attribute,
|
||||
operation: SpantypesSpanMapperOperationDTO.copy,
|
||||
priority: 0,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
createdAt: '2026-07-18T11:20:00Z',
|
||||
createdBy: 'anna@signoz.io',
|
||||
updatedAt: '2026-08-05T08:40:00Z',
|
||||
updatedBy: 'anna@signoz.io',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const MANY_ATTRIBUTES = [
|
||||
'langchain.model',
|
||||
'langchain.prompt_tokens',
|
||||
|
||||
@@ -168,17 +168,6 @@ export const QuickFiltersSettingsDirty: Story = {
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
/**
|
||||
* The same panel with a banner above the shell. The banner takes 48px off the
|
||||
* layout, so this is the case where the footer used to be pushed off screen:
|
||||
* the panel is sized from the filters pane rather than the viewport, which
|
||||
* keeps Save changes reachable.
|
||||
*/
|
||||
export const QuickFiltersSettingsWithBanner: Story = {
|
||||
args: { banner: 'trial-expiry' },
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
/** A quick-filter value selected against the LLM span query. */
|
||||
export const QuickFilterSelected: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
|
||||
@@ -39,6 +39,34 @@ export const NothingPriced: Story = {
|
||||
args: { rules: 0 },
|
||||
};
|
||||
|
||||
/** Models seen in spans that no rule prices yet. */
|
||||
export const UnpricedModels: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole(
|
||||
'tab',
|
||||
{ name: /unpriced models/i },
|
||||
{ timeout: 15000 },
|
||||
),
|
||||
);
|
||||
await screen.findByRole('tab', { name: /unpriced models/i, selected: true });
|
||||
},
|
||||
};
|
||||
|
||||
/** Creating a rule: the blank drawer, with the provider select enabled. */
|
||||
export const ModelCostDrawerCreate: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole(
|
||||
'button',
|
||||
{ name: /add model cost/i },
|
||||
{ timeout: 15000 },
|
||||
),
|
||||
);
|
||||
await screen.findByTestId('drawer-provider-select');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The first pricing rule's menu, open over the table: edit the rule or drop it.
|
||||
*/
|
||||
|
||||
@@ -116,6 +116,16 @@ export const AddProcessor: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Severity Parser is the type whose mapping fields are CSV inputs. */
|
||||
export const AddSeverityParserProcessor: Story = {
|
||||
play: async (context): Promise<void> => {
|
||||
await AddProcessor.play?.(context);
|
||||
await userEvent.click(await screen.findByText('Grok'));
|
||||
await userEvent.click(await screen.findByTitle('Severity Parser'));
|
||||
await screen.findByText('Parse Severity Value From');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The logs a pipeline matches, over the interval the preview asks for, with the
|
||||
* simulation of its processors one click away.
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
import {
|
||||
RESPONSE_STATES,
|
||||
type ResponseState,
|
||||
@@ -312,3 +313,5 @@ export const logsMocks = defineStoryMocks({
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export type LogsArgs = PageStoryArgs<typeof logsMocks>;
|
||||
|
||||
@@ -1,23 +1,36 @@
|
||||
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 { logsMocks } from './LogsModulePage.stories.mocks';
|
||||
import { type LogsArgs, logsMocks } from './LogsModulePage.stories.mocks';
|
||||
import {
|
||||
completeFilter,
|
||||
copyLog,
|
||||
copyLogDetailsLink,
|
||||
copyLogLinkFromRow,
|
||||
dirtyQuickFiltersSettings,
|
||||
openFormatMenu,
|
||||
openLogContext,
|
||||
openLogDetails,
|
||||
openLogDetailsMenu,
|
||||
openQuickFiltersSettings,
|
||||
showFilterSyntaxError,
|
||||
stepToNextLog,
|
||||
suggestConjunctions,
|
||||
suggestFilterKeys,
|
||||
suggestFilterValues,
|
||||
suggestInListValues,
|
||||
suggestInNestedGroup,
|
||||
suggestNegatedOperators,
|
||||
suggestNoValues,
|
||||
suggestNumberOperators,
|
||||
suggestOperators,
|
||||
suggestPartialKey,
|
||||
suggestRecentSearches,
|
||||
} from './__story_mockdata__/logs.play';
|
||||
|
||||
import LogsModulePage from '../LogsModulePage';
|
||||
|
||||
type LogsArgs = PageStoryArgs<typeof logsMocks>;
|
||||
|
||||
const pageStory = storyMocks(logsMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
@@ -38,42 +51,6 @@ 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
|
||||
@@ -106,6 +83,12 @@ export const RawFormat: Story = {
|
||||
args: { format: 'raw' },
|
||||
};
|
||||
|
||||
/** The format popover: the format list, the max-lines stepper, and the font-size control. */
|
||||
export const FormatMenuOpen: Story = {
|
||||
args: { format: 'raw' },
|
||||
play: openFormatMenu,
|
||||
};
|
||||
|
||||
/** An explorer with no configured quick filters. */
|
||||
export const NoQuickFilters: Story = {
|
||||
args: { quickFilters: 0 },
|
||||
@@ -148,30 +131,17 @@ export const JsonBodyFieldsRaw: Story = {
|
||||
|
||||
/** One log opened up: its own fields, then the resource that sent it. */
|
||||
export const LogDetails: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openFirstLog(canvasElement);
|
||||
},
|
||||
play: openLogDetails,
|
||||
};
|
||||
|
||||
/** The logs either side of the one that was opened, on the same filters. */
|
||||
export const LogDetailsContext: Story = {
|
||||
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);
|
||||
},
|
||||
play: openLogContext,
|
||||
};
|
||||
|
||||
/** The log detail drawer with its header actions menu open. */
|
||||
export const LogDetailsMenuOpen: Story = {
|
||||
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');
|
||||
},
|
||||
play: openLogDetailsMenu,
|
||||
};
|
||||
|
||||
/** A response warning shown beside the query actions. */
|
||||
@@ -196,33 +166,11 @@ 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,
|
||||
};
|
||||
|
||||
/**
|
||||
* The same panel with a banner above the shell. The banner takes 48px off the
|
||||
* layout, so this is the case where the footer used to be pushed off screen:
|
||||
* the panel is sized from the filters pane rather than the viewport, which
|
||||
* keeps Save changes reachable.
|
||||
*/
|
||||
export const QuickFiltersSettingsWithBanner: Story = {
|
||||
args: { banner: 'trial-expiry' },
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
/**
|
||||
* Every tooltip the explorer renders, held open at once: the quick filters'
|
||||
* severity, host and service values on the left, and the drawer's move to
|
||||
@@ -232,76 +180,48 @@ export const QuickFiltersSettingsWithBanner: Story = {
|
||||
*/
|
||||
export const Tooltips: Story = {
|
||||
args: { tooltipsOpen: true },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openFirstLog(canvasElement);
|
||||
await userEvent.click(
|
||||
await screen.findByTestId('log-details-header-next', undefined, untilLoaded),
|
||||
);
|
||||
},
|
||||
play: stepToNextLog,
|
||||
};
|
||||
|
||||
/** The filter focused before anything is typed: every key the logs carry. */
|
||||
export const FilterKeySuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openKeySuggestions(canvasElement, 'severity_text');
|
||||
},
|
||||
play: suggestFilterKeys,
|
||||
};
|
||||
|
||||
/** A partial key, with the keys that still match and the typed part marked. */
|
||||
export const FilterPartialKey: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'serv');
|
||||
await findSuggestion(canvasElement, 'service.name');
|
||||
},
|
||||
play: suggestPartialKey,
|
||||
};
|
||||
|
||||
/** A string key followed by a space: the operators a string compares with. */
|
||||
export const FilterOperatorSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'service.name ');
|
||||
await findSuggestion(canvasElement, 'CONTAINS');
|
||||
},
|
||||
play: suggestOperators,
|
||||
};
|
||||
|
||||
/** A number key puts the range comparisons first. */
|
||||
export const FilterNumberOperatorSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'http.status_code ');
|
||||
await findSuggestion(canvasElement, 'BETWEEN');
|
||||
},
|
||||
play: suggestNumberOperators,
|
||||
};
|
||||
|
||||
/** `NOT` after a key narrows the list to the operators it can negate. */
|
||||
export const FilterNegatedOperatorSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'service.name NOT ');
|
||||
await findSuggestion(canvasElement, 'IN');
|
||||
},
|
||||
play: suggestNegatedOperators,
|
||||
};
|
||||
|
||||
/** A key and an operator: the values the key holds, fetched for it. */
|
||||
export const FilterValueSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'service.name = ');
|
||||
await findSuggestion(canvasElement, 'checkout');
|
||||
},
|
||||
play: suggestFilterValues,
|
||||
};
|
||||
|
||||
/** Values still being fetched for the key. */
|
||||
export const FilterValuesLoading: Story = {
|
||||
args: { filterValues: 'loading' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'service.name = ');
|
||||
await findSuggestion(canvasElement, 'Loading suggestions');
|
||||
},
|
||||
play: suggestFilterValues,
|
||||
};
|
||||
|
||||
/** A key the backend holds no values for, such as the free-text body. */
|
||||
export const FilterNoValueSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'body = ');
|
||||
await findSuggestion(canvasElement, 'No suggestions available');
|
||||
},
|
||||
play: suggestNoValues,
|
||||
};
|
||||
|
||||
/** The values request failed. */
|
||||
@@ -309,120 +229,55 @@ export const FilterValuesError: Story = {
|
||||
args: { filterValues: 'error' },
|
||||
// The values request deliberately fails.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'service.name = ');
|
||||
await findSuggestion(canvasElement, 'Error loading suggestions');
|
||||
},
|
||||
play: suggestFilterValues,
|
||||
};
|
||||
|
||||
/** 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: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilterWithCaretBack(
|
||||
canvasElement,
|
||||
"service.name IN ['auth', ]",
|
||||
1,
|
||||
'checkout',
|
||||
);
|
||||
},
|
||||
play: suggestInListValues,
|
||||
};
|
||||
|
||||
/** A complete condition: the conjunctions that start the next one. */
|
||||
export const FilterConjunctionSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, "service.name = 'checkout' ");
|
||||
await findSuggestion(canvasElement, 'OR');
|
||||
},
|
||||
play: suggestConjunctions,
|
||||
};
|
||||
|
||||
/** Inside an opened group: keys, another group and `NOT`. */
|
||||
export const FilterNestedGroup: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilterWithCaretBack(canvasElement, '()', 1, 'NOT');
|
||||
},
|
||||
play: suggestInNestedGroup,
|
||||
};
|
||||
|
||||
/** 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: 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);
|
||||
},
|
||||
play: completeFilter,
|
||||
};
|
||||
|
||||
/** An incomplete expression after focus left: the marker and its errors. */
|
||||
export const FilterSyntaxError: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await showFilterErrors(canvasElement, 'service.name = ');
|
||||
},
|
||||
play: showFilterSyntaxError,
|
||||
};
|
||||
|
||||
/** Filters run before, offered above the key suggestions. */
|
||||
export const FilterRecentSearches: Story = {
|
||||
args: { recentFilters: 3 },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openKeySuggestions(
|
||||
canvasElement,
|
||||
"k8s.namespace.name = 'observability'",
|
||||
);
|
||||
},
|
||||
play: suggestRecentSearches,
|
||||
};
|
||||
|
||||
/** Row actions render on hover, so the first row is hovered before the copy link is pressed. */
|
||||
export const LogLinkCopiedToast: Story = {
|
||||
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(),
|
||||
);
|
||||
},
|
||||
play: copyLogLinkFromRow,
|
||||
};
|
||||
|
||||
/** "Copy log" in the log detail drawer's actions menu. */
|
||||
export const LogCopiedToast: Story = {
|
||||
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(),
|
||||
);
|
||||
},
|
||||
play: copyLog,
|
||||
};
|
||||
|
||||
/** "Copy link to log" in the log detail drawer's actions menu. */
|
||||
export const LogDetailsLinkCopiedToast: Story = {
|
||||
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(),
|
||||
);
|
||||
},
|
||||
play: copyLogDetailsLink,
|
||||
};
|
||||
|
||||
338
frontend/src/pages/LogsModulePage/stories/__story_mockdata__/logs.play.ts
generated
Normal file
338
frontend/src/pages/LogsModulePage/stories/__story_mockdata__/logs.play.ts
generated
Normal file
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* 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');
|
||||
};
|
||||
|
||||
/** The format options sit in the list view's toolbar. */
|
||||
export const openFormatMenu = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (args.view !== 'list') {
|
||||
return;
|
||||
}
|
||||
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'periscope-btn-format-options',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByTestId('max-lines-per-row', undefined, untilLoaded);
|
||||
};
|
||||
|
||||
export const openQuickFiltersSettings = async ({
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!canManageQuickFilters(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The settings control renders disabled while its permission check is in
|
||||
// flight and is swapped for the enabled one once the check answers, so it is
|
||||
// looked up again on every attempt; a click on the disabled one is dropped in
|
||||
// silence.
|
||||
const control = await waitFor(async () => {
|
||||
const settings = screen.getByTestId('settings-icon-container');
|
||||
|
||||
await expect(settings).not.toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
return settings;
|
||||
}, untilLoaded);
|
||||
|
||||
await userEvent.click(control);
|
||||
await screen.findByText('Edit quick filters');
|
||||
};
|
||||
|
||||
export const dirtyQuickFiltersSettings = async (
|
||||
context: PlayContext,
|
||||
): Promise<void> => {
|
||||
const { args } = context;
|
||||
if (
|
||||
!canManageQuickFilters(args) ||
|
||||
args.dataState !== 'loaded' ||
|
||||
args.quickFilters === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openQuickFiltersSettings(context);
|
||||
// One Remove per added filter; the first row's is the one clicked.
|
||||
const [removeFilter] = await screen.findAllByRole('button', {
|
||||
name: 'Remove',
|
||||
});
|
||||
|
||||
await userEvent.click(removeFilter);
|
||||
await screen.findByRole('button', { name: 'Save changes' });
|
||||
};
|
||||
|
||||
export const 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');
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { MessagingQueuesViewTypeOptions } from 'pages/MessagingQueues/MessagingQueuesUtils';
|
||||
import { expect, userEvent, waitFor, within } from 'storybook/test';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
@@ -98,6 +98,21 @@ export const DropRate: Story = {
|
||||
args: { view: MessagingQueuesViewTypeOptions.DropRate },
|
||||
};
|
||||
|
||||
/** The custom-interval input inside the evaluation-time dropdown. */
|
||||
export const DropRateIntervalOpen: Story = {
|
||||
args: { view: MessagingQueuesViewTypeOptions.DropRate },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByText('10ms', undefined, untilLoaded),
|
||||
);
|
||||
await screen.findByPlaceholderText(
|
||||
'Enter custom time (ms)',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** The broker, producer, consumer and JVM metrics kafka itself reports. */
|
||||
export const MetricView: Story = {
|
||||
args: { view: MessagingQueuesViewTypeOptions.MetricPage },
|
||||
|
||||
@@ -85,24 +85,6 @@ export const NoUsage: Story = {
|
||||
args: { signals: [], quickFilters: 0, savedViews: 0 },
|
||||
};
|
||||
|
||||
/**
|
||||
* A window from the meter's beta phase, which a cloud tenant is warned about
|
||||
* because the numbers from before 22 August 2025 are not billable.
|
||||
*/
|
||||
export const BeforeMeterLaunch: Story = {
|
||||
args: { timeRange: 'august-2025' },
|
||||
};
|
||||
|
||||
/** The warning shown when the selected window is shorter than one hour. */
|
||||
export const ShortWindowNotice: Story = {
|
||||
args: { timeRange: 'last-30-minutes' },
|
||||
};
|
||||
|
||||
/** The returning-user meter view after the billing notice was dismissed. */
|
||||
export const BillingNoticeDismissed: Story = {
|
||||
args: { billingNotice: false },
|
||||
};
|
||||
|
||||
/** Meter usage where only logs are currently ingesting. */
|
||||
export const PartialIngestion: Story = {
|
||||
args: { signals: ['logs'] },
|
||||
@@ -124,14 +106,3 @@ export const QuickFiltersSettingsDirty: Story = {
|
||||
args: { tab: 'explorer' },
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
/**
|
||||
* The same panel with a banner above the shell. The banner takes 48px off the
|
||||
* layout, so this is the case where the footer used to be pushed off screen:
|
||||
* the panel is sized from the filters pane rather than the viewport, which
|
||||
* keeps Save changes reachable.
|
||||
*/
|
||||
export const QuickFiltersSettingsWithBanner: Story = {
|
||||
args: { tab: 'explorer', banner: 'trial-expiry' },
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { renderAtRoute } from '@/storybook/renderAtRoute';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
import { screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
@@ -76,3 +76,26 @@ export const DownloadMenu: Story = {
|
||||
await screen.findByRole('menu');
|
||||
},
|
||||
};
|
||||
|
||||
/** The apdex threshold popover, opened from the overview's settings control. */
|
||||
export const ApDexSettingsOpen: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const trigger = await waitFor(
|
||||
() => {
|
||||
const found = canvasElement.querySelector<HTMLElement>(
|
||||
'.ap-dex-settings-popover-content button',
|
||||
);
|
||||
|
||||
if (!found) {
|
||||
throw new Error('Apdex settings control is not rendered');
|
||||
}
|
||||
|
||||
return found;
|
||||
},
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
|
||||
await userEvent.click(trigger);
|
||||
await screen.findByText(/apdex threshold/i);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
import { dashboardsForUserResponse } from '@/storybook/msw/__story_mockdata__/dashboards';
|
||||
import {
|
||||
fieldKeysResponse,
|
||||
@@ -553,6 +554,8 @@ export const metricsMocks = defineStoryMocks({
|
||||
},
|
||||
});
|
||||
|
||||
export type MetricsArgs = PageStoryArgs<typeof metricsMocks>;
|
||||
|
||||
export const saveUnit = async (
|
||||
canvasElement: HTMLElement,
|
||||
expected: string | false,
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import {
|
||||
findSuggestion,
|
||||
openKeySuggestions,
|
||||
typeFilter,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
|
||||
import { screen, userEvent } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { metricsMocks, saveUnit } from './MetricsExplorerPage.stories.mocks';
|
||||
import {
|
||||
type MetricsArgs,
|
||||
metricsMocks,
|
||||
} from './MetricsExplorerPage.stories.mocks';
|
||||
import {
|
||||
openDashboardsMenu,
|
||||
saveMetricUnit,
|
||||
suggestFilterKeys,
|
||||
suggestFilterValues,
|
||||
} from './__story_mockdata__/metricsExplorer.play';
|
||||
import MetricsExplorerPage from '../MetricsExplorerPage';
|
||||
|
||||
type MetricsArgs = PageStoryArgs<typeof metricsMocks>;
|
||||
|
||||
const pageStory = storyMocks(metricsMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
@@ -103,31 +103,17 @@ export const Tooltips: Story = {
|
||||
*/
|
||||
export const MetricDetailsDashboardsMenu: Story = {
|
||||
args: { drawer: 'details' },
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await screen.findByTestId(
|
||||
'metric-dashboards-popover',
|
||||
{},
|
||||
{ timeout: 10000 },
|
||||
),
|
||||
);
|
||||
await screen.findByRole('menu');
|
||||
},
|
||||
play: openDashboardsMenu,
|
||||
};
|
||||
|
||||
/** The summary's metric search focused: the attributes metrics can be found by. */
|
||||
export const FilterKeySuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openKeySuggestions(canvasElement, 'k8s.cluster.name');
|
||||
},
|
||||
play: suggestFilterKeys,
|
||||
};
|
||||
|
||||
/** The metric search on an attribute and an operator: the values it holds. */
|
||||
export const FilterValueSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'service.name = ');
|
||||
await findSuggestion(canvasElement, 'checkout');
|
||||
},
|
||||
play: suggestFilterValues,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -136,14 +122,7 @@ export const FilterValueSuggestions: Story = {
|
||||
*/
|
||||
export const UnitSavedToast: Story = {
|
||||
args: { tab: 'explorer', metricUnit: 'unset' },
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await saveUnit(
|
||||
canvasElement,
|
||||
args.unitSave === 'success'
|
||||
? 'Unit saved successfully'
|
||||
: 'Failed to save unit',
|
||||
);
|
||||
},
|
||||
play: saveMetricUnit,
|
||||
};
|
||||
|
||||
/** The same save with the metadata write failing. */
|
||||
@@ -151,7 +130,5 @@ export const UnitSaveErrorToast: Story = {
|
||||
args: { tab: 'explorer', metricUnit: 'unset', unitSave: 'error' },
|
||||
// The deliberate 500 is the state under test.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await saveUnit(canvasElement, 'Failed to save unit');
|
||||
},
|
||||
play: saveMetricUnit,
|
||||
};
|
||||
|
||||
90
frontend/src/pages/MetricsExplorer/stories/__story_mockdata__/metricsExplorer.play.ts
generated
Normal file
90
frontend/src/pages/MetricsExplorer/stories/__story_mockdata__/metricsExplorer.play.ts
generated
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import {
|
||||
findSuggestion,
|
||||
openKeySuggestions,
|
||||
typeFilter,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
|
||||
import { screen, userEvent } from 'storybook/test';
|
||||
|
||||
import {
|
||||
type MetricsArgs,
|
||||
saveUnit,
|
||||
} from '../MetricsExplorerPage.stories.mocks';
|
||||
|
||||
interface PlayContext {
|
||||
canvasElement: HTMLElement;
|
||||
args: MetricsArgs;
|
||||
}
|
||||
|
||||
const showsMetricSearch = (args: MetricsArgs): boolean =>
|
||||
args.dataState === 'loaded' && args.tab !== 'volume-control';
|
||||
|
||||
export const openDashboardsMenu = async ({
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (
|
||||
args.tab !== 'summary' ||
|
||||
args.drawer !== 'details' ||
|
||||
args.dataState !== 'loaded' ||
|
||||
args.relatedAssets === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await userEvent.click(
|
||||
await screen.findByTestId(
|
||||
'metric-dashboards-popover',
|
||||
{},
|
||||
{ timeout: 10000 },
|
||||
),
|
||||
);
|
||||
await screen.findByRole('menu');
|
||||
};
|
||||
|
||||
export const suggestFilterKeys = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!showsMetricSearch(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openKeySuggestions(canvasElement, 'k8s.cluster.name');
|
||||
};
|
||||
|
||||
export const suggestFilterValues = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!showsMetricSearch(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await typeFilter(canvasElement, 'service.name = ');
|
||||
await findSuggestion(canvasElement, 'checkout');
|
||||
};
|
||||
|
||||
export const saveMetricUnit = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (
|
||||
args.tab !== 'explorer' ||
|
||||
args.dataState !== 'loaded' ||
|
||||
args.metricUnit !== 'unset' ||
|
||||
args.explorerMetrics !== 1
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await saveUnit(
|
||||
canvasElement,
|
||||
args.unitSave === 'success'
|
||||
? 'Unit saved successfully'
|
||||
: 'Failed to save unit',
|
||||
);
|
||||
};
|
||||
@@ -613,6 +613,22 @@ const RULE_UPDATED_AT = '2026-08-20T09:24:00Z';
|
||||
|
||||
const RULE_EFFECTIVE_FROM = '2026-08-20T09:29:00Z';
|
||||
|
||||
const isPendingRule = (index: number, pending: boolean): boolean =>
|
||||
pending && index % 4 === 0;
|
||||
|
||||
/** Whether the drawer's metric has a rule that has not taken effect yet. */
|
||||
export const hasPendingRule = (
|
||||
type: DrawerMetricType,
|
||||
count: number,
|
||||
pending: boolean,
|
||||
): boolean => {
|
||||
const index = REDUCTION_RULES.findIndex(
|
||||
({ metricName }) => metricName === drawerMetricName(type),
|
||||
);
|
||||
|
||||
return index >= 0 && index < count && isPendingRule(index, pending);
|
||||
};
|
||||
|
||||
const ruleOf = (
|
||||
seed: ReductionRuleSeed,
|
||||
index: number,
|
||||
@@ -627,7 +643,7 @@ const ruleOf = (
|
||||
labels: seed.labels,
|
||||
// A rule takes about five minutes to take effect; until then the table
|
||||
// badges it as pending.
|
||||
active: !(pending && index % 4 === 0),
|
||||
active: !isPendingRule(index, pending),
|
||||
effectiveFrom: RULE_EFFECTIVE_FROM,
|
||||
ingestedSeries: timeseries,
|
||||
retainedSeries: Math.round(timeseries * (1 - seed.reduction)),
|
||||
|
||||
@@ -86,6 +86,11 @@ export const onboardingV2Mocks = defineStoryMocks({
|
||||
'http://localhost/api/v1/roles',
|
||||
response.json(() => rolesListResponse(0)),
|
||||
),
|
||||
// Add Credit Card creates the subscription; an empty redirect keeps the
|
||||
// story from opening a billing tab.
|
||||
rest.post('http://localhost/api/v1/subscriptions', (_req, res, ctx) =>
|
||||
res(ctx.json({ status: 'success', data: { redirectURL: '' } })),
|
||||
),
|
||||
rest.post('http://localhost/api/v2/users', (_req, res, ctx) => {
|
||||
if (values.invite === 'loading') {
|
||||
return res(ctx.delay('infinite'));
|
||||
|
||||
@@ -1,82 +1,20 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import OnboardingPageV2 from '../OnboardingPageV2';
|
||||
import { onboardingV2Mocks } from './OnboardingPageV2.stories.mocks';
|
||||
import {
|
||||
onboardingV2Mocks,
|
||||
SETUP_STEPS,
|
||||
type SetupStep,
|
||||
} from './OnboardingPageV2.stories.mocks';
|
||||
openAddCreditCard,
|
||||
openInviteModal,
|
||||
searchWithoutResults,
|
||||
sendInvite,
|
||||
walkToStep,
|
||||
} from './__story_mockdata__/onboardingV2.play';
|
||||
|
||||
type OnboardingV2Args = PageStoryArgs<typeof onboardingV2Mocks>;
|
||||
|
||||
type Canvas = ReturnType<typeof within>;
|
||||
|
||||
/** The catalogue mounts before the first group of sources paints. */
|
||||
const untilRendered = { timeout: 15_000 };
|
||||
|
||||
/**
|
||||
* Every option is a button wrapping its own logo, so the accessible name carries
|
||||
* the label twice and matches the other sources with the label as a prefix
|
||||
* (`Java`, `JavaScript`). The label's own text node is the one thing that is
|
||||
* exact.
|
||||
*/
|
||||
const chooseOption = async (canvas: Canvas, label: string): Promise<void> => {
|
||||
const option = await canvas.findByText(label, undefined, untilRendered);
|
||||
|
||||
await userEvent.click(option.closest('button') as HTMLElement);
|
||||
};
|
||||
|
||||
const answerDataSource = (canvas: Canvas): Promise<void> =>
|
||||
chooseOption(canvas, 'Java');
|
||||
|
||||
const answerFramework = (canvas: Canvas): Promise<void> =>
|
||||
chooseOption(canvas, 'Spring Boot');
|
||||
|
||||
const answerEnvironment = (canvas: Canvas): Promise<void> =>
|
||||
chooseOption(canvas, 'VM');
|
||||
|
||||
const openConfigureProduct = async (canvas: Canvas): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await canvas.findByRole(
|
||||
'button',
|
||||
{ name: /configure your product/i },
|
||||
untilRendered,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const ANSWER_STEP = [
|
||||
answerDataSource,
|
||||
answerFramework,
|
||||
answerEnvironment,
|
||||
openConfigureProduct,
|
||||
];
|
||||
|
||||
const advanceToSetupStep = async (
|
||||
canvasElement: HTMLElement,
|
||||
step: SetupStep,
|
||||
): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
// A source that arrived through the param is already on the setup
|
||||
// instructions, with no question left to answer.
|
||||
if (!canvas.queryByText('Select your data source')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const answers = ANSWER_STEP.slice(0, SETUP_STEPS.indexOf(step));
|
||||
|
||||
// Sequential: each answer is what renders the question the next one reads.
|
||||
await answers.reduce(
|
||||
(walked, answer) => walked.then(() => answer(canvas)),
|
||||
Promise.resolve(),
|
||||
);
|
||||
};
|
||||
|
||||
const pageStory = storyMocks(onboardingV2Mocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
@@ -89,16 +27,7 @@ const meta = {
|
||||
title: 'Pages/Onboarding/Add Data Source',
|
||||
tags: ['play'],
|
||||
component: OnboardingPageV2,
|
||||
/**
|
||||
* Destructuring `mount` is what makes the step control a control: Storybook
|
||||
* re-runs a play function on an arg change only for a story that asks to be
|
||||
* remounted, and otherwise re-renders the one it already walked.
|
||||
*/
|
||||
play: async ({ mount, args, canvasElement }): Promise<void> => {
|
||||
await mount();
|
||||
|
||||
await advanceToSetupStep(canvasElement, args.step);
|
||||
},
|
||||
play: walkToStep,
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<OnboardingV2Args>;
|
||||
@@ -137,35 +66,12 @@ export const SourcePreselected: Story = {
|
||||
|
||||
/** The one thing the header offers besides support: bringing the team along. */
|
||||
export const InviteTeammate: Story = {
|
||||
play: async ({ mount, args, canvasElement }): Promise<void> => {
|
||||
await mount();
|
||||
|
||||
await advanceToSetupStep(canvasElement, args.step);
|
||||
|
||||
await userEvent.click(
|
||||
within(canvasElement).getByRole('button', { name: /invite a teammate/i }),
|
||||
);
|
||||
|
||||
// antd portals the modal out of the story root.
|
||||
await screen.findByText('Invite a team member', undefined, untilRendered);
|
||||
},
|
||||
play: openInviteModal,
|
||||
};
|
||||
|
||||
/** A search nothing answers, where the catalogue offers to add the source instead. */
|
||||
export const NoSearchResults: Story = {
|
||||
play: async ({ mount, canvasElement }): Promise<void> => {
|
||||
await mount();
|
||||
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.type(
|
||||
await canvas.findByPlaceholderText('Search', undefined, untilRendered),
|
||||
'ledger',
|
||||
);
|
||||
|
||||
// The search is debounced, so the miss lands a beat after the last keystroke.
|
||||
await canvas.findByText(/no results for ledger/i, undefined, untilRendered);
|
||||
},
|
||||
play: searchWithoutResults,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -174,48 +80,14 @@ export const NoSearchResults: Story = {
|
||||
* is accepted; Invite result changes how it answers.
|
||||
*/
|
||||
export const InvitesSentToast: Story = {
|
||||
play: async ({ mount, args, canvasElement }): Promise<void> => {
|
||||
await mount();
|
||||
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole(
|
||||
'button',
|
||||
{ name: /invite a teammate/i },
|
||||
untilRendered,
|
||||
),
|
||||
);
|
||||
|
||||
const dialog = await screen.findByRole('dialog', undefined, untilRendered);
|
||||
const [email] = await within(dialog).findAllByTestId(
|
||||
/^invite-email-/,
|
||||
undefined,
|
||||
untilRendered,
|
||||
);
|
||||
|
||||
await userEvent.type(email, 'jon@winterfell.dev');
|
||||
const [roles] = await within(dialog).findAllByRole('combobox');
|
||||
|
||||
await userEvent.click(roles);
|
||||
const viewers = await screen.findAllByText(
|
||||
'viewer',
|
||||
undefined,
|
||||
untilRendered,
|
||||
);
|
||||
|
||||
await userEvent.click(
|
||||
viewers[viewers.length - 1].closest(
|
||||
'.ant-select-item-option',
|
||||
) as HTMLElement,
|
||||
);
|
||||
await userEvent.keyboard('{Escape}');
|
||||
await userEvent.click(
|
||||
await within(dialog).findByRole('button', { name: /send invites/i }),
|
||||
);
|
||||
|
||||
if (args.invite === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/invites sent successfully/i)).toBeVisible(),
|
||||
);
|
||||
}
|
||||
},
|
||||
play: sendInvite,
|
||||
};
|
||||
|
||||
/**
|
||||
* A cloud trial without premium support, whose Contact Support in the header asks
|
||||
* for a card to unlock chat support instead of opening the chat.
|
||||
*/
|
||||
export const FacingIssuesAddCard: Story = {
|
||||
args: { support: 'add-card' },
|
||||
play: openAddCreditCard,
|
||||
};
|
||||
|
||||
227
frontend/src/pages/OnboardingPageV2/stories/__story_mockdata__/onboardingV2.play.ts
generated
Normal file
227
frontend/src/pages/OnboardingPageV2/stories/__story_mockdata__/onboardingV2.play.ts
generated
Normal file
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
onboardingV2Mocks,
|
||||
SETUP_STEPS,
|
||||
type SetupStep,
|
||||
} from '../OnboardingPageV2.stories.mocks';
|
||||
|
||||
type OnboardingV2Args = PageStoryArgs<typeof onboardingV2Mocks>;
|
||||
|
||||
interface PlayContext {
|
||||
mount: () => Promise<unknown>;
|
||||
args: OnboardingV2Args;
|
||||
canvasElement: HTMLElement;
|
||||
}
|
||||
|
||||
type Canvas = ReturnType<typeof within>;
|
||||
|
||||
/** The catalogue mounts before the first group of sources paints. */
|
||||
const untilRendered = { timeout: 15_000 };
|
||||
|
||||
/**
|
||||
* Every option is a button wrapping its own logo, so the accessible name carries
|
||||
* the label twice and matches the other sources with the label as a prefix
|
||||
* (`Java`, `JavaScript`). The label's own text node is the one thing that is
|
||||
* exact.
|
||||
*/
|
||||
const chooseOption = async (canvas: Canvas, label: string): Promise<void> => {
|
||||
const option = await canvas.findByText(label, undefined, untilRendered);
|
||||
|
||||
await userEvent.click(option.closest('button') as HTMLElement);
|
||||
};
|
||||
|
||||
const answerDataSource = (canvas: Canvas): Promise<void> =>
|
||||
chooseOption(canvas, 'Java');
|
||||
|
||||
const answerFramework = (canvas: Canvas): Promise<void> =>
|
||||
chooseOption(canvas, 'Spring Boot');
|
||||
|
||||
const answerEnvironment = (canvas: Canvas): Promise<void> =>
|
||||
chooseOption(canvas, 'VM');
|
||||
|
||||
const openConfigureProduct = async (canvas: Canvas): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await canvas.findByRole(
|
||||
'button',
|
||||
{ name: /configure your product/i },
|
||||
untilRendered,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const ANSWER_STEP = [
|
||||
answerDataSource,
|
||||
answerFramework,
|
||||
answerEnvironment,
|
||||
openConfigureProduct,
|
||||
];
|
||||
|
||||
const advanceToSetupStep = async (
|
||||
canvasElement: HTMLElement,
|
||||
step: SetupStep,
|
||||
): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
// A source that arrived through the param is already on the setup
|
||||
// instructions, with no question left to answer.
|
||||
if (!canvas.queryByText('Select your data source')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const answers = ANSWER_STEP.slice(0, SETUP_STEPS.indexOf(step));
|
||||
|
||||
// Sequential: each answer is what renders the question the next one reads.
|
||||
await answers.reduce(
|
||||
(walked, answer) => walked.then(() => answer(canvas)),
|
||||
Promise.resolve(),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Destructuring `mount` is what makes the step control a control: Storybook
|
||||
* re-runs a play function on an arg change only for a story that asks to be
|
||||
* remounted, and otherwise re-renders the one it already walked.
|
||||
*/
|
||||
export const walkToStep = async ({
|
||||
mount,
|
||||
args,
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await mount();
|
||||
|
||||
await advanceToSetupStep(canvasElement, args.step);
|
||||
};
|
||||
|
||||
export const openInviteModal = async ({
|
||||
mount,
|
||||
args,
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await mount();
|
||||
|
||||
await advanceToSetupStep(canvasElement, args.step);
|
||||
|
||||
await userEvent.click(
|
||||
within(canvasElement).getByRole('button', { name: /invite a teammate/i }),
|
||||
);
|
||||
|
||||
// antd portals the modal out of the story root.
|
||||
await screen.findByText('Invite a team member', undefined, untilRendered);
|
||||
};
|
||||
|
||||
export const searchWithoutResults = async ({
|
||||
mount,
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await mount();
|
||||
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.type(
|
||||
await canvas.findByPlaceholderText('Search', undefined, untilRendered),
|
||||
'ledger',
|
||||
);
|
||||
|
||||
// The search is debounced, so the miss lands a beat after the last keystroke.
|
||||
await canvas.findByText(/no results for ledger/i, undefined, untilRendered);
|
||||
};
|
||||
|
||||
export const sendInvite = async ({
|
||||
mount,
|
||||
args,
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await mount();
|
||||
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole(
|
||||
'button',
|
||||
{ name: /invite a teammate/i },
|
||||
untilRendered,
|
||||
),
|
||||
);
|
||||
|
||||
const dialog = await screen.findByRole('dialog', undefined, untilRendered);
|
||||
const [email] = await within(dialog).findAllByTestId(
|
||||
/^invite-email-/,
|
||||
undefined,
|
||||
untilRendered,
|
||||
);
|
||||
|
||||
await userEvent.type(email, 'jon@winterfell.dev');
|
||||
const [roles] = await within(dialog).findAllByRole('combobox');
|
||||
|
||||
await userEvent.click(roles);
|
||||
const viewers = await screen.findAllByText('viewer', undefined, untilRendered);
|
||||
|
||||
await userEvent.click(
|
||||
viewers[viewers.length - 1].closest('.ant-select-item-option') as HTMLElement,
|
||||
);
|
||||
await userEvent.keyboard('{Escape}');
|
||||
await userEvent.click(
|
||||
await within(dialog).findByRole('button', { name: /send invites/i }),
|
||||
);
|
||||
|
||||
if (args.invite === 'success') {
|
||||
await waitFor(
|
||||
() => expect(screen.getByText(/invites sent successfully/i)).toBeVisible(),
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/** Banners that turn the cloud license into a self-hosted one. */
|
||||
const SELF_HOSTED_BANNERS: readonly OnboardingV2Args['banner'][] = [
|
||||
'license-expired',
|
||||
'license-terminated',
|
||||
];
|
||||
|
||||
/** Only a cloud workspace without premium support is offered the card. */
|
||||
const offersAddCard = (args: OnboardingV2Args): boolean =>
|
||||
args.support === 'add-card' &&
|
||||
args.license === 'cloud' &&
|
||||
!SELF_HOSTED_BANNERS.includes(args.banner);
|
||||
|
||||
const ADD_CARD_DIALOG = { name: /Add Credit Card for Chat Support/i };
|
||||
|
||||
export const openAddCreditCard = async ({
|
||||
mount,
|
||||
args,
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await mount();
|
||||
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
if (!offersAddCard(args)) {
|
||||
await canvas.findByRole(
|
||||
'button',
|
||||
{ name: /invite a teammate/i },
|
||||
untilRendered,
|
||||
);
|
||||
await expect(
|
||||
screen.queryByRole('dialog', ADD_CARD_DIALOG),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await userEvent.click(
|
||||
await canvas.findByRole(
|
||||
'button',
|
||||
{ name: /contact support/i },
|
||||
untilRendered,
|
||||
),
|
||||
);
|
||||
|
||||
// antd portals the modal out of the story root.
|
||||
await screen.findByRole('dialog', ADD_CARD_DIALOG, untilRendered);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { screen, userEvent } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
@@ -35,3 +36,20 @@ export const ExpiredLink: Story = {
|
||||
// The deliberate 410 is the state under test.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
/** The mismatch callout, after the two password fields disagree and blur. */
|
||||
export const PasswordMismatch: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const password = await screen.findByTestId('password', undefined, {
|
||||
timeout: 15000,
|
||||
});
|
||||
const confirm = await screen.findByPlaceholderText(
|
||||
/confirm your new password/i,
|
||||
);
|
||||
|
||||
await userEvent.type(password, 'password123');
|
||||
await userEvent.type(confirm, 'password456');
|
||||
await userEvent.click(canvasElement);
|
||||
await screen.findByText(/passwords don't match/i);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -46,8 +46,3 @@ export const Default: Story = {};
|
||||
export const PaginatedOperations: Story = {
|
||||
args: { operations: 120 },
|
||||
};
|
||||
|
||||
/** The documented 2,500-operation threshold is exceeded by one entry point. */
|
||||
export const ThresholdWarning: Story = {
|
||||
args: { operations: 2501 },
|
||||
};
|
||||
|
||||
@@ -197,30 +197,8 @@ export const openMember = async (
|
||||
await userEvent.click(cell.closest('tr') as HTMLElement);
|
||||
};
|
||||
|
||||
/**
|
||||
* Selects `viewer` in the roles dropdown at `index`. The option's content ignores
|
||||
* pointer events, so the click goes to the option around it. A click on the
|
||||
* label closes the dropdown, where Escape would close the dialog too.
|
||||
*/
|
||||
const pickRole = async (index: number): Promise<void> => {
|
||||
const comboboxes = await screen.findAllByRole(
|
||||
'combobox',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.click(comboboxes[index]);
|
||||
const options = await screen.findAllByText('viewer', undefined, untilLoaded);
|
||||
|
||||
await userEvent.click(
|
||||
options[options.length - 1].closest('.ant-select-item-option') as HTMLElement,
|
||||
);
|
||||
await userEvent.click(await screen.findByText('Email address'));
|
||||
};
|
||||
|
||||
export const sendInvites = async (
|
||||
export const openInviteForm = async (
|
||||
canvasElement: HTMLElement,
|
||||
emails: string[],
|
||||
): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByText(
|
||||
@@ -229,6 +207,49 @@ export const sendInvites = async (
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
// The form opens with a row per seat, each carrying its own email field.
|
||||
await screen.findAllByTestId(/^invite-email-/, undefined, untilLoaded);
|
||||
};
|
||||
|
||||
/** A click on the label closes the dropdown, where Escape would close the dialog too. */
|
||||
export const closeRoles = async (): Promise<void> => {
|
||||
await userEvent.click(await screen.findByText('Email address'));
|
||||
};
|
||||
|
||||
/**
|
||||
* Picks `role` in the open roles dropdown. The option's content ignores pointer
|
||||
* events, so the click goes to the option around it.
|
||||
*/
|
||||
export const pickOpenRole = async (role: string): Promise<void> => {
|
||||
const options = await screen.findAllByText(role, undefined, untilLoaded);
|
||||
|
||||
await userEvent.click(
|
||||
options[options.length - 1].closest('.ant-select-item-option') as HTMLElement,
|
||||
);
|
||||
};
|
||||
|
||||
/** Opens the roles dropdown of the invite row at `index`. */
|
||||
export const openInviteRoleDropdown = async (index: number): Promise<void> => {
|
||||
const comboboxes = await screen.findAllByRole(
|
||||
'combobox',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.click(comboboxes[index]);
|
||||
};
|
||||
|
||||
const pickRole = async (index: number): Promise<void> => {
|
||||
await openInviteRoleDropdown(index);
|
||||
await pickOpenRole('viewer');
|
||||
await closeRoles();
|
||||
};
|
||||
|
||||
export const sendInvites = async (
|
||||
canvasElement: HTMLElement,
|
||||
emails: string[],
|
||||
): Promise<void> => {
|
||||
await openInviteForm(canvasElement);
|
||||
const fields = await screen.findAllByTestId(
|
||||
/^invite-email-/,
|
||||
undefined,
|
||||
|
||||
@@ -1,16 +1,31 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
membersMocks,
|
||||
openMember,
|
||||
openResetLink,
|
||||
sendInvites,
|
||||
untilLoaded,
|
||||
} from './Members.stories.mocks';
|
||||
copyInviteLink,
|
||||
copyResetLink,
|
||||
deleteMember,
|
||||
failResetLink,
|
||||
filterDeletedOnly,
|
||||
filterPendingOnly,
|
||||
openAssignRoles,
|
||||
openDeleteMemberConfirm,
|
||||
openEditMember,
|
||||
openFilterMenu,
|
||||
openInviteMembers,
|
||||
openInviteRoles,
|
||||
openPendingMember,
|
||||
openRevokeInviteConfirm,
|
||||
revokeInvite,
|
||||
saveMemberName,
|
||||
searchNoResults,
|
||||
selectInviteRoles,
|
||||
sendInvitesPartiallyRefused,
|
||||
sendInvitesToAll,
|
||||
} from './__story_mockdata__/members.play';
|
||||
import { membersMocks } from './Members.stories.mocks';
|
||||
|
||||
import SettingsPage from '../../Settings';
|
||||
|
||||
@@ -39,15 +54,6 @@ type Story = StoryObj<MembersArgs>;
|
||||
/** One more than the twenty rows a page holds. */
|
||||
const OVER_ONE_PAGE = 21;
|
||||
|
||||
const openPendingMember = async ({
|
||||
canvasElement,
|
||||
}: {
|
||||
canvasElement: HTMLElement;
|
||||
}): Promise<void> => {
|
||||
await openMember(canvasElement, /podrick payne/i);
|
||||
await screen.findByText(/invited on/i, undefined, untilLoaded);
|
||||
};
|
||||
|
||||
/**
|
||||
* Everyone with a seat in the workspace: who they are, whether they have taken
|
||||
* up their invite, and when they joined.
|
||||
@@ -61,68 +67,22 @@ export const AllPending: Story = {
|
||||
|
||||
/** Interaction: the status menu is open with the real member counts. */
|
||||
export const FilterMenuOpen: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByText(
|
||||
/all members/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByText(/pending invites/i, undefined, untilLoaded);
|
||||
},
|
||||
play: openFilterMenu,
|
||||
};
|
||||
|
||||
/** Interaction: the pending-invite filter narrows the real member table. */
|
||||
export const PendingOnly: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByText(
|
||||
/all members/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await userEvent.click(
|
||||
await screen.findByText(/^pending invites/i, undefined, untilLoaded),
|
||||
);
|
||||
// One badge per pending row.
|
||||
await screen.findAllByText('INVITED', undefined, untilLoaded);
|
||||
},
|
||||
play: filterPendingOnly,
|
||||
};
|
||||
|
||||
/** Interaction: the deleted-member filter exposes the read-only member rows. */
|
||||
export const DeletedOnly: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByText(
|
||||
/all members/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await userEvent.click(
|
||||
// The unfiltered table already shows a DELETED badge, so the menu option
|
||||
// is matched on its own count suffix.
|
||||
await screen.findByText(/^deleted ⎯/i, undefined, untilLoaded),
|
||||
);
|
||||
await screen.findAllByText('DELETED', undefined, untilLoaded);
|
||||
},
|
||||
play: filterDeletedOnly,
|
||||
};
|
||||
|
||||
/** Empty: searching for a non-member renders the table's supported empty state. */
|
||||
export const SearchNoResults: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.type(
|
||||
await within(canvasElement).findByPlaceholderText(
|
||||
/search by name or email/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
'no-such-member',
|
||||
);
|
||||
await screen.findByText(/no results for/i, undefined, untilLoaded);
|
||||
},
|
||||
play: searchNoResults,
|
||||
};
|
||||
|
||||
/** More members than one page holds, which is where the pager appears. */
|
||||
@@ -132,10 +92,7 @@ export const Paginated: Story = {
|
||||
|
||||
/** A member opened up: the name and roles that can be changed from here. */
|
||||
export const EditMember: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openMember(canvasElement, /jon snow/i);
|
||||
await screen.findByPlaceholderText(/enter name/i, undefined, untilLoaded);
|
||||
},
|
||||
play: openEditMember,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -154,51 +111,32 @@ export const ExpiredInvite: Story = {
|
||||
|
||||
/** Interaction: deleting an active member is guarded by its confirmation dialog. */
|
||||
export const DeleteMemberConfirm: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openMember(canvasElement, /jon snow/i);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Delete Member' }),
|
||||
);
|
||||
await screen.findByRole('dialog', { name: 'Delete Member' });
|
||||
},
|
||||
play: openDeleteMemberConfirm,
|
||||
};
|
||||
|
||||
/** Interaction: revoking a pending invite uses its distinct confirmation copy. */
|
||||
export const RevokeInviteConfirm: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openPendingMember({ canvasElement });
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Revoke Invite' }),
|
||||
);
|
||||
await screen.findByRole('dialog', { name: 'Revoke Invite' });
|
||||
},
|
||||
play: openRevokeInviteConfirm,
|
||||
};
|
||||
|
||||
/**
|
||||
* The roles a member can be given. The dropdown opens off the inner combobox,
|
||||
* not the wrapper the click lands on first.
|
||||
*/
|
||||
/** The roles a member can be given. */
|
||||
export const AssignRoles: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openMember(canvasElement, /jon snow/i);
|
||||
await userEvent.click(await screen.findByRole('combobox'));
|
||||
await screen.findByText('oncall-responder', undefined, untilLoaded);
|
||||
},
|
||||
play: openAssignRoles,
|
||||
};
|
||||
|
||||
/** The form that mails seats out: an email and a role per row. */
|
||||
export const InviteMembers: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByText(
|
||||
/invite member/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
// The form opens with a row per seat, each carrying its own email field.
|
||||
await screen.findAllByTestId(/^invite-email-/, undefined, untilLoaded);
|
||||
},
|
||||
play: openInviteMembers,
|
||||
};
|
||||
|
||||
/** The invite form with the first row's roles dropdown open. */
|
||||
export const InviteRolesOpen: Story = {
|
||||
play: openInviteRoles,
|
||||
};
|
||||
|
||||
/** The invite form after three roles were picked on the first row. */
|
||||
export const InviteRolesSelected: Story = {
|
||||
play: selectInviteRoles,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -206,34 +144,14 @@ export const InviteMembers: Story = {
|
||||
* to `error` or `loading` to see the form instead.
|
||||
*/
|
||||
export const InvitesSentToast: Story = {
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await sendInvites(canvasElement, [
|
||||
'bran@nightswatch.io',
|
||||
'hodor@nightswatch.io',
|
||||
]);
|
||||
if (args.invite === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/invites sent successfully/i)).toBeVisible(),
|
||||
);
|
||||
}
|
||||
},
|
||||
play: sendInvitesToAll,
|
||||
};
|
||||
|
||||
/** One address refused out of two: the warning that some invites failed. */
|
||||
export const InvitesPartiallyFailedToast: Story = {
|
||||
// The 409 for the refused address is the state under test.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await sendInvites(canvasElement, [
|
||||
'bran@nightswatch.io',
|
||||
'hodor@blocked.example',
|
||||
]);
|
||||
if (args.invite === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/some invites failed/i)).toBeVisible(),
|
||||
);
|
||||
}
|
||||
},
|
||||
play: sendInvitesPartiallyRefused,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -241,27 +159,7 @@ export const InvitesPartiallyFailedToast: Story = {
|
||||
* member to `error` or `loading` to see the drawer instead.
|
||||
*/
|
||||
export const MemberUpdatedToast: Story = {
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await openMember(canvasElement, /jon snow/i);
|
||||
const name = await screen.findByPlaceholderText(
|
||||
/enter name/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(name).toHaveValue('Jon Snow'), untilLoaded);
|
||||
await userEvent.type(name, ' Targaryen');
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Save Member Details' }),
|
||||
);
|
||||
if (args.memberUpdate === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText(/member details updated successfully/i),
|
||||
).toBeVisible(),
|
||||
);
|
||||
}
|
||||
},
|
||||
play: saveMemberName,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -269,22 +167,7 @@ export const MemberUpdatedToast: Story = {
|
||||
* the member to `error` or `loading` to see the dialog instead.
|
||||
*/
|
||||
export const MemberDeletedToast: Story = {
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await openMember(canvasElement, /jon snow/i);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Delete Member' }),
|
||||
);
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Delete Member' });
|
||||
|
||||
await userEvent.click(
|
||||
within(dialog).getByRole('button', { name: 'Delete Member' }),
|
||||
);
|
||||
if (args.memberDelete === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/member deleted successfully/i)).toBeVisible(),
|
||||
);
|
||||
}
|
||||
},
|
||||
play: deleteMember,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -292,22 +175,7 @@ export const MemberDeletedToast: Story = {
|
||||
* Deleting the member to `error` or `loading` to see the dialog instead.
|
||||
*/
|
||||
export const InviteRevokedToast: Story = {
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await openPendingMember({ canvasElement });
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Revoke Invite' }),
|
||||
);
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Revoke Invite' });
|
||||
|
||||
await userEvent.click(
|
||||
within(dialog).getByRole('button', { name: 'Revoke Invite' }),
|
||||
);
|
||||
if (args.memberDelete === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/invite revoked successfully/i)).toBeVisible(),
|
||||
);
|
||||
}
|
||||
},
|
||||
play: revokeInvite,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -315,13 +183,7 @@ export const InviteRevokedToast: Story = {
|
||||
* confirming it.
|
||||
*/
|
||||
export const ResetLinkCopiedToast: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openResetLink(canvasElement);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: /^copy$/i }, untilLoaded),
|
||||
);
|
||||
await screen.findByText(/reset link copied to clipboard/i);
|
||||
},
|
||||
play: copyResetLink,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -329,16 +191,7 @@ export const ResetLinkCopiedToast: Story = {
|
||||
* confirming it.
|
||||
*/
|
||||
export const InviteLinkCopiedToast: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openPendingMember({ canvasElement });
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: /invite link/i }, untilLoaded),
|
||||
);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: /^copy$/i }, untilLoaded),
|
||||
);
|
||||
await screen.findByText(/invite link copied to clipboard/i);
|
||||
},
|
||||
play: copyInviteLink,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -347,14 +200,5 @@ export const InviteLinkCopiedToast: Story = {
|
||||
*/
|
||||
export const ResetLinkFailedToast: Story = {
|
||||
args: { resetLink: 'no-token' },
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await openResetLink(canvasElement);
|
||||
if (args.resetLink === 'no-token') {
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText(/failed to generate password reset link/i),
|
||||
).toBeVisible(),
|
||||
);
|
||||
}
|
||||
},
|
||||
play: failResetLink,
|
||||
};
|
||||
|
||||
293
frontend/src/pages/Settings/Members/stories/__story_mockdata__/members.play.ts
generated
Normal file
293
frontend/src/pages/Settings/Members/stories/__story_mockdata__/members.play.ts
generated
Normal file
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
closeRoles,
|
||||
membersMocks,
|
||||
openInviteForm,
|
||||
openInviteRoleDropdown,
|
||||
openMember,
|
||||
openResetLink,
|
||||
pickOpenRole,
|
||||
sendInvites,
|
||||
untilLoaded,
|
||||
} from '../Members.stories.mocks';
|
||||
|
||||
interface PlayContext {
|
||||
canvasElement: HTMLElement;
|
||||
args: PageStoryArgs<typeof membersMocks>;
|
||||
}
|
||||
|
||||
const openStatusFilter = async (canvasElement: HTMLElement): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByText(
|
||||
/all members/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
export const openFilterMenu = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openStatusFilter(canvasElement);
|
||||
await screen.findByText(/pending invites/i, undefined, untilLoaded);
|
||||
};
|
||||
|
||||
export const filterPendingOnly = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openStatusFilter(canvasElement);
|
||||
await userEvent.click(
|
||||
await screen.findByText(/^pending invites/i, undefined, untilLoaded),
|
||||
);
|
||||
// One badge per pending row.
|
||||
await screen.findAllByText('INVITED', undefined, untilLoaded);
|
||||
};
|
||||
|
||||
export const filterDeletedOnly = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openStatusFilter(canvasElement);
|
||||
await userEvent.click(
|
||||
// The unfiltered table already shows a DELETED badge, so the menu option
|
||||
// is matched on its own count suffix.
|
||||
await screen.findByText(/^deleted ⎯/i, undefined, untilLoaded),
|
||||
);
|
||||
await screen.findAllByText('DELETED', undefined, untilLoaded);
|
||||
};
|
||||
|
||||
export const searchNoResults = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await userEvent.type(
|
||||
await within(canvasElement).findByPlaceholderText(
|
||||
/search by name or email/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
'no-such-member',
|
||||
);
|
||||
await screen.findByText(/no results for/i, undefined, untilLoaded);
|
||||
};
|
||||
|
||||
export const openEditMember = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openMember(canvasElement, /jon snow/i);
|
||||
await screen.findByPlaceholderText(/enter name/i, undefined, untilLoaded);
|
||||
};
|
||||
|
||||
export const openPendingMember = async ({
|
||||
canvasElement,
|
||||
}: Pick<PlayContext, 'canvasElement'>): Promise<void> => {
|
||||
await openMember(canvasElement, /podrick payne/i);
|
||||
await screen.findByText(/invited on/i, undefined, untilLoaded);
|
||||
};
|
||||
|
||||
export const openDeleteMemberConfirm = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openMember(canvasElement, /jon snow/i);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Delete Member' }),
|
||||
);
|
||||
await screen.findByRole('dialog', { name: 'Delete Member' });
|
||||
};
|
||||
|
||||
export const openRevokeInviteConfirm = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openPendingMember({ canvasElement });
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Revoke Invite' }),
|
||||
);
|
||||
await screen.findByRole('dialog', { name: 'Revoke Invite' });
|
||||
};
|
||||
|
||||
export const openAssignRoles = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openMember(canvasElement, /jon snow/i);
|
||||
// The dropdown opens off the inner combobox, not the wrapper the click lands on first.
|
||||
await userEvent.click(await screen.findByRole('combobox'));
|
||||
await screen.findByText('oncall-responder', undefined, untilLoaded);
|
||||
};
|
||||
|
||||
export const openInviteMembers = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openInviteForm(canvasElement);
|
||||
};
|
||||
|
||||
export const openInviteRoles = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openInviteForm(canvasElement);
|
||||
await openInviteRoleDropdown(0);
|
||||
await screen.findAllByText('viewer', undefined, untilLoaded);
|
||||
};
|
||||
|
||||
export const selectInviteRoles = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openInviteForm(canvasElement);
|
||||
await openInviteRoleDropdown(0);
|
||||
for (const role of ['viewer', 'editor', 'oncall-responder']) {
|
||||
await pickOpenRole(role);
|
||||
}
|
||||
await closeRoles();
|
||||
// The open list titles its options too, so the check is scoped to the selected tags.
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(
|
||||
document.querySelector(
|
||||
'.ant-select-selection-item[title="oncall-responder"]',
|
||||
),
|
||||
).toBeVisible(),
|
||||
untilLoaded,
|
||||
);
|
||||
};
|
||||
|
||||
export const sendInvitesToAll = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await sendInvites(canvasElement, [
|
||||
'bran@nightswatch.io',
|
||||
'hodor@nightswatch.io',
|
||||
]);
|
||||
if (args.invite === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/invites sent successfully/i)).toBeVisible(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const sendInvitesPartiallyRefused = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await sendInvites(canvasElement, [
|
||||
'bran@nightswatch.io',
|
||||
'hodor@blocked.example',
|
||||
]);
|
||||
if (args.invite === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/some invites failed/i)).toBeVisible(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const saveMemberName = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openMember(canvasElement, /jon snow/i);
|
||||
const name = await screen.findByPlaceholderText(
|
||||
/enter name/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(name).toHaveValue('Jon Snow'), untilLoaded);
|
||||
await userEvent.type(name, ' Targaryen');
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Save Member Details' }),
|
||||
);
|
||||
if (args.memberUpdate === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText(/member details updated successfully/i),
|
||||
).toBeVisible(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteMember = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openMember(canvasElement, /jon snow/i);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Delete Member' }),
|
||||
);
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: 'Delete Member',
|
||||
});
|
||||
|
||||
await userEvent.click(
|
||||
within(dialog).getByRole('button', { name: 'Delete Member' }),
|
||||
);
|
||||
if (args.memberDelete === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/member deleted successfully/i)).toBeVisible(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const revokeInvite = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openPendingMember({ canvasElement });
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Revoke Invite' }),
|
||||
);
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: 'Revoke Invite',
|
||||
});
|
||||
|
||||
await userEvent.click(
|
||||
within(dialog).getByRole('button', { name: 'Revoke Invite' }),
|
||||
);
|
||||
if (args.memberDelete === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/invite revoked successfully/i)).toBeVisible(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const copyResetLink = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openResetLink(canvasElement);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: /^copy$/i }, untilLoaded),
|
||||
);
|
||||
await screen.findByText(/reset link copied to clipboard/i);
|
||||
};
|
||||
|
||||
export const copyInviteLink = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openPendingMember({ canvasElement });
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: /invite link/i }, untilLoaded),
|
||||
);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: /^copy$/i }, untilLoaded),
|
||||
);
|
||||
await screen.findByText(/invite link copied to clipboard/i);
|
||||
};
|
||||
|
||||
export const failResetLink = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openResetLink(canvasElement);
|
||||
if (args.resetLink === 'no-token') {
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText(/failed to generate password reset link/i),
|
||||
).toBeVisible(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,10 +1,20 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { singleSignOnMocks } from './SingleSignOn.stories.mocks';
|
||||
import {
|
||||
deleteDomain,
|
||||
expandAttributeMapping,
|
||||
expandRoleMappings,
|
||||
openAddDomain,
|
||||
openConfigureDomain,
|
||||
openDeleteDomain,
|
||||
openProviderSelector,
|
||||
submitEmptyProvider,
|
||||
updateDisplayName,
|
||||
} from './__story_mockdata__/singleSignOn.play';
|
||||
|
||||
import SettingsPage from '../../Settings';
|
||||
|
||||
@@ -30,23 +40,6 @@ export default meta;
|
||||
|
||||
type Story = StoryObj<SingleSignOnArgs>;
|
||||
|
||||
/** The table fetches before it renders a row, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
/** Row actions repeat per domain, so a story that opens one takes the first. */
|
||||
const clickFirst = async (
|
||||
canvasElement: HTMLElement,
|
||||
testId: string,
|
||||
): Promise<void> => {
|
||||
const [action] = await within(canvasElement).findAllByTestId(
|
||||
testId,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.click(action);
|
||||
};
|
||||
|
||||
/**
|
||||
* The org's display name and the email domains that sign in through an identity
|
||||
* provider, with whether each one is allowed to fall back to a password.
|
||||
@@ -75,88 +68,46 @@ export const SsoNotEnforced: Story = {
|
||||
|
||||
/** The form behind a domain's provider: what SigNoz sends the IdP and reads back. */
|
||||
export const ConfigureDomain: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await clickFirst(canvasElement, 'auth-domain-configure');
|
||||
await screen.findByText(/attribute mapping/i, undefined, untilLoaded);
|
||||
},
|
||||
play: openConfigureDomain,
|
||||
};
|
||||
|
||||
/** Adding a domain: the provider chosen first decides the rest of the form. */
|
||||
export const AddDomain: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'auth-domain-add',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
},
|
||||
play: openAddDomain,
|
||||
};
|
||||
|
||||
/** Interaction: adding a domain opens the real provider selector. */
|
||||
export const ProviderSelectorOpen: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'auth-domain-add',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByText(
|
||||
/configure authentication method/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
},
|
||||
play: openProviderSelector,
|
||||
};
|
||||
|
||||
/** Interaction: advanced role mappings expand from a configured identity provider. */
|
||||
export const RoleMappingsExpanded: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await clickFirst(canvasElement, 'auth-domain-configure');
|
||||
await userEvent.click(
|
||||
await screen.findByTestId('role-mapping-header', undefined, untilLoaded),
|
||||
);
|
||||
await screen.findByText(/group to role mappings/i, undefined, untilLoaded);
|
||||
},
|
||||
play: expandRoleMappings,
|
||||
};
|
||||
|
||||
/**
|
||||
* Interaction: the SAML attribute mapping open on a configured domain. Set
|
||||
* Identity provider to `oidc` for its claim mapping; Google has neither.
|
||||
*/
|
||||
export const AttributeMappingExpanded: Story = {
|
||||
play: expandAttributeMapping,
|
||||
};
|
||||
|
||||
/** Interaction: the OIDC claim mapping open on a configured domain. */
|
||||
export const ClaimMappingExpanded: Story = {
|
||||
args: { provider: 'oidc' },
|
||||
play: expandAttributeMapping,
|
||||
};
|
||||
|
||||
/** Validation: submitting a new provider without required fields leaves form errors visible. */
|
||||
export const CreateDomainValidation: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'auth-domain-add',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await userEvent.click(
|
||||
await screen.findByTestId(
|
||||
'authn-provider-configure-google',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await userEvent.click(
|
||||
await screen.findByTestId('auth-domain-save', undefined, untilLoaded),
|
||||
);
|
||||
await screen.findByText(/domain is required/i, undefined, untilLoaded);
|
||||
},
|
||||
play: submitEmptyProvider,
|
||||
};
|
||||
|
||||
/** What removing a domain warns about before its members lose SSO. */
|
||||
export const DeleteDomain: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await clickFirst(canvasElement, 'auth-domain-delete');
|
||||
await screen.findByTestId(
|
||||
'auth-domain-delete-confirm',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
},
|
||||
play: openDeleteDomain,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -164,22 +115,7 @@ export const DeleteDomain: Story = {
|
||||
* update to `error` or `loading` to see the modal instead.
|
||||
*/
|
||||
export const DisplayNameUpdatedToast: Story = {
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
const input = await within(canvasElement).findByLabelText(
|
||||
/display name/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, 'Night Watch');
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole('button', { name: /^submit$/i }),
|
||||
);
|
||||
if (args.displayNameUpdate === 'success') {
|
||||
await waitFor(() => expect(screen.getByText(/^success$/i)).toBeVisible());
|
||||
}
|
||||
},
|
||||
play: updateDisplayName,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -187,19 +123,5 @@ export const DisplayNameUpdatedToast: Story = {
|
||||
* `loading` to see the modal instead.
|
||||
*/
|
||||
export const DomainDeletedToast: Story = {
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await clickFirst(canvasElement, 'auth-domain-delete');
|
||||
await userEvent.click(
|
||||
await screen.findByTestId(
|
||||
'auth-domain-delete-confirm',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
if (args.domainDelete === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/domain deleted successfully/i)).toBeVisible(),
|
||||
);
|
||||
}
|
||||
},
|
||||
play: deleteDomain,
|
||||
};
|
||||
|
||||
161
frontend/src/pages/Settings/SingleSignOn/stories/__story_mockdata__/singleSignOn.play.ts
generated
Normal file
161
frontend/src/pages/Settings/SingleSignOn/stories/__story_mockdata__/singleSignOn.play.ts
generated
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import type { singleSignOnMocks } from '../SingleSignOn.stories.mocks';
|
||||
|
||||
interface PlayContext {
|
||||
canvasElement: HTMLElement;
|
||||
args: PageStoryArgs<typeof singleSignOnMocks>;
|
||||
}
|
||||
|
||||
/** The table fetches before it renders a row, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
/** Row actions repeat per domain, so a story that opens one takes the first. */
|
||||
const clickFirst = async (
|
||||
canvasElement: HTMLElement,
|
||||
testId: string,
|
||||
): Promise<void> => {
|
||||
const [action] = await within(canvasElement).findAllByTestId(
|
||||
testId,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.click(action);
|
||||
};
|
||||
|
||||
/** Google maps no IdP attributes, so only SAML and OIDC have this section. */
|
||||
const MAPPING_SECTION = {
|
||||
saml: { header: /attribute mapping \(advanced\)/i, field: 'email-attribute' },
|
||||
oidc: { header: /claim mapping \(advanced\)/i, field: 'email-claim' },
|
||||
google: null,
|
||||
} as const;
|
||||
|
||||
export const openConfigureDomain = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await clickFirst(canvasElement, 'auth-domain-configure');
|
||||
await screen.findByText(/role mapping \(advanced\)/i, undefined, untilLoaded);
|
||||
};
|
||||
|
||||
export const openAddDomain = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'auth-domain-add',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
export const openProviderSelector = async (
|
||||
context: PlayContext,
|
||||
): Promise<void> => {
|
||||
await openAddDomain(context);
|
||||
await screen.findByText(
|
||||
/configure authentication method/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
};
|
||||
|
||||
export const expandRoleMappings = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await clickFirst(canvasElement, 'auth-domain-configure');
|
||||
await userEvent.click(
|
||||
await screen.findByTestId('role-mapping-header', undefined, untilLoaded),
|
||||
);
|
||||
await screen.findByText(/group to role mappings/i, undefined, untilLoaded);
|
||||
};
|
||||
|
||||
export const expandAttributeMapping = async (
|
||||
context: PlayContext,
|
||||
): Promise<void> => {
|
||||
await openConfigureDomain(context);
|
||||
const section = MAPPING_SECTION[context.args.provider];
|
||||
if (!section) {
|
||||
return;
|
||||
}
|
||||
|
||||
await userEvent.click(screen.getByText(section.header));
|
||||
await waitFor(() =>
|
||||
expect(document.getElementById(section.field)).toBeVisible(),
|
||||
);
|
||||
};
|
||||
|
||||
export const submitEmptyProvider = async (
|
||||
context: PlayContext,
|
||||
): Promise<void> => {
|
||||
await openAddDomain(context);
|
||||
await userEvent.click(
|
||||
await screen.findByTestId(
|
||||
'authn-provider-configure-google',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await userEvent.click(
|
||||
await screen.findByTestId('auth-domain-save', undefined, untilLoaded),
|
||||
);
|
||||
await screen.findByText(/domain is required/i, undefined, untilLoaded);
|
||||
};
|
||||
|
||||
export const openDeleteDomain = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await clickFirst(canvasElement, 'auth-domain-delete');
|
||||
await screen.findByTestId(
|
||||
'auth-domain-delete-confirm',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
};
|
||||
|
||||
export const updateDisplayName = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
const input = await within(canvasElement).findByLabelText(
|
||||
/display name/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, 'Night Watch');
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole('button', { name: /^submit$/i }),
|
||||
);
|
||||
if (args.displayNameUpdate === 'success') {
|
||||
await waitFor(() => expect(screen.getByText(/^success$/i)).toBeVisible());
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteDomain = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await clickFirst(canvasElement, 'auth-domain-delete');
|
||||
await userEvent.click(
|
||||
await screen.findByTestId(
|
||||
'auth-domain-delete-confirm',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
if (args.domainDelete === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/domain deleted successfully/i)).toBeVisible(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -100,6 +100,10 @@ export const workspaceMocks = defineStoryMocks({
|
||||
)
|
||||
: res(ctx.status(204));
|
||||
}),
|
||||
|
||||
rest.post('http://localhost/api/v1/subscriptions', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: { redirectURL: '' } })),
|
||||
),
|
||||
],
|
||||
config: () => ({ route: ROUTES.SETTINGS }),
|
||||
});
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
copyLicenseKey,
|
||||
openAddCreditCardFromConflict,
|
||||
openCustomDomainMenu,
|
||||
updateCustomDomain,
|
||||
} from './__story_mockdata__/workspace.play';
|
||||
import { workspaceMocks } from './Workspace.stories.mocks';
|
||||
|
||||
import SettingsPage from '../../Settings';
|
||||
@@ -61,16 +66,7 @@ export const NoColdStorage: Story = {
|
||||
* of them is the active one.
|
||||
*/
|
||||
export const CustomDomainMenu: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'custom-domain-menu-trigger',
|
||||
{},
|
||||
{ timeout: 10000 },
|
||||
),
|
||||
);
|
||||
await screen.findByRole('menu');
|
||||
},
|
||||
play: openCustomDomainMenu,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -79,38 +75,21 @@ export const CustomDomainMenu: Story = {
|
||||
* instead.
|
||||
*/
|
||||
export const CustomDomainUpdatedToast: Story = {
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole(
|
||||
'button',
|
||||
{ name: /edit workspace link/i },
|
||||
{ timeout: 10000 },
|
||||
),
|
||||
);
|
||||
const input = await screen.findByRole('textbox');
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, 'watchers');
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: /apply changes/i }),
|
||||
);
|
||||
if (args.domainUpdate === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/workspace url is being updated/i)).toBeVisible(),
|
||||
);
|
||||
}
|
||||
},
|
||||
play: updateCustomDomain,
|
||||
};
|
||||
|
||||
/**
|
||||
* A workspace link change the instance refuses with a 409: the edit modal swaps
|
||||
* Apply Changes for Contact Support, which on a cloud trial without a card opens
|
||||
* the Add Credit Card modal.
|
||||
*/
|
||||
export const CustomDomainConflictAddCard: Story = {
|
||||
args: { domainUpdate: 'error', support: 'add-card' },
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: openAddCreditCardFromConflict,
|
||||
};
|
||||
|
||||
/** The license key copied: the toast confirming it. */
|
||||
export const LicenseKeyCopiedToast: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'license-key-row-copy-btn',
|
||||
{},
|
||||
{ timeout: 10000 },
|
||||
),
|
||||
);
|
||||
await screen.findByText(/license key copied to clipboard/i);
|
||||
},
|
||||
play: copyLicenseKey,
|
||||
};
|
||||
|
||||
109
frontend/src/pages/Settings/Workspace/stories/__story_mockdata__/workspace.play.ts
generated
Normal file
109
frontend/src/pages/Settings/Workspace/stories/__story_mockdata__/workspace.play.ts
generated
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { accessFor } from '@/storybook/access/access';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import type { workspaceMocks } from '../Workspace.stories.mocks';
|
||||
|
||||
interface PlayContext {
|
||||
canvasElement: HTMLElement;
|
||||
args: PageStoryArgs<typeof workspaceMocks>;
|
||||
}
|
||||
|
||||
const untilLoaded = { timeout: 10000 };
|
||||
|
||||
/** The domain card only renders for an admin on cloud. */
|
||||
const canEditDomain = ({ args }: Pick<PlayContext, 'args'>): boolean =>
|
||||
args.license === 'cloud' &&
|
||||
accessFor(args.access, args.permissions, args.revoked).legacyRole ===
|
||||
USER_ROLES.ADMIN;
|
||||
|
||||
const submitNewSubdomain = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole(
|
||||
'button',
|
||||
{ name: /edit workspace link/i },
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
const input = await screen.findByRole('textbox');
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, 'watchers');
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: /apply changes/i }),
|
||||
);
|
||||
};
|
||||
|
||||
export const openCustomDomainMenu = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'custom-domain-menu-trigger',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByRole('menu');
|
||||
};
|
||||
|
||||
export const updateCustomDomain = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await submitNewSubdomain(canvasElement);
|
||||
if (args.domainUpdate === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/workspace url is being updated/i)).toBeVisible(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const copyLicenseKey = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'license-key-row-copy-btn',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByText(/license key copied to clipboard/i);
|
||||
};
|
||||
|
||||
export const openAddCreditCardFromConflict = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!canEditDomain({ args })) {
|
||||
return;
|
||||
}
|
||||
|
||||
await submitNewSubdomain(canvasElement);
|
||||
|
||||
if (args.domainUpdate !== 'error') {
|
||||
return;
|
||||
}
|
||||
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: /contact support/i }, untilLoaded),
|
||||
);
|
||||
|
||||
// Only a plan without premium support is offered the modal over the chat widget.
|
||||
if (args.support === 'add-card') {
|
||||
await screen.findByRole(
|
||||
'dialog',
|
||||
{ name: /Add Credit Card for Chat Support/i },
|
||||
untilLoaded,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
timeSeriesPoints,
|
||||
} from '@/storybook/msw/__story_mockdata__/queryRange';
|
||||
|
||||
import { traceFunnelsResponse } from '../../TracesModulePage/stories/__story_mockdata__/traces';
|
||||
import { TraceDetailsTab } from '../constants';
|
||||
import { SpanDetailVariant } from '../SpanDetailsPanel/constants';
|
||||
import {
|
||||
focusSpanIndex,
|
||||
@@ -46,6 +48,7 @@ import {
|
||||
traceDetailFieldValues,
|
||||
traceFlamegraphResponse,
|
||||
traceSummaryResponse,
|
||||
traceThreadResponse,
|
||||
TRACE_SCENARIOS,
|
||||
type TraceScenario,
|
||||
traceWaterfallResponse,
|
||||
@@ -60,6 +63,8 @@ const EXPORT_STATES = ['success', 'loading', 'error'] as const;
|
||||
|
||||
type ExportState = (typeof EXPORT_STATES)[number];
|
||||
|
||||
const TABS = [TraceDetailsTab.Overview, TraceDetailsTab.Thread] as const;
|
||||
|
||||
const PANEL_POSITIONS = [
|
||||
SpanDetailVariant.DOCKED_RIGHT,
|
||||
SpanDetailVariant.DOCKED,
|
||||
@@ -125,6 +130,13 @@ export const traceDetailsMocks = defineStoryMocks({
|
||||
'Marks the payment and shipping spans as errored, each with an exception event, which is what the error counts and the red bars read.',
|
||||
value: true,
|
||||
}),
|
||||
tab: choiceControl<TraceDetailsTab>('Tab', {
|
||||
group: TRACE,
|
||||
description:
|
||||
'The tab the page opens on. `thread` lists the LLM calls as a conversation, and shows its no-AI-spans state outside the `ai` scenario.',
|
||||
options: TABS,
|
||||
value: TraceDetailsTab.Overview,
|
||||
}),
|
||||
missingSpans: toggleControl('Missing spans', {
|
||||
group: TRACE,
|
||||
description:
|
||||
@@ -207,6 +219,11 @@ export const traceDetailsMocks = defineStoryMocks({
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/traces/:traceId/thread',
|
||||
response.json(() => traceThreadResponse(trace)),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v3/traces/:traceId/flamegraph',
|
||||
response.json(() => traceFlamegraphResponse(trace)),
|
||||
@@ -221,6 +238,11 @@ export const traceDetailsMocks = defineStoryMocks({
|
||||
}),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/trace-funnels/list',
|
||||
response.json(() => traceFunnelsResponse(3)),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v1/span_percentile',
|
||||
response.json(spanPercentilesResponse),
|
||||
@@ -306,11 +328,28 @@ export const traceDetailsMocks = defineStoryMocks({
|
||||
),
|
||||
];
|
||||
},
|
||||
config: (values) => ({
|
||||
route: values.spanPanel
|
||||
? `/trace/${STORY_TRACE_ID}?spanId=${spanIdAt(focusSpanIndex(values.scenario, values.spans))}`
|
||||
: `/trace/${STORY_TRACE_ID}`,
|
||||
}),
|
||||
config: (values) => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (values.tab !== TraceDetailsTab.Overview) {
|
||||
params.set('tab', values.tab);
|
||||
}
|
||||
|
||||
if (values.spanPanel) {
|
||||
params.set(
|
||||
'spanId',
|
||||
spanIdAt(focusSpanIndex(values.scenario, values.spans)),
|
||||
);
|
||||
}
|
||||
|
||||
const search = params.toString();
|
||||
|
||||
return {
|
||||
route: search
|
||||
? `/trace/${STORY_TRACE_ID}?${search}`
|
||||
: `/trace/${STORY_TRACE_ID}`,
|
||||
};
|
||||
},
|
||||
effect: (values) => {
|
||||
set(LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION, values.panelPosition);
|
||||
},
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
traceDetailsMocks,
|
||||
untilLoaded,
|
||||
} from './TraceDetailsV3.stories.mocks';
|
||||
import { TraceDetailsTab } from '../constants';
|
||||
import TraceDetailsV3 from '../index';
|
||||
|
||||
type TraceDetailsArgs = PageStoryArgs<typeof traceDetailsMocks>;
|
||||
@@ -119,6 +120,22 @@ export const AiTrace: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The same agent run on the Thread tab: each LLM call's messages in order, with
|
||||
* the tool call and its response, the model's reasoning folded into a thinking
|
||||
* block, and the span panel open on the Claude call.
|
||||
*/
|
||||
export const AiThread: Story = {
|
||||
args: { scenario: 'ai', spans: AI_SPANS, tab: TraceDetailsTab.Thread },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await within(canvasElement).findAllByText(
|
||||
/Let me check the upgrade notes/,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Turns on Highlight errors, which writes `has_error = true` into the filter and
|
||||
* expands the bar to show it.
|
||||
@@ -248,6 +265,60 @@ export const SpanPercentileOpen: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** The analytics panel the header toggle opens. */
|
||||
export const AnalyticsOpen: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole(
|
||||
'button',
|
||||
{ name: 'Analytics' },
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByTestId('trace-analytics-panel', undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/** The download progress panel, held open by an export that never answers. */
|
||||
export const TraceDownload: Story = {
|
||||
args: { exportState: 'loading' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await startDownload(canvasElement);
|
||||
await screen.findByTestId('trace-download-panel', undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
const openAddSpanToFunnel = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<void> => {
|
||||
const [button] = await within(canvasElement).findAllByTestId(
|
||||
'span-add-to-funnel-btn',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await fireEvent.click(button);
|
||||
await screen.findByText('Add span to funnel', undefined, untilLoaded);
|
||||
};
|
||||
|
||||
/** The funnel picker a span row's add-to-funnel action opens. */
|
||||
export const AddSpanToFunnel: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openAddSpanToFunnel(canvasElement);
|
||||
},
|
||||
};
|
||||
|
||||
/** The name field behind "Create new funnel" in that picker. */
|
||||
export const AddSpanToFunnelCreateStep: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openAddSpanToFunnel(canvasElement);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: /create new funnel/i }),
|
||||
);
|
||||
await screen.findByTestId('create-funnel-name-input');
|
||||
},
|
||||
};
|
||||
|
||||
/** The span has no logs of its own but its trace does, so the Logs tab points to the trace's. */
|
||||
export const SpanPanelTraceOnlyLogs: Story = {
|
||||
args: { traceOnlyLogs: true },
|
||||
|
||||
39
frontend/src/pages/TraceDetailsV3/stories/__story_mockdata__/traceDetails.play.ts
generated
Normal file
39
frontend/src/pages/TraceDetailsV3/stories/__story_mockdata__/traceDetails.play.ts
generated
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { screen } from 'storybook/test';
|
||||
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { TraceDetailsTab } from '../../constants';
|
||||
import {
|
||||
type traceDetailsMocks,
|
||||
untilLoaded,
|
||||
} from '../TraceDetailsV3.stories.mocks';
|
||||
|
||||
type TraceDetailsArgs = PageStoryArgs<typeof traceDetailsMocks>;
|
||||
|
||||
interface PlayContext {
|
||||
args: Pick<TraceDetailsArgs, 'missingSpans' | 'tab' | 'dataState'>;
|
||||
}
|
||||
|
||||
/** The header reads the flag off the summary, and only the overview tab shows it. */
|
||||
export const showsMissingSpansBanner = async ({
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (
|
||||
!args.missingSpans ||
|
||||
args.tab !== TraceDetailsTab.Overview ||
|
||||
args.dataState !== 'loaded'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await screen.findByText(
|
||||
/this trace has missing spans/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
};
|
||||
@@ -4,12 +4,16 @@
|
||||
*/
|
||||
|
||||
import type {
|
||||
AiobservabilitytypesInputMessagesDTO,
|
||||
AiobservabilitytypesOutputMessagesDTO,
|
||||
GetFlamegraph200,
|
||||
GetTraceAggregations200,
|
||||
GetTraceSummary200,
|
||||
GetTraceThread200,
|
||||
GetWaterfallV4200,
|
||||
SpantypesFlamegraphSpanDTO,
|
||||
SpantypesSpanAggregationDTO,
|
||||
SpantypesThreadSpanDTO,
|
||||
SpantypesWaterfallSpanDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { SpantypesSpanAggregationTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
@@ -28,8 +32,26 @@ interface SpanTemplate {
|
||||
/** `response_status_code`; failing spans default to 503, the rest to 200. */
|
||||
statusCode?: string;
|
||||
attributes: Record<string, string | number | boolean>;
|
||||
/** The LLM call's messages. Only spans that carry them are in the thread. */
|
||||
messages?: {
|
||||
input: AiobservabilitytypesInputMessagesDTO;
|
||||
output: AiobservabilitytypesOutputMessagesDTO;
|
||||
};
|
||||
}
|
||||
|
||||
const SUPPORT_SYSTEM_PROMPT =
|
||||
'You are the SigNoz support agent. Answer from the docs, cite the page you used, and open a ticket only when the docs do not cover the problem.';
|
||||
|
||||
const SUPPORT_QUESTION =
|
||||
'My checkout traces stopped showing up after I upgraded the collector to 0.110. What changed?';
|
||||
|
||||
const SEARCH_DOCS_CALL = {
|
||||
type: 'tool_call',
|
||||
id: 'call_search_docs_1',
|
||||
name: 'search_docs',
|
||||
arguments: { query: 'otel collector 0.110 breaking changes traces', limit: 3 },
|
||||
};
|
||||
|
||||
/** Walked in order, so the tree keeps the same services at the same depths. */
|
||||
const SPAN_TEMPLATES: SpanTemplate[] = [
|
||||
{
|
||||
@@ -182,6 +204,28 @@ const AI_TEMPLATES: SpanTemplate[] = [
|
||||
'gen_ai.usage.cache_creation.input_tokens': 0,
|
||||
'signoz.gen_ai.usage.tokens.cost': 0.00566,
|
||||
},
|
||||
messages: {
|
||||
input: [
|
||||
{
|
||||
role: 'system',
|
||||
parts: [{ type: 'text', content: SUPPORT_SYSTEM_PROMPT }],
|
||||
},
|
||||
{ role: 'user', parts: [{ type: 'text', content: SUPPORT_QUESTION }] },
|
||||
],
|
||||
output: [
|
||||
{
|
||||
role: 'assistant',
|
||||
finish_reason: 'tool_call',
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
content: 'Let me check the upgrade notes for that release.',
|
||||
},
|
||||
SEARCH_DOCS_CALL,
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
service: 'support-agent',
|
||||
@@ -202,6 +246,52 @@ const AI_TEMPLATES: SpanTemplate[] = [
|
||||
'gen_ai.usage.cache_creation.input_tokens': 1300,
|
||||
'signoz.gen_ai.usage.tokens.cost': 0.01853,
|
||||
},
|
||||
messages: {
|
||||
input: [
|
||||
{
|
||||
role: 'system',
|
||||
parts: [{ type: 'text', content: SUPPORT_SYSTEM_PROMPT }],
|
||||
},
|
||||
{ role: 'user', parts: [{ type: 'text', content: SUPPORT_QUESTION }] },
|
||||
{ role: 'assistant', parts: [SEARCH_DOCS_CALL] },
|
||||
{
|
||||
role: 'tool',
|
||||
parts: [
|
||||
{
|
||||
type: 'tool_call_response',
|
||||
id: 'call_search_docs_1',
|
||||
response: {
|
||||
results: [
|
||||
{
|
||||
title: 'Collector 0.110 upgrade notes',
|
||||
snippet:
|
||||
'The otlp exporter now sends over gRPC on 4317 by default. Pipelines that pointed it at the HTTP port 4318 must set the endpoint explicitly.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
output: [
|
||||
{
|
||||
role: 'assistant',
|
||||
finish_reason: 'stop',
|
||||
parts: [
|
||||
{
|
||||
type: 'reasoning',
|
||||
content:
|
||||
'The traces stopped right after the upgrade, and the notes say the otlp exporter changed its default transport. If their exporter still points at 4318, every export now fails against a port that expects HTTP. That fits better than a sampling change, which would thin the traces out rather than stop them.',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
content:
|
||||
'Collector 0.110 changed the default transport of the `otlp` exporter to gRPC on port **4317**. If your exporter still points at `:4318`, every export fails and no traces arrive.\n\nSet the endpoint explicitly:\n\n```yaml\nexporters:\n otlp:\n endpoint: ingest.example.signoz.cloud:4317\n```\n\nSource: Collector 0.110 upgrade notes.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
service: 'docs-api',
|
||||
@@ -247,6 +337,39 @@ const AI_TEMPLATES: SpanTemplate[] = [
|
||||
'gen_ai.usage.reasoning.output_tokens': 1152,
|
||||
'signoz.gen_ai.usage.tokens.cost': 0.007524,
|
||||
},
|
||||
messages: {
|
||||
input: [
|
||||
{
|
||||
role: 'system',
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
content:
|
||||
'You are the triage agent. Write a one-line summary and a priority for the support queue.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'user', parts: [{ type: 'text', content: SUPPORT_QUESTION }] },
|
||||
],
|
||||
output: [
|
||||
{
|
||||
role: 'assistant',
|
||||
finish_reason: 'stop',
|
||||
parts: [
|
||||
{
|
||||
type: 'reasoning',
|
||||
content:
|
||||
'No data loss beyond the gap, and the fix is a config change the docs already describe, so this is not urgent.',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
content:
|
||||
'Summary: traces stopped after a collector upgrade to 0.110, caused by the otlp exporter default port. Priority: P3.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -426,6 +549,43 @@ const waterfallSpan = (span: BuiltSpan): SpantypesWaterfallSpanDTO => ({
|
||||
trace_state: '',
|
||||
});
|
||||
|
||||
const threadSpan = (span: BuiltSpan): SpantypesThreadSpanDTO => ({
|
||||
span_id: span.spanId,
|
||||
trace_id: STORY_TRACE_ID,
|
||||
parent_span_id: span.parentSpanId,
|
||||
time_unix: Math.round(span.startMs),
|
||||
duration_nano: Math.round(span.durationMs * 1_000_000),
|
||||
name: span.template.name,
|
||||
kind_string: span.template.kind,
|
||||
has_error: span.hasError,
|
||||
status_code_string: span.hasError ? 'Error' : 'Ok',
|
||||
status_message: span.hasError ? 'upstream unavailable' : '',
|
||||
attributes: span.template.attributes,
|
||||
resource: resourceOf(span),
|
||||
events: eventsOf(span) ?? [],
|
||||
references: [],
|
||||
formattedInput: span.template.messages?.input ?? [],
|
||||
formattedOutput: span.template.messages?.output ?? [],
|
||||
formatterWarnings: [],
|
||||
});
|
||||
|
||||
/**
|
||||
* The spans with messages, in start order, on one page: no cursors, so the
|
||||
* list never asks for another. A scenario with no LLM calls answers empty,
|
||||
* which is the tab's no-AI-spans state.
|
||||
*/
|
||||
export const traceThreadResponse = (
|
||||
options: TraceOptions,
|
||||
): GetTraceThread200 => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
spans: buildSpans(options)
|
||||
.filter(({ template }) => template.messages)
|
||||
.sort((a, b) => a.startMs - b.startMs)
|
||||
.map(threadSpan),
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* `totalSpansCount` equals what the response carries, which is what puts the
|
||||
* waterfall in its all-loaded mode: every parent expands and no further window
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
import { fieldValuesResponse } from '@/storybook/msw/__story_mockdata__/fields';
|
||||
import {
|
||||
queryRangeV5RawResponse,
|
||||
@@ -354,3 +355,5 @@ export const tracesMocks = defineStoryMocks({
|
||||
],
|
||||
config: (values) => ({ route: tracesRoute(values) }),
|
||||
});
|
||||
|
||||
export type TracesArgs = PageStoryArgs<typeof tracesMocks>;
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import {
|
||||
findSuggestion,
|
||||
openKeySuggestions,
|
||||
showFilterErrors,
|
||||
typeFilter,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { tracesMocks } from './TracesModulePage.stories.mocks';
|
||||
import { type TracesArgs, tracesMocks } from './TracesModulePage.stories.mocks';
|
||||
import {
|
||||
dirtyQuickFiltersSettings,
|
||||
loadNextListPage,
|
||||
openCreateFunnel,
|
||||
openQuickFiltersSettings,
|
||||
openRenameFunnel,
|
||||
showRootSpansNextPage,
|
||||
suggestBooleanValues,
|
||||
suggestFilterKeys,
|
||||
suggestFilterValues,
|
||||
suggestResourceKeys,
|
||||
showFilterSyntaxError,
|
||||
} from './__story_mockdata__/traces.play';
|
||||
import TracesModulePage from '../TracesModulePage';
|
||||
|
||||
type TracesArgs = PageStoryArgs<typeof tracesMocks>;
|
||||
|
||||
const pageStory = storyMocks(tracesMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
@@ -34,28 +37,8 @@ const meta = {
|
||||
|
||||
export default meta;
|
||||
|
||||
/** The page fetches before it renders its filters, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
type Story = StoryObj<TracesArgs>;
|
||||
|
||||
const openQuickFiltersSettings = async (): Promise<void> => {
|
||||
// The settings control renders disabled while its permission check is in
|
||||
// flight and is swapped for the enabled one once the check answers, so it is
|
||||
// looked up again on every attempt; a click on the disabled one is dropped in
|
||||
// silence.
|
||||
const control = await waitFor(() => {
|
||||
const settings = screen.getByTestId('settings-icon-container');
|
||||
|
||||
expect(settings).not.toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
return settings;
|
||||
}, untilLoaded);
|
||||
|
||||
await userEvent.click(control);
|
||||
await screen.findByText('Edit quick filters');
|
||||
};
|
||||
|
||||
/**
|
||||
* The Explorer tab on a full window of spans: the org's quick filters down the
|
||||
* left, the query builder and the view switcher above, and the span list with
|
||||
@@ -77,35 +60,13 @@ export const RootSpans: Story = {
|
||||
*/
|
||||
export const RootSpansNextPage: Story = {
|
||||
args: { view: ExplorerViews.TRACE, spans: 50 },
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Go to next page' }, untilLoaded),
|
||||
);
|
||||
const pager = screen.getByLabelText('pagination');
|
||||
|
||||
await expect(
|
||||
await within(pager).findByText('3', undefined, untilLoaded),
|
||||
).toBeVisible();
|
||||
},
|
||||
play: showRootSpansNextPage,
|
||||
};
|
||||
|
||||
/** The span list scrolled to its end, with the next 100 spans loaded under the first. */
|
||||
export const ListNextPage: Story = {
|
||||
args: { spans: 150 },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
// Scrolls again on every attempt, since each loaded page moves the end.
|
||||
await waitFor(async (): Promise<void> => {
|
||||
const scroller = canvasElement.querySelector<HTMLElement>(
|
||||
'[data-virtuoso-scroller]',
|
||||
);
|
||||
|
||||
await expect(scroller).not.toBeNull();
|
||||
(scroller as HTMLElement).scrollTop = (scroller as HTMLElement).scrollHeight;
|
||||
await expect(
|
||||
canvasElement.querySelector('tr[data-index="120"]'),
|
||||
).not.toBeNull();
|
||||
}, untilLoaded);
|
||||
},
|
||||
play: loadNextListPage,
|
||||
};
|
||||
|
||||
/** The charted view of the same trace query. */
|
||||
@@ -123,6 +84,18 @@ export const Funnels: Story = {
|
||||
args: { tab: 'funnels' },
|
||||
};
|
||||
|
||||
/** The name field for a funnel that does not exist yet. */
|
||||
export const CreateFunnel: Story = {
|
||||
args: { tab: 'funnels' },
|
||||
play: openCreateFunnel,
|
||||
};
|
||||
|
||||
/** The rename field for a funnel already on the list. */
|
||||
export const RenameFunnel: Story = {
|
||||
args: { tab: 'funnels' },
|
||||
play: openRenameFunnel,
|
||||
};
|
||||
|
||||
/**
|
||||
* A fresh workspace: nothing ingested in the window and no quick filters
|
||||
* configured yet, so the table and the filter panel both show their empty
|
||||
@@ -154,67 +127,32 @@ 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,
|
||||
};
|
||||
|
||||
/**
|
||||
* The same panel with a banner above the shell. The banner takes 48px off the
|
||||
* layout, so this is the case where the footer used to be pushed off screen:
|
||||
* the panel is sized from the filters pane rather than the viewport, which
|
||||
* keeps Save changes reachable.
|
||||
*/
|
||||
export const QuickFiltersSettingsWithBanner: Story = {
|
||||
args: { banner: 'trial-expiry' },
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
/** The filter focused before anything is typed: span and resource keys together. */
|
||||
export const FilterKeySuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openKeySuggestions(canvasElement, 'status_code_string');
|
||||
},
|
||||
play: suggestFilterKeys,
|
||||
};
|
||||
|
||||
/** A context prefix: only the keys that live on the resource. */
|
||||
export const FilterResourceKeys: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'resource.');
|
||||
await findSuggestion(canvasElement, 'resource.service.name');
|
||||
},
|
||||
play: suggestResourceKeys,
|
||||
};
|
||||
|
||||
/** A key and an operator: the services the spans came from. */
|
||||
export const FilterValueSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'service.name = ');
|
||||
await findSuggestion(canvasElement, 'checkout');
|
||||
},
|
||||
play: suggestFilterValues,
|
||||
};
|
||||
|
||||
/** A boolean key offers its two values. */
|
||||
export const FilterBooleanValues: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'has_error = ');
|
||||
await findSuggestion(canvasElement, 'false');
|
||||
},
|
||||
play: suggestBooleanValues,
|
||||
};
|
||||
|
||||
/** A dangling conjunction after focus left: the marker and its errors. */
|
||||
export const FilterSyntaxError: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await showFilterErrors(canvasElement, 'has_error = true AND');
|
||||
},
|
||||
play: showFilterSyntaxError,
|
||||
};
|
||||
|
||||
153
frontend/src/pages/TracesModulePage/stories/__story_mockdata__/spanDrawer.play.ts
generated
Normal file
153
frontend/src/pages/TracesModulePage/stories/__story_mockdata__/spanDrawer.play.ts
generated
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
interface MountContext {
|
||||
mount: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
/** The page fetches before it renders its rows, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
// The quick filters list the same span name, so the row is found by its test
|
||||
// id; the table renders its rows a few at a time, so it waits for this one.
|
||||
const findOrdersRow = (): Promise<HTMLElement> =>
|
||||
waitFor(async (): Promise<HTMLElement> => {
|
||||
const match = screen
|
||||
.getAllByTestId(/^traces-table-row-/)
|
||||
.find((candidate) => candidate.textContent?.includes('POST /api/orders'));
|
||||
|
||||
await expect(match).toBeDefined();
|
||||
|
||||
return match as HTMLElement;
|
||||
}, untilLoaded);
|
||||
|
||||
const openDrawer = async (): Promise<HTMLElement> => {
|
||||
const row = await findOrdersRow();
|
||||
|
||||
await userEvent.click(within(row).getByText('POST /api/orders'));
|
||||
|
||||
return screen.findByTestId('span-details-drawer');
|
||||
};
|
||||
|
||||
const openLogsTab = async (): Promise<HTMLElement> => {
|
||||
const drawer = await openDrawer();
|
||||
|
||||
await userEvent.click(within(drawer).getByRole('tab', { name: /Logs/ }));
|
||||
|
||||
return drawer;
|
||||
};
|
||||
|
||||
export const showsSpanDetails = async (): Promise<void> => {
|
||||
const drawer = await openDrawer();
|
||||
|
||||
await expect(
|
||||
await within(drawer).findByText(/of total exec time/, undefined, untilLoaded),
|
||||
).toBeVisible();
|
||||
};
|
||||
|
||||
export const openSpanDetails = async ({
|
||||
mount,
|
||||
}: MountContext): Promise<void> => {
|
||||
await mount();
|
||||
await showsSpanDetails();
|
||||
};
|
||||
|
||||
export const hoverOrdersRow = async ({
|
||||
mount,
|
||||
}: MountContext): Promise<void> => {
|
||||
await mount();
|
||||
await userEvent.hover(await findOrdersRow());
|
||||
|
||||
await expect(await screen.findByTestId('span-row-open-trace')).toBeVisible();
|
||||
};
|
||||
|
||||
export const showsSpanLogs = async ({ mount }: MountContext): Promise<void> => {
|
||||
await mount();
|
||||
const drawer = await openLogsTab();
|
||||
|
||||
await expect(
|
||||
(
|
||||
await within(drawer).findAllByText(
|
||||
/payment provider timed out/,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
)
|
||||
)[0],
|
||||
).toBeVisible();
|
||||
};
|
||||
|
||||
export const showsTraceOnlyLogs = async ({
|
||||
mount,
|
||||
}: MountContext): Promise<void> => {
|
||||
await mount();
|
||||
const drawer = await openLogsTab();
|
||||
|
||||
await expect(
|
||||
await within(drawer).findByText(
|
||||
'No logs found for selected span. View logs for the current trace.',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
).toBeVisible();
|
||||
};
|
||||
|
||||
export const showsNoLogs = async ({ mount }: MountContext): Promise<void> => {
|
||||
await mount();
|
||||
const drawer = await openLogsTab();
|
||||
|
||||
await expect(
|
||||
await within(drawer).findByText(
|
||||
'No logs found for this trace.',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
).toBeVisible();
|
||||
};
|
||||
|
||||
export const showsWithoutTraceSummary = async ({
|
||||
mount,
|
||||
}: MountContext): Promise<void> => {
|
||||
await mount();
|
||||
const drawer = await openLogsTab();
|
||||
|
||||
await expect(
|
||||
await within(drawer).findByText(
|
||||
'No logs found for this span.',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
within(drawer).queryByText(/of total exec time/),
|
||||
).not.toBeInTheDocument();
|
||||
};
|
||||
|
||||
export const showsTraceSummaryLoading = async ({
|
||||
mount,
|
||||
}: MountContext): Promise<void> => {
|
||||
await mount();
|
||||
await openLogsTab();
|
||||
|
||||
await expect(
|
||||
await screen.findByTestId('open-in-explorer-button'),
|
||||
).toHaveAttribute('aria-disabled', 'true');
|
||||
};
|
||||
|
||||
export const showsLogsError = async ({
|
||||
mount,
|
||||
}: MountContext): Promise<void> => {
|
||||
await mount();
|
||||
const drawer = await openLogsTab();
|
||||
|
||||
await expect(
|
||||
await within(drawer).findByText(
|
||||
/Something went wrong/,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
).toBeVisible();
|
||||
};
|
||||
235
frontend/src/pages/TracesModulePage/stories/__story_mockdata__/traces.play.ts
generated
Normal file
235
frontend/src/pages/TracesModulePage/stories/__story_mockdata__/traces.play.ts
generated
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import {
|
||||
findSuggestion,
|
||||
openKeySuggestions,
|
||||
showFilterErrors,
|
||||
typeFilter,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
|
||||
import { QuickFilterManagePermissions } from 'lib/authz/hooks/useAuthZ/permissions/quick-filter.permissions';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { grantsAll } from '@/storybook/access/access';
|
||||
|
||||
import type { TracesArgs } from '../TracesModulePage.stories.mocks';
|
||||
|
||||
interface PlayContext {
|
||||
canvasElement: HTMLElement;
|
||||
args: TracesArgs;
|
||||
}
|
||||
|
||||
/** The page fetches before it renders its filters, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
const LIST_PAGE_SIZE = 100;
|
||||
|
||||
const TRACE_PAGE_SIZE = 20;
|
||||
|
||||
const showsFilter = (args: TracesArgs): boolean =>
|
||||
args.dataState === 'loaded' && args.tab !== 'funnels';
|
||||
|
||||
const showsView = (args: TracesArgs, view: ExplorerViews): boolean =>
|
||||
args.tab === 'explorer' && args.view === view && args.dataState === 'loaded';
|
||||
|
||||
/** The settings control stays enabled when the check itself fails. */
|
||||
const canManageQuickFilters = (args: TracesArgs): boolean =>
|
||||
args.tab === 'explorer' &&
|
||||
(args.authzState === 'error' ||
|
||||
grantsAll(args, ...QuickFilterManagePermissions));
|
||||
|
||||
export const showRootSpansNextPage = async ({
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
// The pager offers page 3 once page 2 comes back full.
|
||||
if (
|
||||
!showsView(args, ExplorerViews.TRACE) ||
|
||||
args.spans < 2 * TRACE_PAGE_SIZE
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Go to next page' }, untilLoaded),
|
||||
);
|
||||
const pager = screen.getByLabelText('pagination');
|
||||
|
||||
await expect(
|
||||
await within(pager).findByText('3', undefined, untilLoaded),
|
||||
).toBeVisible();
|
||||
};
|
||||
|
||||
export const loadNextListPage = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!showsView(args, ExplorerViews.LIST) || args.spans <= LIST_PAGE_SIZE) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Scrolls again on every attempt, since each loaded page moves the end.
|
||||
await waitFor(async (): Promise<void> => {
|
||||
const scroller = canvasElement.querySelector<HTMLElement>(
|
||||
'[data-virtuoso-scroller]',
|
||||
);
|
||||
|
||||
await expect(scroller).not.toBeNull();
|
||||
(scroller as HTMLElement).scrollTop = (scroller as HTMLElement).scrollHeight;
|
||||
const indexes = Array.from(
|
||||
canvasElement.querySelectorAll<HTMLElement>('tr[data-index]'),
|
||||
(row) => Number(row.dataset.index),
|
||||
);
|
||||
|
||||
await expect(Math.max(...indexes)).toBeGreaterThanOrEqual(LIST_PAGE_SIZE);
|
||||
}, untilLoaded);
|
||||
};
|
||||
|
||||
export const openQuickFiltersSettings = async ({
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!canManageQuickFilters(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The settings control renders disabled while its permission check is in
|
||||
// flight and is swapped for the enabled one once the check answers, so it is
|
||||
// looked up again on every attempt; a click on the disabled one is dropped in
|
||||
// silence.
|
||||
const control = await waitFor(async () => {
|
||||
const settings = screen.getByTestId('settings-icon-container');
|
||||
|
||||
await expect(settings).not.toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
return settings;
|
||||
}, untilLoaded);
|
||||
|
||||
await userEvent.click(control);
|
||||
await screen.findByText('Edit quick filters');
|
||||
};
|
||||
|
||||
export const dirtyQuickFiltersSettings = async (
|
||||
context: PlayContext,
|
||||
): Promise<void> => {
|
||||
const { args } = context;
|
||||
if (
|
||||
!canManageQuickFilters(args) ||
|
||||
args.dataState !== 'loaded' ||
|
||||
args.quickFilters === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openQuickFiltersSettings(context);
|
||||
// One Remove per added filter; the first row's is the one clicked.
|
||||
const [removeFilter] = await screen.findAllByRole('button', {
|
||||
name: 'Remove',
|
||||
});
|
||||
|
||||
await userEvent.click(removeFilter);
|
||||
await screen.findByRole('button', { name: 'Save changes' });
|
||||
};
|
||||
|
||||
export const suggestFilterKeys = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!showsFilter(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openKeySuggestions(canvasElement, 'status_code_string');
|
||||
};
|
||||
|
||||
export const suggestResourceKeys = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!showsFilter(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await typeFilter(canvasElement, 'resource.');
|
||||
await findSuggestion(canvasElement, 'resource.service.name');
|
||||
};
|
||||
|
||||
export const suggestFilterValues = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!showsFilter(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await typeFilter(canvasElement, 'service.name = ');
|
||||
await findSuggestion(canvasElement, 'checkout');
|
||||
};
|
||||
|
||||
export const suggestBooleanValues = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!showsFilter(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await typeFilter(canvasElement, 'has_error = ');
|
||||
await findSuggestion(canvasElement, 'false');
|
||||
};
|
||||
|
||||
export const showFilterSyntaxError = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!showsFilter(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await showFilterErrors(canvasElement, 'has_error = true AND');
|
||||
};
|
||||
|
||||
export const openCreateFunnel = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (args.tab !== 'funnels') {
|
||||
return;
|
||||
}
|
||||
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole(
|
||||
'button',
|
||||
{ name: /new funnel/i },
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByRole('dialog', { name: /create new funnel/i });
|
||||
};
|
||||
|
||||
/** Row actions exist only once the list holds a funnel. */
|
||||
export const openRenameFunnel = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (args.tab !== 'funnels' || args.funnels === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const action = await waitFor(() => {
|
||||
const found = canvasElement.querySelector<HTMLElement>(
|
||||
'.funnel-item__action-icon',
|
||||
);
|
||||
|
||||
if (!found) {
|
||||
throw new Error('Funnel row actions are not rendered');
|
||||
}
|
||||
|
||||
return found;
|
||||
}, untilLoaded);
|
||||
|
||||
await userEvent.click(action);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Rename' }));
|
||||
await screen.findByRole('dialog', { name: /rename funnel/i });
|
||||
};
|
||||
@@ -1,10 +1,18 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { tracesMocks } from '../TracesModulePage.stories.mocks';
|
||||
import {
|
||||
hoverOrdersRow,
|
||||
openSpanDetails,
|
||||
showsLogsError,
|
||||
showsNoLogs,
|
||||
showsSpanLogs,
|
||||
showsTraceOnlyLogs,
|
||||
showsTraceSummaryLoading,
|
||||
showsWithoutTraceSummary,
|
||||
} from '../__story_mockdata__/spanDrawer.play';
|
||||
|
||||
import TracesModulePage from '../../TracesModulePage';
|
||||
|
||||
@@ -31,55 +39,12 @@ export default meta;
|
||||
|
||||
type Story = StoryObj<TracesArgs>;
|
||||
|
||||
/** The page fetches before it renders its rows, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
// The quick filters list the same span name, so the row is found by its test
|
||||
// id; the table renders its rows a few at a time, so it waits for this one.
|
||||
const findOrdersRow = (): Promise<HTMLElement> =>
|
||||
waitFor((): HTMLElement => {
|
||||
const match = screen
|
||||
.getAllByTestId(/^traces-table-row-/)
|
||||
.find((candidate) => candidate.textContent?.includes('POST /api/orders'));
|
||||
|
||||
expect(match).toBeDefined();
|
||||
|
||||
return match as HTMLElement;
|
||||
}, untilLoaded);
|
||||
|
||||
const openDrawer = async (): Promise<HTMLElement> => {
|
||||
const row = await findOrdersRow();
|
||||
|
||||
await userEvent.click(within(row).getByText('POST /api/orders'));
|
||||
|
||||
return screen.findByTestId('span-details-drawer');
|
||||
};
|
||||
|
||||
const openLogsTab = async (): Promise<HTMLElement> => {
|
||||
const drawer = await openDrawer();
|
||||
|
||||
await userEvent.click(within(drawer).getByRole('tab', { name: /Logs/ }));
|
||||
|
||||
return drawer;
|
||||
};
|
||||
|
||||
/**
|
||||
* The drawer on a failing span: its summary with % exec time, attributes, the
|
||||
* exception event and a linked span, with up and down stepping through the page.
|
||||
*/
|
||||
export const Default: Story = {
|
||||
play: async ({ mount }): Promise<void> => {
|
||||
await mount();
|
||||
const drawer = await openDrawer();
|
||||
|
||||
await expect(
|
||||
await within(drawer).findByText(
|
||||
/of total exec time/,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
).toBeVisible();
|
||||
},
|
||||
play: openSpanDetails,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -87,65 +52,25 @@ export const Default: Story = {
|
||||
* tab, without opening the drawer.
|
||||
*/
|
||||
export const OpenTraceIcon: Story = {
|
||||
play: async ({ mount }): Promise<void> => {
|
||||
await mount();
|
||||
await userEvent.hover(await findOrdersRow());
|
||||
|
||||
await expect(await screen.findByTestId('span-row-open-trace')).toBeVisible();
|
||||
},
|
||||
play: hoverOrdersRow,
|
||||
};
|
||||
|
||||
/** The Logs tab with the span's logs, between the logs just before and after them. */
|
||||
export const Logs: Story = {
|
||||
args: { spanLogs: 'span' },
|
||||
play: async ({ mount }): Promise<void> => {
|
||||
await mount();
|
||||
const drawer = await openLogsTab();
|
||||
|
||||
await expect(
|
||||
(
|
||||
await within(drawer).findAllByText(
|
||||
/payment provider timed out/,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
)
|
||||
)[0],
|
||||
).toBeVisible();
|
||||
},
|
||||
play: showsSpanLogs,
|
||||
};
|
||||
|
||||
/** The span has no logs of its own but its trace does, so the tab points to the trace's. */
|
||||
export const TraceOnlyLogs: Story = {
|
||||
args: { spanLogs: 'trace only' },
|
||||
play: async ({ mount }): Promise<void> => {
|
||||
await mount();
|
||||
const drawer = await openLogsTab();
|
||||
|
||||
await expect(
|
||||
await within(drawer).findByText(
|
||||
'No logs found for selected span. View logs for the current trace.',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
).toBeVisible();
|
||||
},
|
||||
play: showsTraceOnlyLogs,
|
||||
};
|
||||
|
||||
/** Neither the span nor its trace has logs. */
|
||||
export const NoLogs: Story = {
|
||||
args: { spanLogs: 'none' },
|
||||
play: async ({ mount }): Promise<void> => {
|
||||
await mount();
|
||||
const drawer = await openLogsTab();
|
||||
|
||||
await expect(
|
||||
await within(drawer).findByText(
|
||||
'No logs found for this trace.',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
).toBeVisible();
|
||||
},
|
||||
play: showsNoLogs,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -156,34 +81,13 @@ export const WithoutTraceSummary: Story = {
|
||||
args: { traceSummary: 'not found', spanLogs: 'none' },
|
||||
// The deliberate 404 is the state under test.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ mount }): Promise<void> => {
|
||||
await mount();
|
||||
const drawer = await openLogsTab();
|
||||
|
||||
await expect(
|
||||
await within(drawer).findByText(
|
||||
'No logs found for this span.',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
within(drawer).queryByText(/of total exec time/),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
play: showsWithoutTraceSummary,
|
||||
};
|
||||
|
||||
/** The trace summary never answers: the Logs tab waits and its explorer link is off. */
|
||||
export const TraceSummaryLoading: Story = {
|
||||
args: { traceSummary: 'loading' },
|
||||
play: async ({ mount }): Promise<void> => {
|
||||
await mount();
|
||||
await openLogsTab();
|
||||
|
||||
await expect(
|
||||
await screen.findByTestId('open-in-explorer-button'),
|
||||
).toHaveAttribute('aria-disabled', 'true');
|
||||
},
|
||||
play: showsTraceSummaryLoading,
|
||||
};
|
||||
|
||||
/** The log queries fail while the rest of the drawer renders. */
|
||||
@@ -191,16 +95,5 @@ export const LogsError: Story = {
|
||||
args: { spanLogs: 'error' },
|
||||
// The deliberate 500 is the state under test.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ mount }): Promise<void> => {
|
||||
await mount();
|
||||
const drawer = await openLogsTab();
|
||||
|
||||
await expect(
|
||||
await within(drawer).findByText(
|
||||
/Something went wrong/,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
).toBeVisible();
|
||||
},
|
||||
play: showsLogsError,
|
||||
};
|
||||
|
||||
@@ -949,7 +949,7 @@ export function QueryBuilderProvider({
|
||||
searchParams?: Record<string, unknown>,
|
||||
redirectingUrl?: (typeof ROUTES)[keyof typeof ROUTES],
|
||||
shouldNotStringify?: boolean,
|
||||
newTab?: boolean,
|
||||
navigateOptions?: { newTab?: boolean; replace?: boolean },
|
||||
) => {
|
||||
const queryType =
|
||||
!query.queryType || !Object.values(EQueryType).includes(query.queryType)
|
||||
@@ -1001,14 +1001,18 @@ export function QueryBuilderProvider({
|
||||
);
|
||||
|
||||
if (searchParams) {
|
||||
Object.keys(searchParams).forEach((param) =>
|
||||
Object.keys(searchParams).forEach((param) => {
|
||||
if (searchParams[param] === null) {
|
||||
urlQuery.delete(param);
|
||||
return;
|
||||
}
|
||||
urlQuery.set(
|
||||
param,
|
||||
shouldNotStringify
|
||||
? (searchParams[param] as string)
|
||||
: JSON.stringify(searchParams[param]),
|
||||
),
|
||||
);
|
||||
);
|
||||
});
|
||||
}
|
||||
// Remove Hidden Filters from URL query parameters on query change
|
||||
urlQuery.delete(QueryParams.activeLogId);
|
||||
@@ -1017,7 +1021,7 @@ export function QueryBuilderProvider({
|
||||
? `${redirectingUrl}?${urlQuery}`
|
||||
: `${location.pathname}?${urlQuery}`;
|
||||
|
||||
safeNavigate(generatedUrl, { newTab });
|
||||
safeNavigate(generatedUrl, navigateOptions);
|
||||
},
|
||||
[location.pathname, safeNavigate],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { safeNavigateMock } from '__tests__/safeNavigateMock';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { act, AllTheProviders, renderHook } from 'tests/test-utils';
|
||||
|
||||
describe('redirectWithQueryBuilderData search params', () => {
|
||||
it('removes params passed as null and JSON-encodes the rest', () => {
|
||||
const { result } = renderHook(() => useQueryBuilder(), {
|
||||
wrapper: ({ children }): JSX.Element => (
|
||||
<AllTheProviders
|
||||
initialRoute={`/unconstrained?${QueryParams.relativeTime}=6h&${QueryParams.viewName}=kept`}
|
||||
>
|
||||
{children}
|
||||
</AllTheProviders>
|
||||
),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.redirectWithQueryBuilderData(initialQueriesMap.logs, {
|
||||
[QueryParams.relativeTime]: null,
|
||||
[QueryParams.startTime]: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
const [url] = safeNavigateMock.mock.lastCall ?? [];
|
||||
expect(url).toContain(`${QueryParams.startTime}=1000`);
|
||||
expect(url).not.toContain(`${QueryParams.relativeTime}=`);
|
||||
expect(url).toContain(`${QueryParams.viewName}=kept`);
|
||||
});
|
||||
|
||||
it('passes navigate options to safeNavigate', () => {
|
||||
const { result } = renderHook(() => useQueryBuilder(), {
|
||||
wrapper: ({ children }): JSX.Element => (
|
||||
<AllTheProviders initialRoute="/unconstrained">{children}</AllTheProviders>
|
||||
),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.redirectWithQueryBuilderData(
|
||||
initialQueriesMap.logs,
|
||||
{ [QueryParams.startTime]: 1000 },
|
||||
undefined,
|
||||
undefined,
|
||||
{ replace: false },
|
||||
);
|
||||
});
|
||||
|
||||
expect(safeNavigateMock).toHaveBeenLastCalledWith(expect.any(String), {
|
||||
replace: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,12 +6,16 @@ import {
|
||||
IsViewerPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/legacy';
|
||||
import permissionsType from 'lib/authz/hooks/useAuthZ/permissions.type';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import {
|
||||
formatPermission,
|
||||
gettableTransactionToPermission,
|
||||
permissionToTransactionDto,
|
||||
} from 'lib/authz/hooks/useAuthZ/utils';
|
||||
import { ROLES, USER_ROLES } from 'types/roles';
|
||||
|
||||
import type { ResponseState } from '../runtime/responseState';
|
||||
|
||||
/**
|
||||
* `relation:kind` for every verb the backend allows on a resource, from the
|
||||
* generated permission catalogue, so a resource added there shows up here
|
||||
@@ -159,3 +163,23 @@ export const accessFor = (
|
||||
legacyRole: deriveLegacyRole(permissions),
|
||||
};
|
||||
};
|
||||
|
||||
export interface AccessArgs {
|
||||
access: AccessPreset;
|
||||
permissions: readonly string[];
|
||||
revoked: readonly string[];
|
||||
authzState: ResponseState;
|
||||
}
|
||||
|
||||
/** Whether `authz/check`, answered from a story's access controls, passes every one of `checks`. */
|
||||
export const grantsAll = (
|
||||
args: AccessArgs,
|
||||
...checks: BrandedPermission[]
|
||||
): boolean => {
|
||||
const grant = accessFor(args.access, args.permissions, args.revoked);
|
||||
|
||||
return (
|
||||
args.authzState === 'loaded' &&
|
||||
checks.every((check) => grant.allows(permissionToTransactionDto(check)))
|
||||
);
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user