mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-15 16:00:41 +01:00
Compare commits
4 Commits
feat/add-m
...
feat/sqlco
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b19b89b004 | ||
|
|
9fb05731a7 | ||
|
|
861e5f75cf | ||
|
|
755cca13cf |
@@ -26,135 +26,6 @@ process on top of it.
|
||||
5. **Verify in the browser**: [references/verify.md](references/verify.md). Never
|
||||
report the story as done without it.
|
||||
|
||||
## Where it lands in the sidebar
|
||||
|
||||
The sidebar mirrors the app's own side nav (`container/SideNav/menuItems.tsx`), so
|
||||
a page sits where someone would click it in the product. Four things decide that,
|
||||
and all four are part of writing the story, not a follow-up.
|
||||
|
||||
**Title.** `Pages/<Area>/<Page>`, where `<Area>` is the nav section and `<Page>`
|
||||
is the label the nav gives it.
|
||||
|
||||
- The leaf is the product's label, never the component's name: `MetricsExplorer`
|
||||
is `Metrics/Explorer`, `MeterExplorer` is `Metering/Cost Meter`,
|
||||
`AIAssistantPage` is `Noz`.
|
||||
- Never repeat the area in the leaf: `Alerts/Rules`, not `Alerts/AlertRules`.
|
||||
- A leaf never shares its name with a sibling folder. The folder wins and the
|
||||
page becomes `List`, or `Overview` for a tab strip: `Services/List` beside
|
||||
`Services/Detail`.
|
||||
- Title Case with spaces. No camelCase, no kebab.
|
||||
- Four levels is the floor to stay under: `Pages/Alerts/Channels/New` is as deep
|
||||
as it goes.
|
||||
- Pages nobody navigates to on purpose go under `Pages/System` (`Status`,
|
||||
`Unauthorized`, `Workspace Locked`), and the pre-session pages under
|
||||
`Pages/Auth`.
|
||||
- A page whose permission stories earn their own folder becomes one:
|
||||
`Pages/Settings/Billing/Overview` beside `Pages/Settings/Billing/Authz`. See
|
||||
**Permission stories** below.
|
||||
|
||||
**Order.** The `storySort.order` literal in `.storybook/preview.tsx` carries the
|
||||
order for every level. A new page in an existing area is appended to that area's
|
||||
array, in the order the product lists it; a new area goes where the side nav
|
||||
puts it. Storybook parses the order out of the file statically, so it has to
|
||||
stay an inline literal. Missing entries fall to the end of their level rather
|
||||
than disappearing, so a forgotten edit is a page at the bottom of its area, not
|
||||
a broken sidebar.
|
||||
|
||||
**Tags.** Declared on the meta, right under `title`, and what the sidebar's tag
|
||||
filter answers questions with. Only these:
|
||||
|
||||
| Tag | When |
|
||||
| --- | --- |
|
||||
| `authz` | The page gates UI on permission checks through `lib/authz` (`AuthZButton`, `AuthZGuard`, `useAuthZ`). Both the page's file and its `Authz` file carry it. |
|
||||
| `role-gated` | The page still branches on the legacy role (`user.role`, `hasEditPermission`) and has no authz check. |
|
||||
| `beta` | `isBeta` on its nav entry. Drop the tag when the product drops the badge. |
|
||||
| `legacy` | Superseded by another page but still routed. The doc comment names the page to start from instead. |
|
||||
| `play` | The story file has a `play` function, so at least one state is reached by an interaction. |
|
||||
|
||||
`autodocs` comes from `preview.tsx` and is never written on a meta.
|
||||
|
||||
**Doc comment on the meta.** What the page is, in the page's own terms, then a
|
||||
blank line, then the route:
|
||||
|
||||
```tsx
|
||||
const pageStory = storyMocks(logsExplorerMocks, {
|
||||
route: explorerRoute('explorer'),
|
||||
layout: 'app',
|
||||
});
|
||||
|
||||
/**
|
||||
* The logs explorer: the query builder, the list, the frequency chart and the log
|
||||
* detail drawer, with quick filters and saved views beside them.
|
||||
*
|
||||
* Route: `/logs/logs-explorer`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Logs/Explorer',
|
||||
tags: ['play'],
|
||||
component: LogsModulePage,
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<LogsExplorerArgs>;
|
||||
```
|
||||
|
||||
The `pageStory` const and the trailing `parameters` line are what make the doc
|
||||
comment safe. The comment compiles to a `parameters` property that the csf plugin
|
||||
appends after the spread, so a meta that spreads `storyMocks(...)` and stops
|
||||
there loses `parameters.signoz` and renders the page against the global handlers
|
||||
alone: every one of the page's endpoints misses. Restating `parameters` as a
|
||||
literal gives the plugin something to merge into. `resolveStory` logs the
|
||||
combination that says it happened, so the console names it rather than leaving it
|
||||
to be found by reading the page.
|
||||
|
||||
It is the description on the page's Docs page, which is the only place a reader
|
||||
who is not in the code finds out what the page is for. Two or three sentences:
|
||||
what it shows, what drives it, and the gating worth knowing about (`Gated on
|
||||
authz permissions`, `follows the legacy editor role`). A control-driven route
|
||||
says so instead of a path: ``Route: `/metrics-explorer/*`, the tab control picks
|
||||
which``.
|
||||
|
||||
## Permission stories
|
||||
|
||||
A page that gates UI on `lib/authz` keeps its permission states in a folder of
|
||||
their own, so the page's own file stays about the page and the sidebar answers
|
||||
"what does this permission do" in one place.
|
||||
|
||||
**Layout.** A second story file at `stories/authz/<Page>.authz.stories.tsx`,
|
||||
titled `Pages/<Area>/<Page>/Authz`, which turns the page into a folder: its own
|
||||
file is retitled `Pages/<Area>/<Page>/Overview`, and `.storybook/preview.tsx`
|
||||
gains the sub-order (`'Billing', ['Overview', 'Authz']`). Both files carry the
|
||||
`authz` tag and share the page's one mocks module, which the authz file imports
|
||||
as `../<Page>.stories.mocks`. It declares no controls and no mock data of its
|
||||
own: a permission story that needs a new response is a control the page's mocks
|
||||
were missing.
|
||||
|
||||
**One story per permission the page reads**, named for what is gone: `NoRead`,
|
||||
`NoList`, `NoUpdate`, `NoCreate`, `NoDelete`. Then the combinations the page
|
||||
itself distinguishes, and only those: `NoManage` where two permissions gate one
|
||||
button, `ReadOnly` where everything but reading is denied, `NoSubscriptionAccess`
|
||||
where none of the resource's permissions are held, and `CheckFailed` for
|
||||
`authzState: 'error'`, which is the page's fail-open path rather than a denial.
|
||||
|
||||
**Revoke, never allow-list.** Each story is a full grant minus what its name
|
||||
says: `args: { revoked: ['read:subscription'] }`. The `Revoked` control subtracts
|
||||
from the preset, so the story stays "an admin missing one permission" as the
|
||||
catalogue grows, and the diff against the page's `Default` is the one permission.
|
||||
Rebuilding the allow-list by hand drifts the moment a resource is added.
|
||||
|
||||
**Never a role preset in this folder.** `access: 'viewer'` moves the legacy role,
|
||||
the side nav and every other resource's permissions at the same time, so the
|
||||
story no longer shows what its name claims. A persona is a story on the page's
|
||||
own file, and only when the product has that persona.
|
||||
|
||||
**Pair the revocation with the state that renders the gated control.** A button
|
||||
that only exists on a trial needs the plan too:
|
||||
`args: { plan: 'on-trial', revoked: ['create:subscription'] }`. A permission
|
||||
whose denial changes nothing on screen gets no story: say so in the PR.
|
||||
|
||||
Verify these by their disabled states, not their text. The page reads the same
|
||||
either way, so a story that is wrong looks right: read `disabled` off the buttons
|
||||
the permission gates, and check the denial callout is there or gone.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Default is the loaded page.** `export const Default: Story = {}` with no args,
|
||||
@@ -179,29 +50,7 @@ the permission gates, and check the denial callout is there or gone.
|
||||
- **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`. 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. 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).
|
||||
page-specific in `src/storybook/controls/`.
|
||||
- **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:
|
||||
|
||||
@@ -225,13 +74,6 @@ the permission gates, and check the denial callout is there or gone.
|
||||
writing a response shape inline, check if a builder exists; if not and the
|
||||
shape will repeat, add it there. Page-specific builders stay in the page's
|
||||
`__story_mockdata__/`.
|
||||
- **The story's own doc comment is per state.** Every `export const` gets one:
|
||||
what that state shows, not how it is built. It renders in the States list on
|
||||
the page's Docs page, so `Undocumented.` there is a story nobody described.
|
||||
- **Story names come from a fixed vocabulary** where one fits: `Default`,
|
||||
`Viewer`, `Empty`, `Loading`, `Error`. Page-specific states get page-specific
|
||||
names (`NoIngestion`, `Unlicensed`), never a second spelling of one of those
|
||||
(`ViewerAccess`, `NonAdmin`).
|
||||
- **No comment is the default.** Write one only for what the code cannot show:
|
||||
a shape the backend dictates, an app bug the mock reproduces, an ordering or
|
||||
cap the page depends on, a workaround and the reason for it. Never restate a
|
||||
@@ -243,16 +85,6 @@ the permission gates, and check the denial callout is there or gone.
|
||||
## Done means
|
||||
|
||||
- [ ] `Default` shows the page with data, checked in dark and light
|
||||
- [ ] title follows the sidebar rules, tags declared, and the page's entry added
|
||||
to the `storySort.order` literal in `.storybook/preview.tsx`
|
||||
- [ ] the meta carries its doc comment with the `Route:` line, the meta restates
|
||||
`parameters: { ...pageStory.parameters }` after the spread, and every story
|
||||
export carries its own doc comment
|
||||
- [ ] the page's Docs page renders: description, controls table, and one row per
|
||||
state with no `Undocumented.`
|
||||
- [ ] 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
|
||||
- [ ] console clean: no `[storybook] no msw handler`, no 501, no msw unhandled
|
||||
|
||||
@@ -128,83 +128,20 @@ export const servicesMocks = defineStoryMocks({
|
||||
// src/pages/Services/stories/Services.stories.tsx
|
||||
type ServicesArgs = PageStoryArgs<typeof servicesMocks>;
|
||||
|
||||
const pageStory = storyMocks(servicesMocks, {
|
||||
route: ROUTES.APPLICATION,
|
||||
layout: 'app',
|
||||
});
|
||||
|
||||
/**
|
||||
* Every instrumented service with its p99, error rate and throughput.
|
||||
*
|
||||
* Route: `/services`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Services/List',
|
||||
title: 'Pages/Services',
|
||||
component: Services,
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
...storyMocks(servicesMocks, { route: ROUTES.APPLICATION, layout: 'app' }),
|
||||
} satisfies Meta<ServicesArgs>;
|
||||
```
|
||||
|
||||
`PageStoryArgs` folds in the global controls, so a story's `args` can set
|
||||
`access`, `dataState` or `banner` next to the page's own knobs and stay typed.
|
||||
|
||||
## A step the page keeps in component state
|
||||
|
||||
A wizard's step, a questionnaire's page, a picker's next question: the page holds
|
||||
it in `useState` and nothing in the URL says which one is open. It is still a
|
||||
control. Declare the steps in the mocks module and drive them from a `play` on
|
||||
the **meta**, so every story of the page inherits the walk and only sets `args`:
|
||||
|
||||
```tsx
|
||||
// <Page>.stories.mocks.tsx
|
||||
export const SETUP_STEPS = ['pick-source', 'pick-framework', 'configure'] as const;
|
||||
export type SetupStep = (typeof SETUP_STEPS)[number];
|
||||
|
||||
controls: {
|
||||
step: choiceControl<SetupStep>('Setup step', { group: SETUP, options: SETUP_STEPS, value: 'pick-source' }),
|
||||
},
|
||||
```
|
||||
|
||||
```tsx
|
||||
// <Page>.stories.tsx
|
||||
const meta = {
|
||||
play: async ({ mount, args, canvasElement }): Promise<void> => {
|
||||
await mount();
|
||||
await advanceToSetupStep(canvasElement, args.step);
|
||||
},
|
||||
...storyMocks(pageMocks),
|
||||
} satisfies Meta<PageArgs>;
|
||||
|
||||
export const Configure: Story = { args: { step: 'configure' } };
|
||||
```
|
||||
|
||||
**Destructuring `mount` is what makes it a control.** Storybook re-runs a play
|
||||
function on an arg change only for a story whose play asks to be remounted
|
||||
(`usesMount`); otherwise it re-renders the tree the previous walk left behind and
|
||||
the panel looks broken. With `mount` destructured, the story renders when `play`
|
||||
calls it, and every arg change replays the walk from a fresh mount.
|
||||
|
||||
The walk itself:
|
||||
|
||||
- one `answer` function per step, in an array indexed the same as the step list,
|
||||
so reaching step *n* is `answers.slice(0, STEPS.indexOf(step))`;
|
||||
- answer each step with the least its Next button accepts, and prefer a "do this
|
||||
later" over filling a slider;
|
||||
- run them sequentially (`reduce` over a promise), since each answer is what
|
||||
renders the step the next one reads;
|
||||
- bail out when the page did not start where the walk expects, such as a source
|
||||
deep-linked past the questions. Check for the first step's own text rather than
|
||||
reading another control's value.
|
||||
|
||||
An endpoint that only settles the transition between two steps (the profile a
|
||||
questionnaire saves before its last page) takes a plain resolver, or the Data
|
||||
control on `loading` strands the walk halfway.
|
||||
|
||||
## Not a control
|
||||
|
||||
- Anything the global controls already cover: banner, side nav, data state,
|
||||
access preset, granted permissions, revoked permissions, check state.
|
||||
access preset, permissions, check state.
|
||||
- A knob whose effect nobody can see on the page. Delete it or find the widget it
|
||||
was supposed to drive.
|
||||
- A raw payload as an object control. Controls carry intent (`5 dashboards`,
|
||||
@@ -218,12 +155,9 @@ control on `loading` strands the walk halfway.
|
||||
Default to a control. Write a story when the state is worth a link:
|
||||
|
||||
- the fresh workspace, because that is what a new user sees
|
||||
- the restricted user, when permissions visibly change the page
|
||||
- a page-defining mode (a tab, a category) that has its own layout
|
||||
|
||||
A permission that visibly changes the page is a story too, but it goes in the
|
||||
page's `Authz` folder, one per permission, turned with the `Revoked` control.
|
||||
See **Permission stories** in SKILL.md.
|
||||
|
||||
Combinations of controls do not need stories, which is what the panel is for.
|
||||
|
||||
Each story gets one prose doc comment: what it shows, in the page's own terms.
|
||||
|
||||
@@ -12,12 +12,11 @@ cd frontend && pnpm storybook --ci --quiet # :6006, background it
|
||||
A newly added `.stories.tsx` takes a few seconds to appear in `index.json` on an
|
||||
already-running server; an empty first poll is not a broken `stories` glob.
|
||||
|
||||
Story ids come from the meta title: `Pages/Services/List` →
|
||||
`pages-services-list`, plus the story export in kebab-case. Render one story on
|
||||
its own:
|
||||
Story ids come from the meta title: `Pages/Services` → `pages-services`, plus the
|
||||
story export in kebab-case. Render one story on its own:
|
||||
|
||||
```
|
||||
http://localhost:6006/iframe.html?id=pages-services-list--default&viewMode=story
|
||||
http://localhost:6006/iframe.html?id=pages-services--default&viewMode=story
|
||||
```
|
||||
|
||||
## Flip controls from the URL
|
||||
|
||||
@@ -21,4 +21,5 @@ We **recommend** (almost enforce) reviewing these guides before contributing to
|
||||
- [Packages](packages.md) - Naming, layout, and conventions for `pkg/` packages
|
||||
- [Service](service.md) - Managed service lifecycle with `factory.Service`
|
||||
- [SQL](sql.md) - Database and SQL patterns
|
||||
- [SQL Compiler](sqlcompiler.md) - Compiling the list filter DSL to relational-store WHERE clauses
|
||||
- [Types](types.md) - Domain types, request/response bodies, and storage rows in `pkg/types/`
|
||||
|
||||
84
docs/contributing/go/sqlcompiler.md
Normal file
84
docs/contributing/go/sqlcompiler.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# SQL Compiler
|
||||
|
||||
List pages (dashboards, alert rules) share one filter DSL in their search bars. [pkg/parser/filterquery/sqlcompiler](/pkg/parser/filterquery/sqlcompiler/compiler.go) compiles a DSL string into a WHERE clause for the relational store: `?`-placeholder SQL plus bind arguments, ready for bun on both SQLite and Postgres. Telemetry filters are a different pipeline. They stay on querybuilder's ClickHouse visitor.
|
||||
|
||||
The package owns everything generic about the language. A module adopting it writes exactly one thing: a `FieldResolver` that says which keys exist and what each maps to.
|
||||
|
||||
## What is the DSL?
|
||||
|
||||
The grammar lives at [grammar/FilterQuery.g4](/grammar/FilterQuery.g4), with the ANTLR-generated parser in [pkg/parser/filterquery/grammar](/pkg/parser/filterquery/grammar). It is the same grammar the telemetry search bars use, so the query language feels identical everywhere. The shapes that matter:
|
||||
|
||||
- Boolean structure: parentheses > `NOT` > `AND` > `OR`; adjacent terms with no connective are an implicit `AND`.
|
||||
- Comparisons: `key OP value`, e.g. `name CONTAINS cpu`, `created_at > '2025-01-01T00:00:00Z'`, `labels.team IN ('infra', 'platform')`, `labels.env EXISTS`. See the `comparison` rule in the grammar for the full operator list.
|
||||
- Free text: a bare or quoted token with no key. Quoting is the escape hatch for a phrase that looks like DSL.
|
||||
- Values: bare tokens or quoted strings; `IN` accepts `in(...)` and `[...]` forms.
|
||||
|
||||
## What does the framework already cover?
|
||||
|
||||
```go
|
||||
compiled, errs := sqlcompiler.Compile(query, formatter, resolver)
|
||||
```
|
||||
|
||||
`Compile` returns either a non-nil `*Compiled` or a list of human-readable errors. An empty query compiles to an empty `Compiled`; callers gate on `IsEmpty()`, not nil. On top of parsing, the package handles:
|
||||
|
||||
- Syntax errors, collected with line/column positions instead of failing on the first one.
|
||||
- The boolean tree: `AND`/`OR`/`NOT`, parentheses, implicit `AND`, and pruning of empty conditions.
|
||||
- Operator extraction, including inversion of `NOT LIKE`, `NOT IN`, `NOT EXISTS` and friends.
|
||||
- Predicate builders the resolver calls back into:
|
||||
|
||||
| Builder | Handles |
|
||||
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `BuildStringOperation` | `=`, `!=`, `LIKE`/`ILIKE`, `CONTAINS`, `IN` on a string column; escapes `%`/`_` for `CONTAINS`, rejects patterns ending in a dangling backslash, lowers both sides for `ILIKE` so SQLite and Postgres agree |
|
||||
| `BuildTimestampComparison` | equality, ranges and `BETWEEN` on RFC3339 timestamps |
|
||||
| `BuildBoolComparison` | `= true/false` |
|
||||
| `BuildFreeTextContains` | case-insensitive substring match, `COALESCE`d so `NOT (...)` does not drop rows where the column is NULL |
|
||||
|
||||
- Typed value extraction (`ExtractSingleStringValue`, `ExtractStringValueList`, ...) with accumulated errors: the user sees every problem in the query at once.
|
||||
- Argument binding through go-sqlbuilder; no value is ever interpolated into the SQL text.
|
||||
|
||||
## When do I write a FieldResolver?
|
||||
|
||||
Whenever a module adopts the DSL for its list page. The resolver is the per-module policy and the only code you write:
|
||||
|
||||
```go
|
||||
type FieldResolver interface {
|
||||
ResolveComparison(v *Visitor, key string, operation qbtypesv5.FilterOperator, ctx *grammar.ComparisonContext) string
|
||||
ResolveFreeText(v *Visitor, value string) string
|
||||
}
|
||||
```
|
||||
|
||||
Rules for implementing one:
|
||||
|
||||
- Declare the key namespace in `pkg/types/<domain>`: `DSLKey` constants plus a `ReservedOps` map of key to allowed operators. The list API advertises these as `reservedKeywords`, so the frontend suggestions never go stale.
|
||||
- Reject a disallowed operator with `v.AddError(...)` and return `""`. Never panic, never fail fast; the compile fails at the end with all errors.
|
||||
- Map a key to a column expression through `v.Formatter` (`JSONExtractString`, `LowerExpression`), never by hand, so the expression is valid on both SQLite and Postgres.
|
||||
- Delegate the predicate to the `Build*` helpers above; do not hand-build SQL or manage arguments yourself.
|
||||
- For a key that lives in a relation table (dashboard tags, rule labels), build an `EXISTS` subquery on a fresh `sqlbuilder.SelectBuilder` and pass that builder into `BuildStringOperation`, so its arguments thread through the compile. For a negative operator, build the positive predicate and toggle `NotExists` on the outer builder.
|
||||
|
||||
The reference implementation is the dashboards resolver, [pkg/modules/dashboard/impldashboard/listfilter_resolver.go](/pkg/modules/dashboard/impldashboard/listfilter_resolver.go): reserved keys backed by columns and JSON paths, tag keys via `EXISTS` subqueries, free text across name, description and tags.
|
||||
|
||||
## How to wire it in?
|
||||
|
||||
Give the module a thin `Compile` wrapper that maps the error list onto the module's error code, as in [pkg/modules/dashboard/impldashboard/listfilter.go](/pkg/modules/dashboard/impldashboard/listfilter.go):
|
||||
|
||||
```go
|
||||
func Compile(query string, formatter sqlstore.SQLFormatter) (*sqlcompiler.Compiled, error) {
|
||||
compiled, errs := sqlcompiler.Compile(query, formatter, dashboardFieldResolver{})
|
||||
if len(errs) > 0 {
|
||||
return nil, errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardListFilterInvalid,
|
||||
"invalid filter query: %s", strings.Join(errs, "; "))
|
||||
}
|
||||
return compiled, nil
|
||||
}
|
||||
```
|
||||
|
||||
The store then appends `compiled.SQL` with `compiled.Args` to its list query when `!compiled.IsEmpty()`.
|
||||
|
||||
## What should I remember?
|
||||
|
||||
- One DSL, one compiler; a new list page adds a `FieldResolver`, not a new parser or SQL layer.
|
||||
- Keys and allowed operators live in `pkg/types/<domain>` and are advertised as `reservedKeywords`.
|
||||
- Column expressions go through `v.Formatter`; predicates go through the `Build*` helpers.
|
||||
- Report problems with `v.AddError` and return `""`; errors accumulate.
|
||||
- Relation-table keys use `EXISTS` subqueries on their own builder; negation toggles `NotExists`.
|
||||
- This package is for the relational store only; telemetry filtering stays in querybuilder.
|
||||
@@ -295,8 +295,6 @@
|
||||
// Prevents bracket access on CSS modules (styles['kebab-case']) which fails with camelCaseOnly config
|
||||
"signoz/no-dashboard-fetch-outside-root": "error",
|
||||
// Forces useDashboardFetchRequired() outside the root V2 pages (allowlisted in overrides below)
|
||||
"signoz/no-msw-in-story-file": "error",
|
||||
// Bans msw imports in *.stories.tsx; handlers/mock data belong in the sibling .stories.mocks.tsx
|
||||
"no-restricted-globals": [
|
||||
"error",
|
||||
{
|
||||
|
||||
@@ -27,22 +27,12 @@ const mockAliases = [
|
||||
find: /^(?:src\/)?api\/common\/logEvent$/,
|
||||
replacement: `${srcPath}/storybook/mocks/logEvent.mock.ts`,
|
||||
},
|
||||
{
|
||||
// jest: not replaced, the suite mounts a mock store per test.
|
||||
find: /^(?:src\/)?store$/,
|
||||
replacement: `${srcPath}/storybook/mocks/store.mock.ts`,
|
||||
},
|
||||
{
|
||||
// jest: __mocks__/env.ts, which leaves `baseURL` empty because jsdom already
|
||||
// resolves a relative `/api/...` against `http://localhost`.
|
||||
find: /^(?:src\/)?constants\/env$/,
|
||||
replacement: `${srcPath}/storybook/mocks/env.mock.ts`,
|
||||
},
|
||||
{
|
||||
// jest: not replaced, a test opens the one tooltip it is about.
|
||||
find: /^@signozhq\/ui\/tooltip$/,
|
||||
replacement: `${srcPath}/storybook/mocks/tooltip.mock.tsx`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -65,12 +55,12 @@ const isExcluded = (plugin: PluginOption): boolean =>
|
||||
|
||||
const config: StorybookConfig = {
|
||||
framework: '@storybook/react-vite',
|
||||
stories: ['../src/storybook/docs/**/*.mdx', '../src/**/*.stories.@(ts|tsx)'],
|
||||
stories: ['../src/**/*.stories.@(ts|tsx)'],
|
||||
// `../public` carries the fonts, icons and i18n bundles the app expects at
|
||||
// the root; `./public` carries the msw worker, which must not ship in a
|
||||
// production build.
|
||||
staticDirs: ['../public', './public'],
|
||||
addons: ['@storybook/addon-a11y', '@storybook/addon-docs'],
|
||||
addons: ['@storybook/addon-a11y'],
|
||||
core: { disableTelemetry: true },
|
||||
viteFinal: async (viteConfig) => {
|
||||
const plugins = (viteConfig.plugins ?? [])
|
||||
@@ -87,14 +77,6 @@ const config: StorybookConfig = {
|
||||
|
||||
return {
|
||||
...viteConfig,
|
||||
build: {
|
||||
...viteConfig.build,
|
||||
// `vite.config.ts` sets this for the app; Storybook's builder replaces
|
||||
// `build` wholesale, which leaves rolldown-vite on its default
|
||||
// lightningcss. That one rejects `:global()` in a plain stylesheet, which
|
||||
// the app has, and the static build dies in CSS minification.
|
||||
cssMinify: 'esbuild',
|
||||
},
|
||||
plugins,
|
||||
resolve: {
|
||||
...viteConfig.resolve,
|
||||
|
||||
@@ -6,17 +6,6 @@
|
||||
-->
|
||||
<link rel="stylesheet" href="storybook-fonts.css" />
|
||||
|
||||
<!--
|
||||
Third-party frames are the one thing msw cannot answer: a cross-origin iframe
|
||||
navigates outside the service worker's scope, so the YouTube embeds and the
|
||||
docs pane in onboarding reach the real network. Same intent as the boot data
|
||||
below, enforced by the browser instead.
|
||||
-->
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="frame-src 'self' blob: data:"
|
||||
/>
|
||||
|
||||
<link rel="stylesheet" href="css/uPlot.min.css" />
|
||||
|
||||
<script>
|
||||
@@ -35,38 +24,3 @@
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// The wall clock every story reads. Chart windows, `4 mins ago` labels and
|
||||
// trial countdowns all derive from `now`, and Chromatic does not freeze the
|
||||
// clock, so a live one redraws every chart axis between two builds of the
|
||||
// same code. `performance.now` and the timers keep running, so anything
|
||||
// waiting on a timeout still resolves. `?storyClock=live`, or an ISO
|
||||
// instant, overrides it.
|
||||
//
|
||||
// `new Date()` is the frozen instant, which is what the app renders from.
|
||||
// `Date.now()` runs on from it instead, because it is also what code measures
|
||||
// elapsed time with: `lodash.debounce` compares two `Date.now()` readings to
|
||||
// decide its trailing call is due, so a frozen one re-arms its timer forever
|
||||
// and every debounced input in the app (the onboarding catalogue search, the
|
||||
// pipelines search, the log filter) silently stops filtering.
|
||||
(() => {
|
||||
const asked = new URLSearchParams(window.location.search).get('storyClock');
|
||||
if (asked === 'live') return;
|
||||
|
||||
const frozen = Date.parse(asked || '2026-06-15T12:00:00.000Z');
|
||||
if (Number.isNaN(frozen)) return;
|
||||
|
||||
const RealDate = Date;
|
||||
const started = performance.now();
|
||||
class FrozenDate extends RealDate {
|
||||
constructor(...args) {
|
||||
super(...(args.length ? args : [frozen]));
|
||||
}
|
||||
static now() {
|
||||
return frozen + (performance.now() - started);
|
||||
}
|
||||
}
|
||||
Object.defineProperty(window, 'Date', { value: FrozenDate, writable: true });
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -3,8 +3,6 @@ import type { SetupWorker } from 'msw';
|
||||
import { setupWorker } from 'msw';
|
||||
|
||||
import { settleForCapture } from '../src/storybook/visual/settleForCapture';
|
||||
import PageDocs from '../src/storybook/docs/PageDocs';
|
||||
import ThemedDocsContainer from '../src/storybook/docs/ThemedDocsContainer';
|
||||
import { withProviders } from '../src/storybook/decorators/withProviders';
|
||||
import { globalMocks } from '../src/storybook/globals';
|
||||
import { resetStoryHistory } from '../src/storybook/navigation/containment';
|
||||
@@ -15,12 +13,7 @@ import {
|
||||
} from '../src/storybook/runtime/resolveStory';
|
||||
import { allModes } from './modes';
|
||||
|
||||
import i18n from '../src/ReactI18';
|
||||
|
||||
// `src/index.tsx` does this at boot: without it `@monaco-editor/react` falls back
|
||||
// to its loader default and pulls Monaco from cdn.jsdelivr.net, which msw does
|
||||
// not report because the requests look like static assets.
|
||||
import '../src/lib/monaco/setup';
|
||||
import '../src/ReactI18';
|
||||
|
||||
import '../src/styles.scss';
|
||||
|
||||
@@ -70,134 +63,10 @@ const { worker, ready } = (holder.__signozStorybookWorker ??=
|
||||
};
|
||||
})());
|
||||
|
||||
/**
|
||||
* `t()` answers with the key until the namespace's JSON has landed, and a `play`
|
||||
* that clicks as soon as the story renders is quick enough to catch it: the
|
||||
* channel form's "Channel name is mandatory" arrives as `channel_name_required`.
|
||||
* Every namespace under `public/locales/en` is loaded once, ahead of the first
|
||||
* story.
|
||||
*/
|
||||
const translationsReady = i18n.loadNamespaces(
|
||||
Object.keys(import.meta.glob('../public/locales/en/*.json')).map((path) =>
|
||||
path.slice(path.lastIndexOf('/') + 1, -'.json'.length),
|
||||
),
|
||||
);
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
controls: { expanded: true },
|
||||
// The sidebar order, mirroring the app's own side nav
|
||||
// (`container/SideNav/menuItems.tsx`), so a page sits where someone would
|
||||
// click it in the product. Storybook's default is the order the story files
|
||||
// happen to be globbed in, which puts `src/modules` first. Anything missing
|
||||
// from a level lands after the entries listed for it, in file order, so a new
|
||||
// story shows up at the end of its area rather than disappearing. Stories
|
||||
// inside a file are never listed, so they keep the order they are declared
|
||||
// in, `Default` first. Storybook parses this out of the file, so it has to
|
||||
// stay an inline literal.
|
||||
options: {
|
||||
storySort: {
|
||||
order: [
|
||||
'Docs',
|
||||
'Pages',
|
||||
[
|
||||
'Home',
|
||||
'Alerts',
|
||||
[
|
||||
'Rules',
|
||||
'Triggered',
|
||||
'Overview',
|
||||
'History',
|
||||
'Create',
|
||||
'Edit',
|
||||
'Planned Downtime',
|
||||
'Routing Policies',
|
||||
'Channels',
|
||||
['List', 'New', 'Edit'],
|
||||
],
|
||||
'Dashboards',
|
||||
['List', 'Detail', 'Panel Editor', 'Public'],
|
||||
'Services',
|
||||
['List', 'Detail', 'Top Level Operations', 'Service Map'],
|
||||
'Logs',
|
||||
[
|
||||
'Explorer',
|
||||
'Live Tail',
|
||||
'Saved Views',
|
||||
'Pipelines',
|
||||
'Settings',
|
||||
'Legacy Explorer',
|
||||
],
|
||||
'Traces',
|
||||
['Explorer', 'Trace Details', 'Funnel Details', 'Legacy Explorer'],
|
||||
'Metrics',
|
||||
['Explorer'],
|
||||
'Infrastructure',
|
||||
[
|
||||
'Overview',
|
||||
'Kubernetes',
|
||||
[
|
||||
'Clusters',
|
||||
'Nodes',
|
||||
'Namespaces',
|
||||
'Pods',
|
||||
'Deployments',
|
||||
'DaemonSets',
|
||||
'StatefulSets',
|
||||
'Jobs',
|
||||
'Volumes',
|
||||
],
|
||||
],
|
||||
'Integrations',
|
||||
['List', 'Details', 'Cloud Account'],
|
||||
'Exceptions',
|
||||
['List', 'Detail'],
|
||||
'External APIs',
|
||||
'AI Observability',
|
||||
['Overview', 'Explorer', 'Model Pricing', 'Attribute Mapping'],
|
||||
'Noz',
|
||||
'Metering',
|
||||
['Cost Meter', 'Usage Explorer'],
|
||||
'Messaging Queues',
|
||||
['Overview', 'Kafka', 'Kafka Detail', 'Celery'],
|
||||
'Onboarding',
|
||||
['Questionnaire', 'Add Data Source'],
|
||||
'Settings',
|
||||
[
|
||||
'Workspace',
|
||||
'Account',
|
||||
'Billing',
|
||||
['Overview', 'Authz'],
|
||||
'MCP Server',
|
||||
'Roles',
|
||||
'Role Details',
|
||||
'Role Editor',
|
||||
'Members',
|
||||
'Service Accounts',
|
||||
'Ingestion',
|
||||
'Single Sign-on',
|
||||
'Keyboard Shortcuts',
|
||||
],
|
||||
'Auth',
|
||||
['Login', 'Sign Up', 'Forgot Password', 'Reset Password'],
|
||||
'System',
|
||||
[
|
||||
'Status',
|
||||
'Support',
|
||||
'License',
|
||||
'Not Found',
|
||||
'Unauthorized',
|
||||
'Error Fallback',
|
||||
'Workspace Locked',
|
||||
'Workspace Suspended',
|
||||
'Workspace Access Restricted',
|
||||
],
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
docs: { page: PageDocs, container: ThemedDocsContainer },
|
||||
// One cloud snapshot per theme, for every story. A mode carries Storybook
|
||||
// globals, so `theme` here is the same toolbar global the app reads out of
|
||||
// localStorage. Widths are Chromatic's only real dimension, as they are
|
||||
@@ -205,9 +74,6 @@ const preview: Preview = {
|
||||
// one it is given.
|
||||
chromatic: { modes: allModes },
|
||||
},
|
||||
// Every page story gets a docs page: the descriptions on the meta and on each
|
||||
// story are the page's documentation, and without this they render nowhere.
|
||||
tags: ['autodocs'],
|
||||
globalTypes: {
|
||||
theme: {
|
||||
description: 'SigNoz color scheme',
|
||||
@@ -253,7 +119,7 @@ const preview: Preview = {
|
||||
world.apply();
|
||||
world.install(worker);
|
||||
|
||||
await Promise.all([ready, translationsReady]);
|
||||
await ready;
|
||||
},
|
||||
],
|
||||
beforeEach: () => {
|
||||
|
||||
@@ -88,16 +88,10 @@ self.addEventListener('fetch', function (event) {
|
||||
const { request } = event
|
||||
const accept = request.headers.get('accept') || ''
|
||||
|
||||
// msw bypasses server-sent events here, because it answers a request in one
|
||||
// piece and has no stream to hand back. A story is not a live connection
|
||||
// either: it wants the backlog a page renders, and one response carries that
|
||||
// fine. Left bypassed, `/api/v3/logs/livetail` reaches the real network and
|
||||
// the live tail story is a spinner over ERR_CONNECTION_REFUSED. Restore the
|
||||
// bypass and re-check `Pages/Logs/Live Tail` if msw regenerates this file.
|
||||
//
|
||||
// if (accept.includes('text/event-stream')) {
|
||||
// return
|
||||
// }
|
||||
// Bypass server-sent events.
|
||||
if (accept.includes('text/event-stream')) {
|
||||
return
|
||||
}
|
||||
|
||||
// Bypass navigation requests.
|
||||
if (request.mode === 'navigate') {
|
||||
|
||||
@@ -162,7 +162,6 @@
|
||||
"@jest/globals": "30.4.1",
|
||||
"@jest/types": "30.2.0",
|
||||
"@storybook/addon-a11y": "10.5.9",
|
||||
"@storybook/addon-docs": "10.5.9",
|
||||
"@storybook/react-vite": "10.5.9",
|
||||
"@storybook/test-runner": "0.24.5",
|
||||
"@testing-library/dom": "8.20.0",
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* Rule: no-msw-in-story-file
|
||||
*
|
||||
* A `.stories.tsx` file is the human-facing surface: it must not carry msw
|
||||
* handlers or response payloads. Those belong in the sibling
|
||||
* `<Page>.stories.mocks.tsx` module (and its `__story_mockdata__` builders).
|
||||
*
|
||||
* This rule flags any import from `msw` inside a `*.stories.tsx` file. It
|
||||
* does not match `*.stories.mocks.tsx`, which is where msw imports belong.
|
||||
*/
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
type: 'suggestion',
|
||||
docs: {
|
||||
description:
|
||||
'Disallow importing from msw inside a .stories.tsx file; move handlers/mock data to the sibling .stories.mocks.tsx module',
|
||||
category: 'Storybook',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
noMsw:
|
||||
'Do not import from msw in a .stories.tsx file. Move the handler and its mock data to the sibling <Page>.stories.mocks.tsx module (and __story_mockdata__ for builders).',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const filename = context.filename || '';
|
||||
if (!filename.endsWith('.stories.tsx')) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
if (node.source.value === 'msw') {
|
||||
context.report({ node, messageId: 'noMsw' });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -15,7 +15,6 @@ import noCssModuleBracketAccess from './rules/no-css-module-bracket-access.mjs';
|
||||
import noDashboardFetchOutsideRoot from './rules/no-dashboard-fetch-outside-root.mjs';
|
||||
import noConditionalTextNodesWithSiblings from './rules/no-conditional-text-nodes-with-siblings.mjs';
|
||||
import noReturnTextNodes from './rules/no-return-text-nodes.mjs';
|
||||
import noMswInStoryFile from './rules/no-msw-in-story-file.mjs';
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
@@ -32,6 +31,5 @@ export default {
|
||||
'no-dashboard-fetch-outside-root': noDashboardFetchOutsideRoot,
|
||||
'no-conditional-text-nodes-with-siblings': noConditionalTextNodesWithSiblings,
|
||||
'no-return-text-nodes': noReturnTextNodes,
|
||||
'no-msw-in-story-file': noMswInStoryFile,
|
||||
},
|
||||
};
|
||||
|
||||
48
frontend/pnpm-lock.yaml
generated
48
frontend/pnpm-lock.yaml
generated
@@ -359,9 +359,6 @@ importers:
|
||||
'@storybook/addon-a11y':
|
||||
specifier: 10.5.9
|
||||
version: 10.5.9(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
|
||||
'@storybook/addon-docs':
|
||||
specifier: 10.5.9
|
||||
version: 10.5.9(@types/react-dom@18.0.10)(@types/react@18.0.26)(esbuild@0.28.1)(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4))(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
|
||||
'@storybook/react-vite':
|
||||
specifier: 10.5.9
|
||||
version: 10.5.9(@types/react-dom@18.0.10)(@types/react@18.0.26)(esbuild@0.28.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4))(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))(typescript@5.9.3)
|
||||
@@ -2180,12 +2177,6 @@ packages:
|
||||
'@marijn/find-cluster-break@1.0.2':
|
||||
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
|
||||
|
||||
'@mdx-js/react@3.1.1':
|
||||
resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==}
|
||||
peerDependencies:
|
||||
'@types/react': '>=16'
|
||||
react: '>=16'
|
||||
|
||||
'@monaco-editor/loader@1.7.0':
|
||||
resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==}
|
||||
|
||||
@@ -3760,15 +3751,6 @@ packages:
|
||||
peerDependencies:
|
||||
storybook: ^10.5.9
|
||||
|
||||
'@storybook/addon-docs@10.5.9':
|
||||
resolution: {integrity: sha512-8sFsMkZYrrdqCLdV+hnwTwDF7RaBsBPRwl4wfc8ve9Q/7Yhi5REe/Xjvd8x1yn6fBPPw9tnID9dx6Agdnr81fw==}
|
||||
peerDependencies:
|
||||
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
storybook: ^10.5.9
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@storybook/builder-vite@10.5.9':
|
||||
resolution: {integrity: sha512-Zg4JbGQiHFPGlFJ9HM+XPgzKmU/RFPCymhohVRJhBBYfmgaQgz0flWWzscseCDpl638MNd8/r/H+nwuoBgSYDg==}
|
||||
peerDependencies:
|
||||
@@ -4192,9 +4174,6 @@ packages:
|
||||
'@types/mdast@4.0.3':
|
||||
resolution: {integrity: sha512-LsjtqsyF+d2/yFOYaN22dHZI1Cpwkrj+g06G8+qtUKlhovPW89YhqSnfKtMbkgmEtYpH2gydRNULd6y8mciAFg==}
|
||||
|
||||
'@types/mdx@2.0.14':
|
||||
resolution: {integrity: sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg==}
|
||||
|
||||
'@types/ms@0.7.31':
|
||||
resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==}
|
||||
|
||||
@@ -12169,12 +12148,6 @@ snapshots:
|
||||
|
||||
'@marijn/find-cluster-break@1.0.2': {}
|
||||
|
||||
'@mdx-js/react@3.1.1(@types/react@18.0.26)(react@18.2.0)':
|
||||
dependencies:
|
||||
'@types/mdx': 2.0.14
|
||||
'@types/react': 18.0.26
|
||||
react: 18.2.0
|
||||
|
||||
'@monaco-editor/loader@1.7.0':
|
||||
dependencies:
|
||||
state-local: 1.0.7
|
||||
@@ -13586,25 +13559,6 @@ snapshots:
|
||||
axe-core: 4.13.0
|
||||
storybook: 10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0)
|
||||
|
||||
'@storybook/addon-docs@10.5.9(@types/react-dom@18.0.10)(@types/react@18.0.26)(esbuild@0.28.1)(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4))(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))':
|
||||
dependencies:
|
||||
'@mdx-js/react': 3.1.1(@types/react@18.0.26)(react@18.2.0)
|
||||
'@storybook/csf-plugin': 10.5.9(esbuild@0.28.1)(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4))(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
|
||||
'@storybook/icons': 2.1.0(react@18.2.0)
|
||||
'@storybook/react-dom-shim': 10.5.9(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
storybook: 10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0)
|
||||
ts-dedent: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/react': 18.0.26
|
||||
transitivePeerDependencies:
|
||||
- '@types/react-dom'
|
||||
- esbuild
|
||||
- rollup
|
||||
- vite
|
||||
- webpack
|
||||
|
||||
'@storybook/builder-vite@10.5.9(esbuild@0.28.1)(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4))(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))':
|
||||
dependencies:
|
||||
'@storybook/csf-plugin': 10.5.9(esbuild@0.28.1)(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4))(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
|
||||
@@ -14050,8 +14004,6 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.2
|
||||
|
||||
'@types/mdx@2.0.14': {}
|
||||
|
||||
'@types/ms@0.7.31': {}
|
||||
|
||||
'@types/node@16.18.25': {}
|
||||
|
||||
@@ -28,4 +28,4 @@ until curl -sf http://127.0.0.1:6006/index.json >/dev/null 2>&1; do
|
||||
sleep 1
|
||||
done
|
||||
|
||||
pnpm exec test-storybook --ci --maxWorkers=2 --testTimeout 30000 "$@"
|
||||
pnpm exec test-storybook --ci --maxWorkers=2 "$@"
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import dayjs from 'dayjs';
|
||||
import { screen, userEvent } from 'storybook/test';
|
||||
|
||||
import { withCanvas } from '@/storybook/decorators/withCanvas';
|
||||
|
||||
import CustomTimePicker from './CustomTimePicker';
|
||||
|
||||
const minTime = dayjs('2025-01-15T11:00:00Z').valueOf() * 1_000_000;
|
||||
const maxTime = dayjs('2025-01-15T12:00:00Z').valueOf() * 1_000_000;
|
||||
|
||||
function TimePickerFixture(): JSX.Element {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedTime, setSelectedTime] = useState('1h');
|
||||
|
||||
return (
|
||||
<CustomTimePicker
|
||||
isModalTimeSelection
|
||||
items={[
|
||||
{ label: 'Last 15 minutes', value: '15m' },
|
||||
{ label: 'Last 1 hour', value: '1h' },
|
||||
{ label: 'Last 6 hours', value: '6h' },
|
||||
{ label: 'Custom', value: 'custom' },
|
||||
]}
|
||||
maxTime={maxTime}
|
||||
minTime={minTime}
|
||||
newPopover
|
||||
open={open}
|
||||
onCustomDateHandler={(): void => undefined}
|
||||
onError={(): void => undefined}
|
||||
onSelect={(value): void => setSelectedTime(value)}
|
||||
onValidCustomDateChange={(): void => undefined}
|
||||
selectedTime={selectedTime}
|
||||
selectedValue="15 Jan 2025 11:00:00 - 15 Jan 2025 12:00:00"
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Custom Time Picker',
|
||||
component: TimePickerFixture,
|
||||
tags: ['play'],
|
||||
decorators: [withCanvas({ maxWidth: 400 })],
|
||||
} satisfies Meta<typeof TimePickerFixture>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Interaction: the time-range menu is open with its relative-range choices. */
|
||||
export const TimeRangeMenuOpen: Story = {
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.click(await screen.findByRole('textbox'));
|
||||
await screen.findByText('RELATIVE TIMES');
|
||||
},
|
||||
};
|
||||
|
||||
/** Interaction: the timezone menu is reached through the real time-range footer. */
|
||||
export const TimezoneMenuOpen: Story = {
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.click(await screen.findByRole('textbox'));
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Change Timezone' }),
|
||||
);
|
||||
await screen.findByPlaceholderText('Search timezones...');
|
||||
},
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { rest } from 'msw';
|
||||
|
||||
import { fieldKeysResponse } from '@/storybook/msw/__story_mockdata__/fields';
|
||||
|
||||
export const fieldSuggestionsHandlers = [
|
||||
rest.get('http://localhost/api/v1/fields/keys', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json(
|
||||
fieldKeysResponse(['service.name', 'body'], {
|
||||
signal: TelemetrytypesSignalDTO.logs,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
export const noFieldSuggestionsHandlers = [
|
||||
rest.get('http://localhost/api/v1/fields/keys', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(fieldKeysResponse([]))),
|
||||
),
|
||||
];
|
||||
@@ -1,102 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { screen, userEvent } from 'storybook/test';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import FieldsSelector from './FieldsSelector';
|
||||
import {
|
||||
fieldSuggestionsHandlers,
|
||||
noFieldSuggestionsHandlers,
|
||||
} from './FieldsSelector.stories.mocks';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Fields Selector',
|
||||
component: FieldsSelector,
|
||||
tags: ['play'],
|
||||
args: {
|
||||
allowCustomFields: true,
|
||||
defaultPosition: { x: 40, y: 40 },
|
||||
fields: [
|
||||
{
|
||||
fieldContext: 'log',
|
||||
fieldDataType: 'string',
|
||||
name: 'timestamp',
|
||||
signal: 'logs',
|
||||
},
|
||||
],
|
||||
height: 560,
|
||||
isOpen: true,
|
||||
onClose: (): void => undefined,
|
||||
onFieldsChange: (): void => undefined,
|
||||
signal: DataSource.LOGS,
|
||||
title: 'Edit log columns',
|
||||
width: 420,
|
||||
},
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: fieldSuggestionsHandlers,
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof FieldsSelector>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Open: the draggable field editor shows its selected and available columns. */
|
||||
export const Open: Story = {};
|
||||
|
||||
/** Mutation: adding a suggested field exposes the real unsaved-change footer. */
|
||||
export const UnsavedChanges: Story = {
|
||||
play: async (): Promise<void> => {
|
||||
// One Add per suggested field, so the first row's is the one clicked.
|
||||
const [addField] = await screen.findAllByRole('button', { name: 'Add' });
|
||||
|
||||
await userEvent.click(addField);
|
||||
await screen.findByRole('button', { name: 'Save changes' });
|
||||
},
|
||||
};
|
||||
|
||||
/** Empty: the suggestion request succeeds with no columns to add. */
|
||||
export const NoResults: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: noFieldSuggestionsHandlers,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Limit: available columns cannot be added once the configured maximum is reached. */
|
||||
export const MaximumFields: Story = {
|
||||
args: {
|
||||
fields: [
|
||||
{
|
||||
fieldContext: 'log',
|
||||
fieldDataType: 'string',
|
||||
name: 'timestamp',
|
||||
signal: 'logs',
|
||||
},
|
||||
{
|
||||
fieldContext: 'log',
|
||||
fieldDataType: 'string',
|
||||
name: 'severity_text',
|
||||
signal: 'logs',
|
||||
},
|
||||
],
|
||||
maxFields: 2,
|
||||
},
|
||||
};
|
||||
|
||||
/** Required: mandatory fields remain present without removal controls. */
|
||||
export const RequiredFields: Story = {
|
||||
args: {
|
||||
fields: [
|
||||
{
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
name: 'service.name',
|
||||
signal: 'logs',
|
||||
},
|
||||
],
|
||||
requiredFields: ['resource:service.name:string'],
|
||||
},
|
||||
};
|
||||
@@ -1,156 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { screen, userEvent } from 'storybook/test';
|
||||
|
||||
import { withCanvas } from '@/storybook/decorators/withCanvas';
|
||||
import type { GlobalMockArgs } from '@/storybook/globals';
|
||||
|
||||
import { CustomMultiSelect, CustomSelect } from './index';
|
||||
|
||||
const options = [
|
||||
{ label: 'Checkout', value: 'checkout' },
|
||||
{ label: 'Frontend', value: 'frontend' },
|
||||
{ label: 'Payments', value: 'payments' },
|
||||
{ label: 'Search', value: 'search' },
|
||||
];
|
||||
|
||||
const longOptions = Array.from({ length: 24 }, (_, index) => ({
|
||||
label: `Service ${String(index + 1).padStart(2, '0')}`,
|
||||
value: `service-${index + 1}`,
|
||||
}));
|
||||
|
||||
const meta = {
|
||||
title: 'Components/New Select',
|
||||
component: CustomSelect,
|
||||
tags: ['play'],
|
||||
decorators: [withCanvas({ maxWidth: 360 })],
|
||||
args: {
|
||||
'aria-label': 'Service',
|
||||
options,
|
||||
placeholder: 'Select a service',
|
||||
},
|
||||
} satisfies Meta<typeof CustomSelect>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
type TooltipsStory = StoryObj<GlobalMockArgs>;
|
||||
|
||||
/** Interaction: the body-portal menu is open for stacking and clipping review. */
|
||||
export const PortalOpen: Story = {
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await screen.findByRole('combobox', { name: 'Service' }),
|
||||
);
|
||||
await screen.findByRole('listbox');
|
||||
},
|
||||
};
|
||||
|
||||
/** Density: a long result list keeps the menu scrollable. */
|
||||
export const LongResults: Story = {
|
||||
args: { options: longOptions },
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await screen.findByRole('combobox', { name: 'Service' }),
|
||||
);
|
||||
await screen.findByText('Service 24');
|
||||
},
|
||||
};
|
||||
|
||||
/** Empty: the select reports its supported no-data state. */
|
||||
export const NoResults: Story = {
|
||||
args: { noDataMessage: 'No services found', options: [] },
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await screen.findByRole('combobox', { name: 'Service' }),
|
||||
);
|
||||
await screen.findByText('No services found');
|
||||
},
|
||||
};
|
||||
|
||||
/** Loading: the open menu keeps its in-progress refresh feedback visible. */
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
loading: true,
|
||||
options: [],
|
||||
},
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await screen.findByRole('combobox', { name: 'Service' }),
|
||||
);
|
||||
await screen.findByText('Refreshing values...');
|
||||
},
|
||||
};
|
||||
|
||||
/** Error: a retryable failed request remains visible in the open menu. */
|
||||
export const Error: Story = {
|
||||
args: {
|
||||
errorMessage: 'Could not load services',
|
||||
onRetry: (): void => undefined,
|
||||
options: [],
|
||||
},
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await screen.findByRole('combobox', { name: 'Service' }),
|
||||
);
|
||||
await screen.findByText('Could not load services');
|
||||
},
|
||||
};
|
||||
|
||||
/** Selection: selected and unavailable options are distinguishable before choosing. */
|
||||
export const SelectedDisabled: Story = {
|
||||
args: {
|
||||
options: [
|
||||
{ label: 'Checkout', value: 'checkout' },
|
||||
{ disabled: true, label: 'Legacy billing', value: 'legacy-billing' },
|
||||
{ label: 'Payments', value: 'payments' },
|
||||
],
|
||||
value: 'checkout',
|
||||
},
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await screen.findByRole('combobox', { name: 'Service' }),
|
||||
);
|
||||
await screen.findByRole('option', { name: 'Legacy billing' });
|
||||
},
|
||||
};
|
||||
|
||||
/** Overflow: a multi-select preserves its selected values when its trigger is constrained. */
|
||||
export const MultiValueOverflow: Story = {
|
||||
render: (): JSX.Element => (
|
||||
<div style={{ maxWidth: 280 }}>
|
||||
<CustomMultiSelect
|
||||
aria-label="Services"
|
||||
maxTagCount={2}
|
||||
options={longOptions}
|
||||
value={['service-1', 'service-2', 'service-3', 'service-4']}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
const LONG_LABEL_OPTION = {
|
||||
label:
|
||||
'checkout-service.production-eu-central-1.svc.cluster.local:8080/v1/orders/{orderId}/payment-authorisation',
|
||||
value: 'checkout-payment-authorisation',
|
||||
};
|
||||
|
||||
/**
|
||||
* Every tooltip the select renders, held open: the selected chip revealing the
|
||||
* option label it was cut from. Nothing bounds that label, so the chip is given
|
||||
* one long enough to need the reveal.
|
||||
*/
|
||||
export const Tooltips: TooltipsStory = {
|
||||
args: { tooltipsOpen: true },
|
||||
render: (): JSX.Element => (
|
||||
<div style={{ maxWidth: 280 }}>
|
||||
<CustomMultiSelect
|
||||
aria-label="Services"
|
||||
maxTagCount={1}
|
||||
maxTagTextLength={14}
|
||||
options={[LONG_LABEL_OPTION, ...options]}
|
||||
value={[LONG_LABEL_OPTION.value]}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
|
||||
/**
|
||||
* The catch-all has no route of its own: it answers for whatever pathname the
|
||||
* `Switch` ran out of routes for, and it calls nothing.
|
||||
*/
|
||||
export const notFoundMocks = defineStoryMocks({
|
||||
controls: {},
|
||||
config: () => ({ route: '/no-such-page' }),
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import NotFound from '../index';
|
||||
import { notFoundMocks } from './NotFound.stories.mocks';
|
||||
|
||||
type NotFoundArgs = PageStoryArgs<typeof notFoundMocks>;
|
||||
|
||||
/**
|
||||
* The catch-all route mounts it with no props, and its `defaultProps` is what
|
||||
* keeps the component itself from typing as one that takes the story's args.
|
||||
*/
|
||||
function CatchAllPage(): JSX.Element {
|
||||
return <NotFound />;
|
||||
}
|
||||
|
||||
const pageStory = storyMocks(notFoundMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* The shell around a pathname no route matched: the side nav stays, the content
|
||||
* area carries the 404.
|
||||
*
|
||||
* Route: any unmatched path.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/System/Not Found',
|
||||
component: CatchAllPage,
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<NotFoundArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<NotFoundArgs>;
|
||||
|
||||
/**
|
||||
* What the app shows for a pathname no route matched, inside the shell: the
|
||||
* side nav is still there, and the way back is the home button.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
@@ -1,126 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import {
|
||||
QuickfiltertypesSourceDTO,
|
||||
TelemetrytypesFieldContextDTO,
|
||||
TelemetrytypesFieldDataTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { rest, type RequestHandler } from 'msw';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
import { attributeValuesResponse } from '@/storybook/msw/__story_mockdata__/attributes';
|
||||
import { fieldKeysResponse } from '@/storybook/msw/__story_mockdata__/fields';
|
||||
import { quickFiltersResponse } from '@/storybook/msw/__story_mockdata__/quickFilters';
|
||||
|
||||
import { FiltersType } from './types';
|
||||
|
||||
const customFilters = [
|
||||
{
|
||||
name: 'service.name',
|
||||
fieldDataType: TelemetrytypesFieldDataTypeDTO.string,
|
||||
fieldContext: TelemetrytypesFieldContextDTO.resource,
|
||||
},
|
||||
{
|
||||
name: 'deployment.environment',
|
||||
fieldDataType: TelemetrytypesFieldDataTypeDTO.string,
|
||||
fieldContext: TelemetrytypesFieldContextDTO.resource,
|
||||
},
|
||||
];
|
||||
|
||||
export const queryBuilder = {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filter: { expression: '' },
|
||||
filters: { items: [], op: 'AND' },
|
||||
queryName: 'Logs query',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
lastUsedQuery: 0,
|
||||
panelType: 'graph',
|
||||
redirectWithQueryBuilderData: (): void => undefined,
|
||||
setLastUsedQuery: (): void => undefined,
|
||||
};
|
||||
|
||||
export const checkboxConfig = [
|
||||
{
|
||||
attributeKey: {
|
||||
dataType: DataTypes.String,
|
||||
key: 'service.name',
|
||||
type: 'resource',
|
||||
},
|
||||
defaultOpen: true,
|
||||
title: 'Service name',
|
||||
type: FiltersType.CHECKBOX,
|
||||
},
|
||||
];
|
||||
|
||||
export const attributeValuesHandler = (
|
||||
values: readonly string[],
|
||||
): RequestHandler =>
|
||||
rest.get(
|
||||
'http://localhost/api/v3/autocomplete/attribute_values',
|
||||
(_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(attributeValuesResponse(values))),
|
||||
);
|
||||
|
||||
export const handlers = [
|
||||
rest.get('http://localhost/api/v2/quick_filters/logs', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json(
|
||||
quickFiltersResponse(QuickfiltertypesSourceDTO.logs, customFilters),
|
||||
),
|
||||
),
|
||||
),
|
||||
rest.get('http://localhost/api/v1/fields/keys', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(fieldKeysResponse(['k8s.namespace.name']))),
|
||||
),
|
||||
attributeValuesHandler(['checkout', 'frontend', 'payments']),
|
||||
];
|
||||
|
||||
export const loadingFiltersHandlers = [
|
||||
rest.get('http://localhost/api/v2/quick_filters/logs', (_req, res, ctx) =>
|
||||
res(ctx.delay('infinite')),
|
||||
),
|
||||
];
|
||||
|
||||
export const LONG_FILTER_VALUES = [
|
||||
'checkout-service.production-eu-central-1.svc.cluster.local',
|
||||
'payments-authorisation-worker.production-us-east-2.svc.cluster.local',
|
||||
'catalog-availability-projector.staging-ap-south-1.svc.cluster.local',
|
||||
];
|
||||
|
||||
export const selectedServiceQueryBuilder = {
|
||||
...queryBuilder,
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filter: { expression: '' },
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
key: 'service.name',
|
||||
type: 'resource',
|
||||
},
|
||||
op: 'in',
|
||||
value: ['checkout', 'payments'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
queryName: 'Logs query',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,120 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import type { ComponentProps, ComponentType } from 'react';
|
||||
import removeLocalStorageKey from 'api/browser/localstorage/remove';
|
||||
import setLocalStorageKey from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { screen, userEvent } from 'storybook/test';
|
||||
|
||||
import { withCanvas } from '@/storybook/decorators/withCanvas';
|
||||
import type { GlobalMockArgs } from '@/storybook/globals';
|
||||
|
||||
import QuickFilters from './QuickFilters';
|
||||
import {
|
||||
attributeValuesHandler,
|
||||
checkboxConfig,
|
||||
handlers,
|
||||
LONG_FILTER_VALUES,
|
||||
loadingFiltersHandlers,
|
||||
queryBuilder,
|
||||
selectedServiceQueryBuilder,
|
||||
} from './QuickFilters.stories.mocks';
|
||||
import { QuickFiltersSource, SignalType } from './types';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Quick Filters',
|
||||
// `QuickFilters.defaultProps` declares `onFilterChange: null` against a prop
|
||||
// typed as an optional function, so the component does not satisfy
|
||||
// `ComponentType` as written. The defaults are load-bearing for the jest
|
||||
// suite, hence the cast rather than a change to them.
|
||||
component: QuickFilters as unknown as ComponentType<
|
||||
ComponentProps<typeof QuickFilters>
|
||||
>,
|
||||
tags: ['play'],
|
||||
// The rail the explorers give it (`Explorer.styles.scss`, `.filter`).
|
||||
decorators: [withCanvas({ width: 260 })],
|
||||
args: {
|
||||
config: checkboxConfig,
|
||||
handleFilterVisibilityChange: (): void => undefined,
|
||||
signal: SignalType.LOGS,
|
||||
source: QuickFiltersSource.LOGS_EXPLORER,
|
||||
},
|
||||
parameters: {
|
||||
msw: { handlers },
|
||||
signoz: { queryBuilder },
|
||||
},
|
||||
// The settings announcement covers the panel it points at, and it is a
|
||||
// first-run state rather than the panel's own; `SettingsAnnouncement` is the
|
||||
// story that keeps it.
|
||||
beforeEach: (): void => {
|
||||
setLocalStorageKey(LOCALSTORAGE.QUICK_FILTERS_SETTINGS_ANNOUNCEMENT, 'false');
|
||||
},
|
||||
} satisfies Meta<typeof QuickFilters>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
type TooltipsStory = StoryObj<
|
||||
ComponentProps<typeof QuickFilters> & GlobalMockArgs
|
||||
>;
|
||||
|
||||
/** Interaction: the settings panel is opened through the admin settings control. */
|
||||
export const SettingsOpen: Story = {
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.click(await screen.findByTestId('settings-icon'));
|
||||
await screen.findByText('Edit quick filters');
|
||||
},
|
||||
};
|
||||
|
||||
/** Mutation: changing the settings list reveals the fixed save and discard footer. */
|
||||
export const SettingsDirtyFooter: Story = {
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.click(await screen.findByTestId('settings-icon'));
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Add' }));
|
||||
await screen.findByRole('button', { name: 'Save changes' });
|
||||
},
|
||||
};
|
||||
|
||||
/** First run: the one-off announcement pointing an admin at the settings control. */
|
||||
export const SettingsAnnouncement: Story = {
|
||||
beforeEach: (): void => {
|
||||
removeLocalStorageKey(LOCALSTORAGE.QUICK_FILTERS_SETTINGS_ANNOUNCEMENT);
|
||||
},
|
||||
};
|
||||
|
||||
/** Loading: dynamic filters are intentionally left pending to display the panel skeleton. */
|
||||
export const LoadingFilters: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: loadingFiltersHandlers,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Empty: a loaded quick-filter configuration with no filters has no result rows. */
|
||||
export const NoResults: Story = {
|
||||
args: { config: [], signal: undefined },
|
||||
};
|
||||
|
||||
/** Selection: an expanded checkbox shows the actual selected service values. */
|
||||
export const SelectedExpandedCheckbox: Story = {
|
||||
args: { signal: undefined },
|
||||
parameters: {
|
||||
signoz: {
|
||||
queryBuilder: selectedServiceQueryBuilder,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Every tooltip the panel renders, held open: the reveal on each truncated
|
||||
* filter value. Nothing bounds those values, so the panel is answered with
|
||||
* service names long enough to be cut. The Service name filter carries them, so
|
||||
* the signal that would add the workspace's own dynamic filters is left off.
|
||||
*/
|
||||
export const Tooltips: TooltipsStory = {
|
||||
args: { signal: undefined, tooltipsOpen: true },
|
||||
parameters: {
|
||||
msw: { handlers: [attributeValuesHandler(LONG_FILTER_VALUES), ...handlers] },
|
||||
},
|
||||
};
|
||||
@@ -1,64 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { withCanvas } from '@/storybook/decorators/withCanvas';
|
||||
|
||||
import { CustomSelect } from '../NewSelect';
|
||||
import SignozModal from './SignozModal';
|
||||
|
||||
function ModalFixture(): JSX.Element {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button data-testid="open-signoz-modal" onClick={(): void => setOpen(true)}>
|
||||
Open modal
|
||||
</Button>
|
||||
<SignozModal
|
||||
footer={null}
|
||||
open={open}
|
||||
onCancel={(): void => setOpen(false)}
|
||||
title="Create saved view"
|
||||
>
|
||||
<CustomSelect
|
||||
aria-label="View scope"
|
||||
options={[
|
||||
{ label: 'This workspace', value: 'workspace' },
|
||||
{ label: 'My views', value: 'personal' },
|
||||
]}
|
||||
placeholder="Select a scope"
|
||||
/>
|
||||
</SignozModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Signoz Modal',
|
||||
component: ModalFixture,
|
||||
tags: ['play'],
|
||||
decorators: [withCanvas({ maxWidth: 400 })],
|
||||
} satisfies Meta<typeof ModalFixture>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Interaction: the modal and its nested body-portal select are both genuinely open. */
|
||||
export const OpenWithNestedSelect: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const trigger = within(canvasElement).getByTestId('open-signoz-modal');
|
||||
|
||||
await userEvent.click(trigger);
|
||||
await screen.findByRole('dialog', { name: 'Create saved view' });
|
||||
await userEvent.keyboard('{Escape}');
|
||||
await waitFor(() => expect(trigger).toHaveFocus());
|
||||
await userEvent.click(trigger);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('combobox', { name: 'View scope' }),
|
||||
);
|
||||
await screen.findByRole('listbox');
|
||||
},
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
import { withCanvas } from '@/storybook/decorators/withCanvas';
|
||||
|
||||
import type { ITableV3Props } from './TableV3';
|
||||
import { TableV3 } from './TableV3';
|
||||
|
||||
type TraceRow = {
|
||||
id: string;
|
||||
traceId: string;
|
||||
service: string;
|
||||
duration: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
const columns: ColumnDef<TraceRow>[] = [
|
||||
{ accessorKey: 'traceId', header: 'Trace ID', size: 280 },
|
||||
{ accessorKey: 'service', header: 'Service', size: 220 },
|
||||
{ accessorKey: 'duration', header: 'Duration', size: 140 },
|
||||
{ accessorKey: 'status', header: 'Status', size: 160 },
|
||||
];
|
||||
|
||||
const rows: TraceRow[] = Array.from({ length: 40 }, (_, index) => ({
|
||||
id: `trace-${index + 1}`,
|
||||
traceId: `c0ffee${String(index + 1).padStart(10, '0')}7f4a9d1c`,
|
||||
service: index % 2 === 0 ? 'checkout-service' : 'catalog-service',
|
||||
duration: `${80 + index * 6} ms`,
|
||||
status: index % 5 === 0 ? 'Error' : 'OK',
|
||||
}));
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Table V3',
|
||||
component: TableV3,
|
||||
decorators: [withCanvas({ height: 360, maxWidth: 640, overflow: 'auto' })],
|
||||
args: {
|
||||
columns,
|
||||
config: { defaultColumnMinSize: 120, defaultColumnMaxSize: 400 },
|
||||
data: rows,
|
||||
setColumnWidths: (): void => undefined,
|
||||
},
|
||||
} satisfies Meta<ITableV3Props<TraceRow>>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Density and overflow: the virtualized table's wide, resizable column layout. */
|
||||
export const WideVirtualizedDataset: Story = {};
|
||||
|
||||
/** Data: the table's native no-row layout, with headers retained for structural review. */
|
||||
export const EmptyDataset: Story = {
|
||||
args: { data: [] },
|
||||
};
|
||||
@@ -71,7 +71,7 @@ interface ITableConfig {
|
||||
instance: Virtualizer<HTMLDivElement, Element>,
|
||||
) => void;
|
||||
}
|
||||
export interface ITableV3Props<T> {
|
||||
interface ITableV3Props<T> {
|
||||
columns: ColumnDef<T, any>[];
|
||||
data: T[];
|
||||
config: ITableConfig;
|
||||
@@ -201,5 +201,5 @@ export function TableV3<T>(props: ITableV3Props<T>): JSX.Element {
|
||||
|
||||
TableV3.defaultProps = {
|
||||
customClassName: '',
|
||||
virtualiserRef: undefined,
|
||||
virtualiserRef: null,
|
||||
};
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Ellipsis } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
|
||||
import { GroupedStatusCounts } from 'container/InfraMonitoringK8sV2/components/GroupedStatusCounts';
|
||||
import type { StatusCountItem } from 'container/InfraMonitoringK8sV2/components/GroupedStatusCounts';
|
||||
import { ValidateColumnValueWrapper } from 'container/InfraMonitoringK8sV2/components/ValidateColumnValueWrapper';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import { expect, screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import { withCanvas } from '@/storybook/decorators/withCanvas';
|
||||
import type { GlobalMockArgs } from '@/storybook/globals';
|
||||
|
||||
import TanStackTable from './index';
|
||||
import type { TableColumnDef, TanStackTableProps } from './types';
|
||||
|
||||
type ServiceRow = {
|
||||
id: string;
|
||||
service: string;
|
||||
endpoint: string;
|
||||
latency: string;
|
||||
owner: string;
|
||||
};
|
||||
|
||||
const rows: ServiceRow[] = [
|
||||
{
|
||||
id: 'checkout',
|
||||
service: 'checkout-service',
|
||||
endpoint: 'POST /api/v1/checkout',
|
||||
latency: '184 ms',
|
||||
owner: 'Payments platform',
|
||||
},
|
||||
{
|
||||
id: 'catalog',
|
||||
service: 'catalog-service',
|
||||
endpoint: 'GET /api/v2/products/{productId}/availability',
|
||||
latency: '96 ms',
|
||||
owner: 'Storefront experience',
|
||||
},
|
||||
{
|
||||
id: 'identity',
|
||||
service: 'identity-service',
|
||||
endpoint: 'POST /api/v1/session/refresh',
|
||||
latency: '242 ms',
|
||||
owner: 'Identity and access management',
|
||||
},
|
||||
];
|
||||
|
||||
const columns: TableColumnDef<ServiceRow>[] = [
|
||||
{
|
||||
id: 'service',
|
||||
header: 'Service',
|
||||
accessorKey: 'service',
|
||||
pin: 'left',
|
||||
width: { fixed: 180 },
|
||||
enableSort: true,
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<TanStackTable.Text>{String(value)}</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'endpoint',
|
||||
header: 'Endpoint',
|
||||
accessorKey: 'endpoint',
|
||||
width: { fixed: 320 },
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<TanStackTable.Text title={String(value)}>
|
||||
{String(value)}
|
||||
</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'latency',
|
||||
header: 'P95 latency',
|
||||
accessorKey: 'latency',
|
||||
width: { fixed: 140 },
|
||||
enableSort: true,
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<TanStackTable.Text>{String(value)}</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'owner',
|
||||
header: 'Owner',
|
||||
accessorKey: 'owner',
|
||||
width: { fixed: 240 },
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<TanStackTable.Text>{String(value)}</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const rowActions = (): JSX.Element => (
|
||||
<DropdownMenuSimple
|
||||
align="end"
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'open', label: 'Open service details' },
|
||||
{ key: 'copy', label: 'Copy service link' },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
aria-label="Service actions"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
variant="outlined"
|
||||
>
|
||||
<Ellipsis size={16} />
|
||||
</Button>
|
||||
</DropdownMenuSimple>
|
||||
);
|
||||
|
||||
const meta = {
|
||||
title: 'Components/TanStack Table View',
|
||||
component: TanStackTable,
|
||||
tags: ['play'],
|
||||
decorators: [withCanvas({ height: 360, maxWidth: 640 })],
|
||||
args: {
|
||||
columns,
|
||||
data: rows,
|
||||
disableVirtualScroll: true,
|
||||
getRowKey: (row): string => row.id,
|
||||
},
|
||||
} satisfies Meta<TanStackTableProps<ServiceRow>>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
type TooltipsStory = StoryObj<GlobalMockArgs>;
|
||||
|
||||
/** Data and overflow: pinned columns, clipped long cells, and a horizontal scroll surface. */
|
||||
export const HorizontalOverflow: Story = {
|
||||
args: { testId: 'tanstack-table' },
|
||||
};
|
||||
|
||||
/** Data: a page-sized result with the shared pagination controls and total count. */
|
||||
export const Pagination: Story = {
|
||||
args: {
|
||||
pagination: { total: 42, defaultLimit: 10, showTotalCount: true },
|
||||
},
|
||||
};
|
||||
|
||||
/** Data: the supported empty result keeps the table structure without a fabricated empty state. */
|
||||
export const Empty: Story = {
|
||||
args: { data: [], testId: 'tanstack-empty-table' },
|
||||
};
|
||||
|
||||
/** Data: the table's real skeleton rows shown while the first page is loading. */
|
||||
export const Loading: Story = {
|
||||
args: { data: [], isLoading: true, skeletonRowCount: 5 },
|
||||
};
|
||||
|
||||
/** Interaction: opens a row action menu rendered through the shared portal. */
|
||||
export const RowActionsMenu: Story = {
|
||||
args: { renderRowActions: rowActions, testId: 'tanstack-actions-table' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const firstRow = within(canvasElement).getByTestId('tanstack-actions-table');
|
||||
|
||||
await userEvent.hover(firstRow.querySelector('tbody tr') as HTMLElement);
|
||||
await userEvent.click(
|
||||
await within(firstRow).findByLabelText('Service actions'),
|
||||
);
|
||||
await expect(
|
||||
await screen.findByRole('menuitem', { name: 'Open service details' }),
|
||||
).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
const RESTART_COUNTS: StatusCountItem[] = [
|
||||
{
|
||||
label: 'Restarts in the last 24 hours',
|
||||
value: 37,
|
||||
color: Color.BG_CHERRY_500,
|
||||
breakdown: [
|
||||
{ label: 'CrashLoopBackOff', value: 14 },
|
||||
{ label: 'OOMKilled', value: 11 },
|
||||
{ label: 'Liveness probe failed', value: 6 },
|
||||
{ label: 'Readiness probe failed', value: 4 },
|
||||
{ label: 'Image pull backoff', value: 2 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const tooltipColumns: TableColumnDef<ServiceRow>[] = [
|
||||
columns[0],
|
||||
{
|
||||
id: 'cpuRequest',
|
||||
header: 'CPU request',
|
||||
width: { fixed: 140 },
|
||||
cell: ({ rowId }): JSX.Element => (
|
||||
<ValidateColumnValueWrapper
|
||||
attribute="CPU request"
|
||||
entity={InfraMonitoringEntity.PODS}
|
||||
rowId={rowId}
|
||||
value={-1}
|
||||
>
|
||||
<TanStackTable.Text>0.5</TanStackTable.Text>
|
||||
</ValidateColumnValueWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'restarts',
|
||||
header: 'Restarts',
|
||||
width: { fixed: 140 },
|
||||
cell: ({ rowId }): JSX.Element => (
|
||||
<GroupedStatusCounts items={RESTART_COUNTS} rowId={rowId} />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Both tooltips a hovered row carries, held open: the plain sentence explaining
|
||||
* a missing value, and the status breakdown, which is elements rather than text
|
||||
* and grows a row per reason. Neither is rendered until the row is hovered, so
|
||||
* the play hovers the first one and the control holds what it uncovered.
|
||||
*/
|
||||
export const Tooltips: TooltipsStory = {
|
||||
args: { tooltipsOpen: true },
|
||||
render: (): JSX.Element => (
|
||||
<TanStackTable
|
||||
columns={tooltipColumns}
|
||||
data={rows}
|
||||
disableVirtualScroll
|
||||
getRowKey={(row): string => row.id}
|
||||
testId="tanstack-tooltips-table"
|
||||
/>
|
||||
),
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const table = within(canvasElement).getByTestId('tanstack-tooltips-table');
|
||||
|
||||
await userEvent.hover(table.querySelector('tbody tr') as HTMLElement);
|
||||
// Both tooltips are rendered by the hovered row, so this is what says the
|
||||
// control has something to hold open.
|
||||
await screen.findByText('Restarts in the last 24 hours');
|
||||
},
|
||||
};
|
||||
@@ -450,12 +450,6 @@ export default function ChatInput({
|
||||
return;
|
||||
}
|
||||
el.style.height = 'auto';
|
||||
// A hidden composer (a closed drawer, a story swapping in) measures 0.
|
||||
// Leaving the height on `auto` keeps the `rows` fallback until there is
|
||||
// something real to measure, instead of pinning the field shut.
|
||||
if (el.scrollHeight === 0) {
|
||||
return;
|
||||
}
|
||||
el.style.height = `${Math.min(el.scrollHeight, TEXTAREA_MAX_HEIGHT_PX)}px`;
|
||||
}, [text]);
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
@use '../../../../styles/scrollbar' as *;
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
// Let the flex children shrink below their content height so the series list
|
||||
// scrolls within the capped legend height instead of overflowing the wrapper
|
||||
// (the default min-height:auto would block the shrink).
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.scroller {
|
||||
// flex:1 + min-height:0 pins the scroller to the space left after the
|
||||
// toolbar instead of growing to fit every row.
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding-right: var(--spacing-2);
|
||||
overflow-x: hidden;
|
||||
overscroll-behavior: contain;
|
||||
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
.gridItem {
|
||||
// Or the item keeps its content width and the label never ellipsizes.
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.gridList {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-auto-flow: row;
|
||||
// min() keeps the column inside a narrow panel, where a wider one would push
|
||||
// the row's actions out of the clipped area.
|
||||
grid-template-columns: repeat(
|
||||
auto-fill,
|
||||
minmax(min(var(--legend-item-width, 240px), 100%), 1fr)
|
||||
);
|
||||
gap: var(--spacing-1) var(--spacing-4);
|
||||
}
|
||||
|
||||
.container.isRight .gridList {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
padding: var(--spacing-16) 0;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l3-foreground);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
@use '../../../../styles/scrollbar' as *;
|
||||
|
||||
.legend-search-container {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
padding-right: 8px;
|
||||
|
||||
.legend-search-input {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.legend-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
// Allow the flex children to shrink below their content height so the
|
||||
// virtualized grid scrolls within the capped legend height instead of
|
||||
// overflowing the wrapper (default min-height:auto would block the shrink).
|
||||
min-height: 0;
|
||||
|
||||
&:has(.legend-item-focused) .legend-item {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
&:has(.legend-item-focused) .legend-item.legend-item-focused {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.legend-empty-state {
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.legend-virtuoso-container {
|
||||
// flex:1 + min-height:0 pins the scroller to the space left after the
|
||||
// search box (RIGHT legend) and lets it scroll instead of growing to fit
|
||||
// every row — without this the grid overflows a BOTTOM legend's fixed height.
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
.virtuoso-grid-list {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-auto-flow: row;
|
||||
grid-template-columns: repeat(
|
||||
auto-fill,
|
||||
minmax(var(--legend-average-width, 240px), 1fr)
|
||||
);
|
||||
column-gap: 12px;
|
||||
}
|
||||
|
||||
.virtuoso-grid-item {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&.legend-virtuoso-container-right {
|
||||
.virtuoso-grid-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
&.legend-virtuoso-container-single-row {
|
||||
.virtuoso-grid-list {
|
||||
grid-template-columns: repeat(
|
||||
auto-fit,
|
||||
minmax(var(--legend-average-width, 240px), max-content)
|
||||
);
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
}
|
||||
|
||||
.legend-row {
|
||||
padding: 4px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
|
||||
&.legend-single-row {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&.legend-row-right {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
&.legend-row-bottom {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
// Include padding within the width so a full-width row (legend-item-right) fits its
|
||||
// column instead of overflowing by the 16px horizontal padding — there is no global
|
||||
// border-box reset, so the default content-box would make it overflow.
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
|
||||
&.legend-item-right {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&.legend-item-off {
|
||||
opacity: 0.3;
|
||||
text-decoration: line-through;
|
||||
text-decoration-thickness: 1px;
|
||||
}
|
||||
|
||||
&.legend-item-focused {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.legend-item-label-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.legend-marker {
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
border-radius: 50%;
|
||||
min-width: 11px;
|
||||
min-height: 11px;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.2);
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.legend-label {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.legend-copy-button {
|
||||
// Always laid out (space reserved) but transparent, so revealing it on
|
||||
// hover fades the icon in without reflowing the row / shifting the label.
|
||||
// Shrink the shared icon Button (defaults to a 2rem square) to the
|
||||
// compact legend row via its size tokens.
|
||||
--button-height: auto;
|
||||
--button-width: auto;
|
||||
--button-padding: 2px;
|
||||
|
||||
opacity: 0;
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
border-radius: 4px;
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--l3-background);
|
||||
.legend-copy-button {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,139 +1,106 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { VirtuosoGrid } from 'react-virtuoso';
|
||||
import { Input } from 'antd';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import cx from 'classnames';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
|
||||
import { LegendPosition, LegendProps } from '../types';
|
||||
import { LegendAction, LegendPosition, LegendProps } from '../types';
|
||||
|
||||
import './Legend.styles.scss';
|
||||
import { LEGEND_ITEM_EXTRA_WIDTH, MAX_LEGEND_WIDTH } from './constants';
|
||||
import LegendRow from './LegendRow';
|
||||
import LegendToolbar from './LegendToolbar';
|
||||
import { filterLegendItems, getShownSeriesState } from './utils';
|
||||
|
||||
export const MAX_LEGEND_WIDTH = 240;
|
||||
import styles from './Legend.module.scss';
|
||||
|
||||
/**
|
||||
* Presentational legend. Renders the supplied `items` (markers + labels, an
|
||||
* optional copy button, and a search box for the RIGHT position) and delegates
|
||||
* all interaction to the container handlers. Source-agnostic — the uPlot
|
||||
* charts feed it via UPlotLegend; Pie feeds it directly.
|
||||
* Presentational legend, source-agnostic: the uPlot charts feed it via
|
||||
* UPlotLegend, Pie feeds it directly. Every state change is delegated.
|
||||
*/
|
||||
export default function Legend({
|
||||
items,
|
||||
position,
|
||||
averageLegendWidth = MAX_LEGEND_WIDTH,
|
||||
focusedSeriesIndex,
|
||||
onClick,
|
||||
onMouseMove,
|
||||
onMouseLeave,
|
||||
onAction,
|
||||
showCopy = true,
|
||||
}: LegendProps): JSX.Element {
|
||||
const legendContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [legendSearchQuery, setLegendSearchQuery] = useState('');
|
||||
const [filterQuery, setFilterQuery] = useState('');
|
||||
|
||||
// Search is intrinsic to the right-positioned legend.
|
||||
const searchEnabled = position === LegendPosition.RIGHT;
|
||||
const { width: containerWidth } = useResizeObserver(legendContainerRef);
|
||||
const itemWidth = averageLegendWidth + LEGEND_ITEM_EXTRA_WIDTH;
|
||||
const isRightPosition = position === LegendPosition.RIGHT;
|
||||
|
||||
const isSingleRow = useMemo(() => {
|
||||
if (position !== LegendPosition.BOTTOM || containerWidth <= 0) {
|
||||
return false;
|
||||
}
|
||||
const totalLegendWidth = items.length * (averageLegendWidth + 16);
|
||||
const totalRows = Math.ceil(totalLegendWidth / containerWidth);
|
||||
return totalRows <= 1;
|
||||
}, [averageLegendWidth, items.length, position, containerWidth]);
|
||||
|
||||
const visibleLegendItems = useMemo(() => {
|
||||
if (!searchEnabled || !legendSearchQuery.trim()) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const query = legendSearchQuery.trim().toLowerCase();
|
||||
return items.filter((item) => item.label?.toLowerCase().includes(query));
|
||||
}, [searchEnabled, legendSearchQuery, items]);
|
||||
|
||||
const renderLegendItem = useCallback(
|
||||
(item: LegendItem): JSX.Element => {
|
||||
// `color` is uPlot's stroke union (string | fn | gradient); only a string
|
||||
// is a usable CSS colour for the marker.
|
||||
const markerColor = typeof item.color === 'string' ? item.color : undefined;
|
||||
return (
|
||||
<div
|
||||
key={item.seriesIndex}
|
||||
data-legend-item-id={item.seriesIndex}
|
||||
className={cx('legend-item', `legend-item-${position.toLowerCase()}`, {
|
||||
'legend-item-off': !item.show,
|
||||
'legend-item-focused': focusedSeriesIndex === item.seriesIndex,
|
||||
})}
|
||||
>
|
||||
<TooltipSimple title={item.label} arrow side="top" disableHoverableContent>
|
||||
<div className="legend-item-label-trigger">
|
||||
<div
|
||||
className="legend-marker"
|
||||
style={{ borderColor: markerColor }}
|
||||
data-is-legend-marker={true}
|
||||
/>
|
||||
<span className="legend-label">{item.label}</span>
|
||||
</div>
|
||||
</TooltipSimple>
|
||||
{showCopy && (
|
||||
<CopyButton
|
||||
value={item.label ?? ''}
|
||||
size={12}
|
||||
className="legend-copy-button"
|
||||
ariaLabel={`Copy ${item.label}`}
|
||||
testId="legend-copy"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[focusedSeriesIndex, position, showCopy],
|
||||
const { visibleCount, soleShownSeriesIndex } = useMemo(
|
||||
() => getShownSeriesState(items),
|
||||
[items],
|
||||
);
|
||||
|
||||
const isEmptyState = useMemo(() => {
|
||||
if (!searchEnabled || !legendSearchQuery.trim()) {
|
||||
return false;
|
||||
}
|
||||
return visibleLegendItems.length === 0;
|
||||
}, [searchEnabled, legendSearchQuery, visibleLegendItems]);
|
||||
// A bottom legend gets two rows; spending one on chrome costs more chart than
|
||||
// the readout is worth.
|
||||
const showToolbar = isRightPosition && items.length > 0;
|
||||
const showFilter = showToolbar;
|
||||
|
||||
const effectiveQuery = showFilter ? filterQuery : '';
|
||||
|
||||
const visibleLegendItems = useMemo(
|
||||
() => filterLegendItems(items, effectiveQuery),
|
||||
[items, effectiveQuery],
|
||||
);
|
||||
|
||||
const isEmptyState =
|
||||
!!effectiveQuery.trim() && visibleLegendItems.length === 0;
|
||||
|
||||
const isAllShown = visibleCount === items.length;
|
||||
|
||||
// A row that unmounts under the pointer never fires its own mouseleave.
|
||||
const handleMouseLeave = useCallback(
|
||||
(): void => onAction({ type: LegendAction.HOVER, seriesIndex: null }),
|
||||
[onAction],
|
||||
);
|
||||
|
||||
const renderLegendItem = useCallback(
|
||||
(item: LegendItem): JSX.Element => (
|
||||
<LegendRow
|
||||
key={item.seriesIndex}
|
||||
item={item}
|
||||
isSoleShown={soleShownSeriesIndex === item.seriesIndex}
|
||||
isAllShown={isAllShown}
|
||||
isFocused={focusedSeriesIndex === item.seriesIndex}
|
||||
showCopy={showCopy}
|
||||
onAction={onAction}
|
||||
/>
|
||||
),
|
||||
[soleShownSeriesIndex, isAllShown, focusedSeriesIndex, showCopy, onAction],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={legendContainerRef}
|
||||
className="legend-container"
|
||||
onClick={onClick}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseLeave={onMouseLeave}
|
||||
style={{
|
||||
['--legend-average-width' as string]: `${averageLegendWidth + 16}px`, // 16px is the marker width
|
||||
}}
|
||||
className={cx(styles.container, {
|
||||
[styles.isRight]: isRightPosition,
|
||||
})}
|
||||
style={{ ['--legend-item-width' as string]: `${itemWidth}px` }}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
data-testid="legend-container"
|
||||
>
|
||||
{searchEnabled && (
|
||||
<div className="legend-search-container">
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="Search..."
|
||||
value={legendSearchQuery}
|
||||
onChange={(e): void => setLegendSearchQuery(e.target.value)}
|
||||
data-testid="legend-search-input"
|
||||
className="legend-search-input"
|
||||
/>
|
||||
</div>
|
||||
{showToolbar && (
|
||||
<LegendToolbar
|
||||
visibleCount={visibleCount}
|
||||
totalCount={items.length}
|
||||
showFilter={showFilter}
|
||||
filterQuery={filterQuery}
|
||||
onFilterQueryChange={setFilterQuery}
|
||||
/>
|
||||
)}
|
||||
{isEmptyState ? (
|
||||
<div className="legend-empty-state">
|
||||
No series found matching "{legendSearchQuery}"
|
||||
<div className={styles.emptyState}>
|
||||
No series found matching "{effectiveQuery}"
|
||||
</div>
|
||||
) : (
|
||||
<VirtuosoGrid
|
||||
className={cx(
|
||||
'legend-virtuoso-container',
|
||||
`legend-virtuoso-container-${position.toLowerCase()}`,
|
||||
{ 'legend-virtuoso-container-single-row': isSingleRow },
|
||||
)}
|
||||
className={styles.scroller}
|
||||
listClassName={styles.gridList}
|
||||
itemClassName={styles.gridItem}
|
||||
data={visibleLegendItems}
|
||||
itemContent={(_, item): JSX.Element => renderLegendItem(item)}
|
||||
/>
|
||||
|
||||
170
frontend/src/lib/uPlotV2/components/Legend/LegendRow.module.scss
Normal file
170
frontend/src/lib/uPlotV2/components/Legend/LegendRow.module.scss
Normal file
@@ -0,0 +1,170 @@
|
||||
.row {
|
||||
// Width of the revealed actions, given up by the label on hover only.
|
||||
--legend-actions-reserve: 78px;
|
||||
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
height: 28px;
|
||||
padding: 0 var(--spacing-3) 0 var(--spacing-4);
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: background 160ms linear;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: var(--l3-background);
|
||||
}
|
||||
}
|
||||
|
||||
.isFocused {
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.marker {
|
||||
// Reads as a checkbox without being one: filled when shown, hollow when
|
||||
// hidden, deliberately not a check glyph.
|
||||
flex: 0 0 auto;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
// Above the actions, so a narrow row's chip never covers the series colour.
|
||||
z-index: 4;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
padding: 0;
|
||||
appearance: none;
|
||||
border-width: 1.5px;
|
||||
border-style: solid;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 200ms ease,
|
||||
box-shadow 200ms ease,
|
||||
background-color 160ms linear,
|
||||
opacity 160ms linear;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.2);
|
||||
box-shadow: 0 0 0 2px
|
||||
color-mix(in srgb, var(--l1-foreground) 30%, transparent);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&:disabled:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Series names run long and have no spaces to break on, so they need both a
|
||||
// cap and a break rule or the tooltip becomes one panel-wide line.
|
||||
.rowTooltip {
|
||||
max-width: 420px;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1 1 auto;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--l2-foreground);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.isHidden .marker {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.isHidden .label {
|
||||
color: var(--l3-foreground);
|
||||
text-decoration: line-through;
|
||||
text-decoration-thickness: 1px;
|
||||
}
|
||||
|
||||
/* Row actions */
|
||||
|
||||
.actions {
|
||||
position: absolute;
|
||||
top: var(--spacing-2);
|
||||
right: var(--spacing-3);
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
padding-left: var(--spacing-5, 10px);
|
||||
// Sits on the row's hover background and masks the label's tail behind it.
|
||||
background: var(--l3-background);
|
||||
box-shadow: -8px 0 8px var(--l3-background);
|
||||
opacity: 0;
|
||||
transform: translateX(10px);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 180ms cubic-bezier(0.08, 0.52, 0.52, 1),
|
||||
transform 180ms cubic-bezier(0.08, 0.52, 0.52, 1);
|
||||
}
|
||||
|
||||
// :focus-visible, not :focus-within — the latter also matches the click that
|
||||
// just toggled the series, leaving the actions stuck open.
|
||||
.row:hover .actions,
|
||||
.row:focus-visible .actions,
|
||||
.row:has(:focus-visible) .actions {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
// The cap spares rows sized to their actions, not their label.
|
||||
.row:hover .label,
|
||||
.row:focus-visible .label,
|
||||
.row:has(:focus-visible) .label {
|
||||
padding-right: min(var(--legend-actions-reserve), 50%);
|
||||
}
|
||||
|
||||
.actionTrigger {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
--button-height: 20px;
|
||||
--button-width: 20px;
|
||||
--button-padding: 0;
|
||||
--button-variant-ghost-color: var(--l3-foreground);
|
||||
--button-variant-ghost-hover-color: var(--l1-foreground);
|
||||
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.actionButton.scopeButton {
|
||||
--button-width: auto;
|
||||
--button-padding: 0 var(--spacing-4);
|
||||
--button-font-size: var(--font-size-xs);
|
||||
--button-border-radius: calc(var(--radius) * 4);
|
||||
--button-base-border-width: 1px;
|
||||
// --l2-border is the actions bar's own background: one step up reads.
|
||||
--button-base-border-color: var(--l3-border);
|
||||
|
||||
border-style: solid;
|
||||
}
|
||||
.actionButton.scopeButton:hover {
|
||||
border-color: var(--l2-border);
|
||||
}
|
||||
182
frontend/src/lib/uPlotV2/components/Legend/LegendRow.tsx
Normal file
182
frontend/src/lib/uPlotV2/components/Legend/LegendRow.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { KeyboardEvent, memo, MouseEvent, useCallback } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import cx from 'classnames';
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
|
||||
import { LegendAction, OnLegendAction } from '../types';
|
||||
|
||||
import { LEGEND_TOOLTIP_DELAY_MS } from './constants';
|
||||
import styles from './LegendRow.module.scss';
|
||||
|
||||
export interface LegendRowProps {
|
||||
item: LegendItem;
|
||||
/** The only series currently shown, so hiding it is refused. */
|
||||
isSoleShown: boolean;
|
||||
/** Nothing is hidden, so the row's action can only narrow the selection. */
|
||||
isAllShown: boolean;
|
||||
isFocused: boolean;
|
||||
showCopy: boolean;
|
||||
onAction: OnLegendAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* One legend row. The marker is its own target for excluding a single series —
|
||||
* the one thing the row click can't do while everything is showing. The actions
|
||||
* overlay the label's tail rather than taking layout width, and their reveal is
|
||||
* pure CSS.
|
||||
*/
|
||||
function LegendRow({
|
||||
item,
|
||||
isSoleShown,
|
||||
isAllShown,
|
||||
isFocused,
|
||||
showCopy,
|
||||
onAction,
|
||||
}: LegendRowProps): JSX.Element {
|
||||
const { seriesIndex, show } = item;
|
||||
const label = item.label ?? '';
|
||||
const isShowAllAction = show && !isAllShown;
|
||||
const scopeActionLabel = isShowAllAction
|
||||
? 'Show all series'
|
||||
: 'Show only current series';
|
||||
// `color` is uPlot's stroke union (string | fn | gradient); only a string is
|
||||
// a usable CSS colour for the marker.
|
||||
const seriesColor = typeof item.color === 'string' ? item.color : undefined;
|
||||
|
||||
/** Everything showing -> isolate; showing alone -> restore all. */
|
||||
const handleRowClick = useCallback((): void => {
|
||||
if (isSoleShown) {
|
||||
onAction({ type: LegendAction.SHOW_ALL });
|
||||
return;
|
||||
}
|
||||
onAction({
|
||||
type: isAllShown ? LegendAction.SHOW_ONLY : LegendAction.TOGGLE,
|
||||
seriesIndex,
|
||||
});
|
||||
}, [isSoleShown, isAllShown, onAction, seriesIndex]);
|
||||
|
||||
const handleMarkerClick = useCallback(
|
||||
(event: MouseEvent<HTMLButtonElement>): void => {
|
||||
event.stopPropagation();
|
||||
onAction({ type: LegendAction.TOGGLE, seriesIndex });
|
||||
},
|
||||
[onAction, seriesIndex],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
// Let the row actions handle their own keys.
|
||||
if (event.target !== event.currentTarget) {
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleRowClick();
|
||||
}
|
||||
},
|
||||
[handleRowClick],
|
||||
);
|
||||
|
||||
const handleScopeClick = useCallback(
|
||||
(event: MouseEvent<HTMLButtonElement>): void => {
|
||||
event.stopPropagation();
|
||||
if (isShowAllAction) {
|
||||
onAction({ type: LegendAction.SHOW_ALL });
|
||||
return;
|
||||
}
|
||||
onAction({ type: LegendAction.SHOW_ONLY, seriesIndex });
|
||||
},
|
||||
[isShowAllAction, onAction, seriesIndex],
|
||||
);
|
||||
|
||||
const handleMouseEnter = useCallback(
|
||||
(): void => onAction({ type: LegendAction.HOVER, seriesIndex }),
|
||||
[onAction, seriesIndex],
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback(
|
||||
(): void => onAction({ type: LegendAction.HOVER, seriesIndex: null }),
|
||||
[onAction],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.row, {
|
||||
[styles.isHidden]: !show,
|
||||
[styles.isFocused]: isFocused,
|
||||
})}
|
||||
data-legend-item-id={seriesIndex}
|
||||
data-testid={`legend-item-${seriesIndex}`}
|
||||
role="switch"
|
||||
tabIndex={0}
|
||||
aria-checked={show}
|
||||
aria-label={label}
|
||||
onClick={handleRowClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.marker}
|
||||
style={{
|
||||
borderColor: seriesColor,
|
||||
backgroundColor: show ? seriesColor : 'transparent',
|
||||
}}
|
||||
onClick={handleMarkerClick}
|
||||
disabled={isSoleShown}
|
||||
aria-label={`${show ? 'Hide' : 'Show'} ${label}`}
|
||||
data-is-legend-marker={true}
|
||||
data-testid={`legend-marker-${seriesIndex}`}
|
||||
/>
|
||||
<TooltipSimple
|
||||
title={label}
|
||||
arrow
|
||||
side="top"
|
||||
delayDuration={LEGEND_TOOLTIP_DELAY_MS}
|
||||
disableHoverableContent
|
||||
tooltipContentProps={{ className: styles.rowTooltip }}
|
||||
>
|
||||
<span className={styles.label}>{label}</span>
|
||||
</TooltipSimple>
|
||||
<div className={styles.actions}>
|
||||
<TooltipSimple
|
||||
title={scopeActionLabel}
|
||||
arrow
|
||||
side="top"
|
||||
delayDuration={LEGEND_TOOLTIP_DELAY_MS}
|
||||
disableHoverableContent
|
||||
tooltipContentProps={{ className: styles.rowTooltip }}
|
||||
>
|
||||
{/* Radix's asChild merge strips the button's own data-testid. */}
|
||||
<span className={styles.actionTrigger}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className={cx(styles.actionButton, styles.scopeButton)}
|
||||
onClick={handleScopeClick}
|
||||
aria-label={scopeActionLabel}
|
||||
testId={`legend-scope-${seriesIndex}`}
|
||||
>
|
||||
{isShowAllAction ? 'All' : 'Only'}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
{showCopy && (
|
||||
<CopyButton
|
||||
value={label}
|
||||
size={13}
|
||||
className={styles.actionButton}
|
||||
ariaLabel={`Copy ${label}`}
|
||||
testId={`legend-copy-${seriesIndex}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(LegendRow);
|
||||
@@ -0,0 +1,35 @@
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-5, 10px);
|
||||
padding: 0 var(--spacing-4) var(--spacing-5, 10px);
|
||||
flex-shrink: 0;
|
||||
|
||||
> * {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
// Wraps rather than losing the count at its end.
|
||||
min-width: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--periscope-font-size-small);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.searchContainer {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
padding-right: var(--spacing-4);
|
||||
padding-bottom: var(--spacing-5, 10px);
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
56
frontend/src/lib/uPlotV2/components/Legend/LegendToolbar.tsx
Normal file
56
frontend/src/lib/uPlotV2/components/Legend/LegendToolbar.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { ChangeEvent, useCallback } from 'react';
|
||||
import { Input } from 'antd';
|
||||
import { Search } from '@signozhq/icons';
|
||||
|
||||
import styles from './LegendToolbar.module.scss';
|
||||
|
||||
export interface LegendToolbarProps {
|
||||
visibleCount: number;
|
||||
totalCount: number;
|
||||
/** Search is intrinsic to the right-positioned legend. */
|
||||
showFilter: boolean;
|
||||
filterQuery: string;
|
||||
onFilterQueryChange: (query: string) => void;
|
||||
}
|
||||
|
||||
/** Legend chrome: the series search box and the "Showing N of M" readout. */
|
||||
export default function LegendToolbar({
|
||||
visibleCount,
|
||||
totalCount,
|
||||
showFilter,
|
||||
filterQuery,
|
||||
onFilterQueryChange,
|
||||
}: LegendToolbarProps): JSX.Element {
|
||||
const handleFilterChange = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>): void =>
|
||||
onFilterQueryChange(event.target.value),
|
||||
[onFilterQueryChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showFilter && (
|
||||
<div className={styles.searchContainer}>
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<Search size={12} className={styles.searchIcon} />}
|
||||
placeholder="Search..."
|
||||
value={filterQuery}
|
||||
onChange={handleFilterChange}
|
||||
className={styles.searchInput}
|
||||
data-testid="legend-search-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.toolbar}>
|
||||
<span
|
||||
className={styles.status}
|
||||
aria-live="polite"
|
||||
data-testid="legend-status"
|
||||
>
|
||||
{`Showing ${visibleCount} of ${totalCount} series`}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -8,8 +8,8 @@ import Legend from './Legend';
|
||||
|
||||
/**
|
||||
* uPlot legend controller. Derives the legend items + focus/visibility state
|
||||
* from the chart config (useLegendsSync) and the toggle/focus interactions from
|
||||
* the plot context (useLegendActions), then renders the presentational Legend.
|
||||
* from the chart config (useLegendsSync) and the series interactions from the
|
||||
* plot context (useLegendActions), then renders the presentational Legend.
|
||||
* Must be rendered inside a PlotContextProvider.
|
||||
*/
|
||||
export default function UPlotLegend({
|
||||
@@ -17,13 +17,8 @@ export default function UPlotLegend({
|
||||
config,
|
||||
averageLegendWidth,
|
||||
}: UPlotLegendProps): JSX.Element {
|
||||
const { legendItemsMap, focusedSeriesIndex, setFocusedSeriesIndex } =
|
||||
useLegendsSync({ config });
|
||||
const { onLegendClick, onLegendMouseMove, onLegendMouseLeave } =
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex,
|
||||
focusedSeriesIndex,
|
||||
});
|
||||
const { legendItemsMap, focusedSeriesIndex } = useLegendsSync({ config });
|
||||
const onAction = useLegendActions();
|
||||
|
||||
const items = useMemo(() => Object.values(legendItemsMap), [legendItemsMap]);
|
||||
|
||||
@@ -33,9 +28,7 @@ export default function UPlotLegend({
|
||||
position={position}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
focusedSeriesIndex={focusedSeriesIndex}
|
||||
onClick={onLegendClick}
|
||||
onMouseMove={onLegendMouseMove}
|
||||
onMouseLeave={onLegendMouseLeave}
|
||||
onAction={onAction}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
import React from 'react';
|
||||
import { render, RenderResult, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
|
||||
|
||||
import { useLegendActions } from '../../../hooks/useLegendActions';
|
||||
import UPlotLegend from '../UPlotLegend';
|
||||
import { LegendAction, LegendActionPayload, LegendPosition } from '../../types';
|
||||
|
||||
jest.mock('react-virtuoso', () => ({
|
||||
VirtuosoGrid: ({
|
||||
data,
|
||||
itemContent,
|
||||
className,
|
||||
}: {
|
||||
data: LegendItem[];
|
||||
itemContent: (index: number, item: LegendItem) => React.ReactNode;
|
||||
className?: string;
|
||||
}): JSX.Element => (
|
||||
<div data-testid="virtuoso-grid" className={className}>
|
||||
{data.map((item, index) => (
|
||||
<div key={item.seriesIndex ?? index} data-testid="legend-item-wrapper">
|
||||
{itemContent(index, item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('lib/uPlotV2/hooks/useLegendsSync');
|
||||
jest.mock('lib/uPlotV2/hooks/useLegendActions');
|
||||
|
||||
const mockUseLegendsSync = useLegendsSync as jest.MockedFunction<
|
||||
typeof useLegendsSync
|
||||
>;
|
||||
const mockUseLegendActions = useLegendActions as jest.MockedFunction<
|
||||
typeof useLegendActions
|
||||
>;
|
||||
|
||||
/** The payloads of one action type, in dispatch order. */
|
||||
const dispatched = (
|
||||
onAction: jest.Mock,
|
||||
type: LegendAction,
|
||||
): LegendActionPayload[] =>
|
||||
onAction.mock.calls
|
||||
.map(([payload]) => payload as LegendActionPayload)
|
||||
.filter((payload) => payload.type === type);
|
||||
|
||||
describe('UPlotLegend', () => {
|
||||
const baseLegendItemsMap = {
|
||||
0: {
|
||||
seriesIndex: 0,
|
||||
label: 'A',
|
||||
show: true,
|
||||
color: '#ff0000',
|
||||
},
|
||||
1: {
|
||||
seriesIndex: 1,
|
||||
label: 'B',
|
||||
show: false,
|
||||
color: '#00ff00',
|
||||
},
|
||||
2: {
|
||||
seriesIndex: 2,
|
||||
label: 'C',
|
||||
show: true,
|
||||
color: '#0000ff',
|
||||
},
|
||||
};
|
||||
|
||||
let onAction: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
onAction = jest.fn();
|
||||
|
||||
mockUseLegendsSync.mockReturnValue({
|
||||
legendItemsMap: baseLegendItemsMap,
|
||||
focusedSeriesIndex: 1,
|
||||
setFocusedSeriesIndex: jest.fn(),
|
||||
});
|
||||
|
||||
mockUseLegendActions.mockReturnValue(onAction);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderLegend = (position?: LegendPosition): RenderResult =>
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<UPlotLegend
|
||||
position={position}
|
||||
// config is consumed by the mocked useLegendsSync hook, not directly
|
||||
config={{} as any}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
describe('layout and position', () => {
|
||||
it('renders the search input on a RIGHT legend', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
expect(screen.getByTestId('legend-search-input')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps a BOTTOM legend bare — its two rows all go to series', () => {
|
||||
renderLegend();
|
||||
|
||||
expect(screen.queryByTestId('legend-search-input')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('legend-status')).not.toBeInTheDocument();
|
||||
// The row interactions are the same in both placements.
|
||||
expect(screen.getByTestId('legend-item-0')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('legend-scope-0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the marker with the series colour, filled only when shown', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
expect(
|
||||
document.querySelector(
|
||||
'[data-legend-item-id="0"] [data-is-legend-marker="true"]',
|
||||
),
|
||||
).toHaveStyle({
|
||||
'border-color': '#ff0000',
|
||||
'background-color': '#ff0000',
|
||||
});
|
||||
// Hidden series read as an empty checkbox.
|
||||
expect(
|
||||
document.querySelector(
|
||||
'[data-legend-item-id="1"] [data-is-legend-marker="true"]',
|
||||
),
|
||||
).toHaveStyle({ 'background-color': 'transparent' });
|
||||
});
|
||||
|
||||
it('renders all legend items in the grid by default', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
expect(screen.getByTestId('virtuoso-grid')).toBeInTheDocument();
|
||||
expect(screen.getByText('A')).toBeInTheDocument();
|
||||
expect(screen.getByText('B')).toBeInTheDocument();
|
||||
expect(screen.getByText('C')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('status readout', () => {
|
||||
it('reports how many series are showing', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
expect(screen.getByTestId('legend-status')).toHaveTextContent(
|
||||
'Showing 2 of 3 series',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filter behavior', () => {
|
||||
it('filters legend items based on the query (case-insensitive)', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
await user.type(screen.getByTestId('legend-search-input'), 'a');
|
||||
|
||||
expect(screen.getByText('A')).toBeInTheDocument();
|
||||
expect(screen.queryByText('B')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('C')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the empty state when nothing matches', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
await user.type(screen.getByTestId('legend-search-input'), 'network');
|
||||
|
||||
expect(
|
||||
screen.getByText(/No series found matching "network"/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('virtuoso-grid')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ignores a whitespace-only query', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
await user.type(screen.getByTestId('legend-search-input'), ' ');
|
||||
|
||||
expect(
|
||||
screen.queryByText(/No series found matching/i),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText('A')).toBeInTheDocument();
|
||||
expect(screen.getByText('B')).toBeInTheDocument();
|
||||
expect(screen.getByText('C')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('row interactions', () => {
|
||||
const allShownItemsMap = {
|
||||
0: { ...baseLegendItemsMap[0] },
|
||||
1: { ...baseLegendItemsMap[1], show: true },
|
||||
2: { ...baseLegendItemsMap[2] },
|
||||
};
|
||||
|
||||
const mockAllShown = (): void => {
|
||||
mockUseLegendsSync.mockReturnValue({
|
||||
legendItemsMap: allShownItemsMap,
|
||||
focusedSeriesIndex: null,
|
||||
setFocusedSeriesIndex: jest.fn(),
|
||||
});
|
||||
};
|
||||
|
||||
it('isolates the series when everything is showing', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockAllShown();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
await user.click(screen.getByText('A'));
|
||||
|
||||
// Nothing the user can see is there to exclude, so the click means Only.
|
||||
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toStrictEqual([
|
||||
{ type: LegendAction.SHOW_ONLY, seriesIndex: 0 },
|
||||
]);
|
||||
expect(dispatched(onAction, LegendAction.TOGGLE)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('toggles the series once something is already hidden', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
await user.click(screen.getByText('A'));
|
||||
|
||||
expect(dispatched(onAction, LegendAction.TOGGLE)).toStrictEqual([
|
||||
{ type: LegendAction.TOGGLE, seriesIndex: 0 },
|
||||
]);
|
||||
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('excludes just that series when its marker is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockAllShown();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
await user.click(screen.getByTestId('legend-marker-0'));
|
||||
|
||||
// The marker is the one way to exclude a single series while
|
||||
// everything is showing — the row click isolates instead.
|
||||
expect(dispatched(onAction, LegendAction.TOGGLE)).toStrictEqual([
|
||||
{ type: LegendAction.TOGGLE, seriesIndex: 0 },
|
||||
]);
|
||||
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('stops the marker offering to hide the last series showing', () => {
|
||||
mockUseLegendsSync.mockReturnValue({
|
||||
legendItemsMap: {
|
||||
0: { ...baseLegendItemsMap[0] },
|
||||
1: { ...baseLegendItemsMap[1] },
|
||||
2: { ...baseLegendItemsMap[2], show: false },
|
||||
},
|
||||
focusedSeriesIndex: null,
|
||||
setFocusedSeriesIndex: jest.fn(),
|
||||
});
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
expect(screen.getByTestId('legend-marker-0')).toBeDisabled();
|
||||
expect(screen.getByTestId('legend-marker-1')).toBeEnabled();
|
||||
});
|
||||
|
||||
it('labels the marker with what clicking it does', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
expect(screen.getByTestId('legend-marker-0')).toHaveAttribute(
|
||||
'aria-label',
|
||||
'Hide A',
|
||||
);
|
||||
expect(screen.getByTestId('legend-marker-1')).toHaveAttribute(
|
||||
'aria-label',
|
||||
'Show B',
|
||||
);
|
||||
});
|
||||
|
||||
it('adds the clicked series to the selection while one is alone', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUseLegendsSync.mockReturnValue({
|
||||
legendItemsMap: {
|
||||
0: { ...baseLegendItemsMap[0] },
|
||||
1: { ...baseLegendItemsMap[1] },
|
||||
2: { ...baseLegendItemsMap[2], show: false },
|
||||
},
|
||||
focusedSeriesIndex: null,
|
||||
setFocusedSeriesIndex: jest.fn(),
|
||||
});
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
// Series 0 is showing alone; clicking another row builds the selection
|
||||
// up rather than moving the isolation.
|
||||
await user.click(screen.getByText('B'));
|
||||
|
||||
expect(dispatched(onAction, LegendAction.TOGGLE)).toStrictEqual([
|
||||
{ type: LegendAction.TOGGLE, seriesIndex: 1 },
|
||||
]);
|
||||
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('toggles the series on Enter and Space', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const row = screen.getByTestId('legend-item-0');
|
||||
row.focus();
|
||||
await user.keyboard('{Enter}');
|
||||
await user.keyboard(' ');
|
||||
|
||||
expect(dispatched(onAction, LegendAction.TOGGLE)).toStrictEqual([
|
||||
{ type: LegendAction.TOGGLE, seriesIndex: 0 },
|
||||
{ type: LegendAction.TOGGLE, seriesIndex: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reflects visibility on the row for assistive tech', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
expect(screen.getByTestId('legend-item-0')).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'true',
|
||||
);
|
||||
expect(screen.getByTestId('legend-item-1')).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
|
||||
it('restores every series from All without also toggling the row', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
// Series 0 is shown while B is hidden, so its action is All.
|
||||
await user.click(screen.getByTestId('legend-scope-0'));
|
||||
|
||||
expect(dispatched(onAction, LegendAction.SHOW_ALL)).toHaveLength(1);
|
||||
expect(dispatched(onAction, LegendAction.TOGGLE)).toHaveLength(0);
|
||||
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('isolates the series from Only on a hidden row', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
await user.click(screen.getByTestId('legend-scope-1'));
|
||||
|
||||
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toStrictEqual([
|
||||
{ type: LegendAction.SHOW_ONLY, seriesIndex: 1 },
|
||||
]);
|
||||
expect(dispatched(onAction, LegendAction.TOGGLE)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('highlights the hovered series and clears it on leave', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const row = screen.getByTestId('legend-item-0');
|
||||
await user.hover(row);
|
||||
expect(onAction).toHaveBeenCalledWith({
|
||||
type: LegendAction.HOVER,
|
||||
seriesIndex: 0,
|
||||
});
|
||||
|
||||
await user.unhover(row);
|
||||
expect(onAction).toHaveBeenCalledWith({
|
||||
type: LegendAction.HOVER,
|
||||
seriesIndex: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('one-series state', () => {
|
||||
const soleShownItemsMap = {
|
||||
0: { ...baseLegendItemsMap[0] },
|
||||
1: { ...baseLegendItemsMap[1] },
|
||||
2: { ...baseLegendItemsMap[2], show: false },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseLegendsSync.mockReturnValue({
|
||||
legendItemsMap: soleShownItemsMap,
|
||||
focusedSeriesIndex: null,
|
||||
setFocusedSeriesIndex: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it('offers All on the shown row and Only on the hidden ones', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
expect(screen.getByTestId('legend-scope-0')).toHaveTextContent('All');
|
||||
expect(screen.getByTestId('legend-scope-1')).toHaveTextContent('Only');
|
||||
expect(screen.getByTestId('legend-scope-2')).toHaveTextContent('Only');
|
||||
});
|
||||
|
||||
it('restores everything from All', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
await user.click(screen.getByTestId('legend-scope-0'));
|
||||
|
||||
expect(dispatched(onAction, LegendAction.SHOW_ALL)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('restores everything when the row showing alone is clicked again', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
await user.click(screen.getByText('A'));
|
||||
|
||||
expect(dispatched(onAction, LegendAction.SHOW_ALL)).toHaveLength(1);
|
||||
expect(dispatched(onAction, LegendAction.TOGGLE)).toHaveLength(0);
|
||||
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import { filterLegendItems, getShownSeriesState } from '../utils';
|
||||
|
||||
const items = (shown: boolean[]): LegendItem[] =>
|
||||
shown.map((show, index) => ({
|
||||
seriesIndex: index + 1,
|
||||
label: `series-${index}`,
|
||||
color: '#000',
|
||||
show,
|
||||
}));
|
||||
|
||||
describe('getShownSeriesState', () => {
|
||||
it('counts the shown series', () => {
|
||||
expect(getShownSeriesState(items([true, false, true]))).toStrictEqual({
|
||||
visibleCount: 2,
|
||||
soleShownSeriesIndex: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('names the series when exactly one is shown', () => {
|
||||
expect(getShownSeriesState(items([false, true, false]))).toStrictEqual({
|
||||
visibleCount: 1,
|
||||
soleShownSeriesIndex: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports nothing shown', () => {
|
||||
expect(getShownSeriesState(items([false, false]))).toStrictEqual({
|
||||
visibleCount: 0,
|
||||
soleShownSeriesIndex: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterLegendItems', () => {
|
||||
it('matches case-insensitively on the label', () => {
|
||||
const filtered = filterLegendItems(items([true, true, true]), 'SERIES-1');
|
||||
expect(filtered.map((item) => item.label)).toStrictEqual(['series-1']);
|
||||
});
|
||||
|
||||
it('returns every item for a blank query', () => {
|
||||
expect(filterLegendItems(items([true, true]), ' ')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
20
frontend/src/lib/uPlotV2/components/Legend/constants.ts
Normal file
20
frontend/src/lib/uPlotV2/components/Legend/constants.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/** Widest a single legend item is allowed to get when sizing the legend grid. */
|
||||
export const MAX_LEGEND_WIDTH = 240;
|
||||
|
||||
/**
|
||||
* Enough for a row to contain its own hover actions, which a short label would
|
||||
* otherwise size a column too narrow for. Little room for the label is intended.
|
||||
*/
|
||||
export const MIN_LEGEND_ITEM_WIDTH = 110;
|
||||
|
||||
/** Marker + row padding, on top of the estimated label width. */
|
||||
export const LEGEND_ITEM_EXTRA_WIDTH = 16;
|
||||
|
||||
/** Must match `.row`'s height and the grid's row gap, or the reserved
|
||||
* rectangle clips a row. */
|
||||
export const LEGEND_ROW_HEIGHT = 28;
|
||||
export const LEGEND_ROW_GAP = 2;
|
||||
export const LEGEND_MAX_BOTTOM_ROWS = 2;
|
||||
|
||||
/** Hover delay before a row's full-name tooltip opens. */
|
||||
export const LEGEND_TOOLTIP_DELAY_MS = 500;
|
||||
34
frontend/src/lib/uPlotV2/components/Legend/utils.ts
Normal file
34
frontend/src/lib/uPlotV2/components/Legend/utils.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
|
||||
export interface ShownSeriesState {
|
||||
visibleCount: number;
|
||||
/** The series index when exactly one series is shown, else null. */
|
||||
soleShownSeriesIndex: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Driven by what is actually shown, never a remembered isolation: hiding series
|
||||
* one at a time down to a single one is the same state as "Only".
|
||||
*/
|
||||
export function getShownSeriesState(items: LegendItem[]): ShownSeriesState {
|
||||
const shown = items.filter((item) => item.show);
|
||||
|
||||
return {
|
||||
visibleCount: shown.length,
|
||||
soleShownSeriesIndex: shown.length === 1 ? shown[0].seriesIndex : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function filterLegendItems(
|
||||
items: LegendItem[],
|
||||
query: string,
|
||||
): LegendItem[] {
|
||||
const normalisedQuery = query.trim().toLowerCase();
|
||||
if (!normalisedQuery) {
|
||||
return items;
|
||||
}
|
||||
|
||||
return items.filter((item) =>
|
||||
item.label?.toLowerCase().includes(normalisedQuery),
|
||||
);
|
||||
}
|
||||
@@ -12,10 +12,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Matches the legend row's marker.
|
||||
.uplotTooltipItemMarker {
|
||||
border-radius: 50%;
|
||||
border-radius: var(--radius);
|
||||
border-style: solid;
|
||||
border-width: 2px;
|
||||
border-width: 1.5px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
box-sizing: border-box;
|
||||
@@ -30,11 +31,23 @@
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
// The legend's mono type; the container's Inter stays for the header.
|
||||
.uplotTooltipItemLabel,
|
||||
.uplotTooltipItemValue {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.uplotTooltipItemLabel {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.uplotTooltipItemValue {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.uplotTooltipItemContentSeparator {
|
||||
flex: 1;
|
||||
border-width: 0.5px;
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function TooltipItem({
|
||||
>
|
||||
<div
|
||||
className={Styles.uplotTooltipItemMarker}
|
||||
style={{ borderColor: item.color }}
|
||||
style={{ borderColor: item.color, backgroundColor: item.color }}
|
||||
data-is-legend-marker={true}
|
||||
data-testid={markerTestId}
|
||||
/>
|
||||
@@ -39,7 +39,7 @@ export default function TooltipItem({
|
||||
className={Styles.uplotTooltipItemContentSeparator}
|
||||
style={{ borderColor: item.color }}
|
||||
/>
|
||||
<span>{item.tooltipValue}</span>
|
||||
<span className={Styles.uplotTooltipItemValue}>{item.tooltipValue}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render, RenderResult, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
|
||||
|
||||
import { useLegendActions } from '../../hooks/useLegendActions';
|
||||
import UPlotLegend from '../Legend/UPlotLegend';
|
||||
import { LegendPosition } from '../types';
|
||||
|
||||
jest.mock('react-virtuoso', () => ({
|
||||
VirtuosoGrid: ({
|
||||
data,
|
||||
itemContent,
|
||||
className,
|
||||
}: {
|
||||
data: LegendItem[];
|
||||
itemContent: (index: number, item: LegendItem) => React.ReactNode;
|
||||
className?: string;
|
||||
}): JSX.Element => (
|
||||
<div data-testid="virtuoso-grid" className={className}>
|
||||
{data.map((item, index) => (
|
||||
<div key={item.seriesIndex ?? index} data-testid="legend-item-wrapper">
|
||||
{itemContent(index, item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('lib/uPlotV2/hooks/useLegendsSync');
|
||||
jest.mock('lib/uPlotV2/hooks/useLegendActions');
|
||||
|
||||
const mockUseLegendsSync = useLegendsSync as jest.MockedFunction<
|
||||
typeof useLegendsSync
|
||||
>;
|
||||
const mockUseLegendActions = useLegendActions as jest.MockedFunction<
|
||||
typeof useLegendActions
|
||||
>;
|
||||
|
||||
describe('UPlotLegend', () => {
|
||||
const baseLegendItemsMap = {
|
||||
0: {
|
||||
seriesIndex: 0,
|
||||
label: 'A',
|
||||
show: true,
|
||||
color: '#ff0000',
|
||||
},
|
||||
1: {
|
||||
seriesIndex: 1,
|
||||
label: 'B',
|
||||
show: false,
|
||||
color: '#00ff00',
|
||||
},
|
||||
2: {
|
||||
seriesIndex: 2,
|
||||
label: 'C',
|
||||
show: true,
|
||||
color: '#0000ff',
|
||||
},
|
||||
};
|
||||
|
||||
let onLegendClick: jest.Mock;
|
||||
let onLegendMouseMove: jest.Mock;
|
||||
let onLegendMouseLeave: jest.Mock;
|
||||
let onFocusSeries: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
onLegendClick = jest.fn();
|
||||
onLegendMouseMove = jest.fn();
|
||||
onLegendMouseLeave = jest.fn();
|
||||
onFocusSeries = jest.fn();
|
||||
|
||||
mockUseLegendsSync.mockReturnValue({
|
||||
legendItemsMap: baseLegendItemsMap,
|
||||
focusedSeriesIndex: 1,
|
||||
setFocusedSeriesIndex: jest.fn(),
|
||||
});
|
||||
|
||||
mockUseLegendActions.mockReturnValue({
|
||||
onLegendClick,
|
||||
onLegendMouseMove,
|
||||
onLegendMouseLeave,
|
||||
onFocusSeries,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderLegend = (position?: LegendPosition): RenderResult =>
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<UPlotLegend
|
||||
position={position}
|
||||
// config is consumed by the mocked useLegendsSync hook, not directly
|
||||
config={{} as any}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
describe('layout and position', () => {
|
||||
it('renders search input when legend position is RIGHT', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
expect(screen.getByTestId('legend-search-input')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render search input when legend position is BOTTOM (default)', () => {
|
||||
renderLegend();
|
||||
|
||||
expect(screen.queryByTestId('legend-search-input')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the marker with the correct border color', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const legendMarker = document.querySelector(
|
||||
'[data-legend-item-id="0"] [data-is-legend-marker="true"]',
|
||||
) as HTMLElement;
|
||||
|
||||
expect(legendMarker).toHaveStyle({
|
||||
'border-color': '#ff0000',
|
||||
});
|
||||
});
|
||||
|
||||
it('renders all legend items in the grid by default', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
expect(screen.getByTestId('virtuoso-grid')).toBeInTheDocument();
|
||||
expect(screen.getByText('A')).toBeInTheDocument();
|
||||
expect(screen.getByText('B')).toBeInTheDocument();
|
||||
expect(screen.getByText('C')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('search behavior (RIGHT position)', () => {
|
||||
it('filters legend items based on search query (case-insensitive)', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const searchInput = screen.getByTestId('legend-search-input');
|
||||
await user.type(searchInput, 'A');
|
||||
|
||||
expect(screen.getByText('A')).toBeInTheDocument();
|
||||
expect(screen.queryByText('B')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('C')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state when no legend items match the search query', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const searchInput = screen.getByTestId('legend-search-input');
|
||||
await user.type(searchInput, 'network');
|
||||
|
||||
expect(
|
||||
screen.getByText(/No series found matching "network"/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('virtuoso-grid')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not filter or show empty state when search query is empty or only whitespace', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const searchInput = screen.getByTestId('legend-search-input');
|
||||
await user.type(searchInput, ' ');
|
||||
|
||||
expect(
|
||||
screen.queryByText(/No series found matching/i),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText('A')).toBeInTheDocument();
|
||||
expect(screen.getByText('B')).toBeInTheDocument();
|
||||
expect(screen.getByText('C')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('legend actions', () => {
|
||||
it('calls onLegendClick when a legend item is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
await user.click(screen.getByText('A'));
|
||||
|
||||
expect(onLegendClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls mouseMove when the mouse moves over a legend item', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const legendItem = document.querySelector(
|
||||
'[data-legend-item-id="0"]',
|
||||
) as HTMLElement;
|
||||
|
||||
await user.hover(legendItem);
|
||||
|
||||
expect(onLegendMouseMove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onLegendMouseLeave when the mouse leaves the legend container', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const container = document.querySelector('.legend-container') as HTMLElement;
|
||||
|
||||
await user.hover(container);
|
||||
await user.unhover(container);
|
||||
|
||||
expect(onLegendMouseLeave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MouseEventHandler, ReactNode } from 'react';
|
||||
import { ReactNode } from 'react';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import uPlot from 'uplot';
|
||||
@@ -115,26 +115,39 @@ export enum LegendPosition {
|
||||
export interface LegendConfig {
|
||||
position: LegendPosition;
|
||||
}
|
||||
export enum LegendAction {
|
||||
TOGGLE = 'toggle',
|
||||
SHOW_ONLY = 'showOnly',
|
||||
SHOW_ALL = 'showAll',
|
||||
HOVER = 'hover',
|
||||
}
|
||||
|
||||
/** Everything the legend can ask of its container, as one dispatch. */
|
||||
export type LegendActionPayload =
|
||||
/** Row click / Space / Enter / marker click: hide or show that one series. */
|
||||
| { type: LegendAction.TOGGLE; seriesIndex: number }
|
||||
/** Show that series alone. */
|
||||
| { type: LegendAction.SHOW_ONLY; seriesIndex: number }
|
||||
/** Leave the narrowed selection and show every series. */
|
||||
| { type: LegendAction.SHOW_ALL }
|
||||
/** Row hover, for the chart-side highlight; null on leave. */
|
||||
| { type: LegendAction.HOVER; seriesIndex: number | null };
|
||||
|
||||
export type OnLegendAction = (payload: LegendActionPayload) => void;
|
||||
|
||||
/**
|
||||
* Presentational legend props. Source-agnostic: it renders whatever `items`
|
||||
* it's given and delegates interaction to the container handlers, so it serves
|
||||
* both uPlot charts (via UPlotLegend) and non-uPlot charts (Pie). The search
|
||||
* box is intrinsic to the RIGHT position (derived from `position`, not a flag).
|
||||
* both uPlot charts (via UPlotLegend) and non-uPlot charts (Pie).
|
||||
*/
|
||||
export interface LegendProps {
|
||||
items: LegendItem[];
|
||||
/** Legend placement; always supplied by the container. */
|
||||
position: LegendPosition;
|
||||
averageLegendWidth?: number;
|
||||
/** Series index to highlight (hovered/focused). */
|
||||
/** Series index highlighted by the chart cursor. */
|
||||
focusedSeriesIndex: number | null;
|
||||
/**
|
||||
* Container-delegated handlers. Items carry `data-legend-item-id`, so the
|
||||
* handler reads the target's id rather than binding per item.
|
||||
*/
|
||||
onClick: MouseEventHandler<HTMLDivElement>;
|
||||
onMouseMove: MouseEventHandler<HTMLDivElement>;
|
||||
onMouseLeave: () => void;
|
||||
onAction: OnLegendAction;
|
||||
/** Show the per-item copy button. Default true. */
|
||||
showCopy?: boolean;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@ export const DEFAULT_HOVER_PROXIMITY_VALUE = 30; // only snap if within 30px hor
|
||||
export const DEFAULT_FOCUS_PROXIMITY_VALUE = 1e6;
|
||||
export const STEP_INTERVAL_MULTIPLIER = 3; // multiply the width computed by STEP_INTERVAL_MULTIPLIER to get the hover prox value
|
||||
|
||||
/** Opacity applied to the series that are NOT highlighted while a legend row is hovered. */
|
||||
export const LEGEND_HIGHLIGHT_DIM_ALPHA = 0.16;
|
||||
/** Stroke-width multiplier applied to the series highlighted from the legend. */
|
||||
export const LEGEND_HIGHLIGHT_WIDTH_RATIO = 1.6;
|
||||
|
||||
export const DEFAULT_PLOT_CONFIG: Partial<Options> = {
|
||||
focus: {
|
||||
alpha: 0.3,
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import {
|
||||
LEGEND_HIGHLIGHT_DIM_ALPHA,
|
||||
LEGEND_HIGHLIGHT_WIDTH_RATIO,
|
||||
} from 'lib/uPlotV2/constants';
|
||||
import type { SeriesVisibilityItem } from 'lib/visualization/panels/types';
|
||||
import { updateSeriesVisibilityToLocalStorage } from 'lib/visualization/panels/utils/legendVisibilityUtils';
|
||||
import type uPlot from 'uplot';
|
||||
@@ -20,12 +24,26 @@ export interface IPlotContext {
|
||||
setPlotContextInitialState: (state: PlotContextInitialState) => void;
|
||||
onToggleSeriesVisibility: (seriesIndex: number) => void;
|
||||
onToggleSeriesOnOff: (seriesIndex: number) => void;
|
||||
/** Show this series alone. */
|
||||
onShowOnlySeries: (seriesIndex: number) => void;
|
||||
/** Show every series again. */
|
||||
onShowAllSeries: () => void;
|
||||
onFocusSeries: (seriesIndex: number | null) => void;
|
||||
/** Lift one series above the rest (dim + thicken) without changing visibility. */
|
||||
onHighlightSeries: (seriesIndex: number | null) => void;
|
||||
syncSeriesVisibilityToLocalStorage: () => void;
|
||||
}
|
||||
|
||||
export const PlotContext = createContext<IPlotContext | null>(null);
|
||||
|
||||
/** Data series (index 0 is the x-axis) currently drawn. */
|
||||
const countShownSeries = (plot: uPlot): number =>
|
||||
plot.series.reduce(
|
||||
(count, series, index) =>
|
||||
index > 0 && series.show !== false ? count + 1 : count,
|
||||
0,
|
||||
);
|
||||
|
||||
export const PlotContextProvider = ({
|
||||
children,
|
||||
}: PropsWithChildren): JSX.Element => {
|
||||
@@ -33,6 +51,9 @@ export const PlotContextProvider = ({
|
||||
const activeSeriesIndex = useRef<number | undefined>(undefined);
|
||||
const idRef = useRef<string | undefined>(undefined);
|
||||
const shouldSavePreferencesRef = useRef<boolean>(false);
|
||||
/** Pre-highlight stroke widths, captured on the first highlight so it can be undone. */
|
||||
const baseSeriesWidthsRef = useRef<Map<number, number | undefined>>(new Map());
|
||||
const highlightedSeriesIndexRef = useRef<number | null>(null);
|
||||
|
||||
const setPlotContextInitialState = useCallback(
|
||||
({
|
||||
@@ -43,6 +64,8 @@ export const PlotContextProvider = ({
|
||||
uPlotInstanceRef.current = uPlotInstance;
|
||||
idRef.current = id;
|
||||
activeSeriesIndex.current = undefined;
|
||||
baseSeriesWidthsRef.current = new Map();
|
||||
highlightedSeriesIndexRef.current = null;
|
||||
shouldSavePreferencesRef.current = !!shouldSaveSelectionPreference;
|
||||
},
|
||||
[],
|
||||
@@ -64,6 +87,54 @@ export const PlotContextProvider = ({
|
||||
updateSeriesVisibilityToLocalStorage(idRef.current, seriesVisibility);
|
||||
}, []);
|
||||
|
||||
const onHighlightSeries = useCallback((seriesIndex: number | null): void => {
|
||||
const plot = uPlotInstanceRef.current;
|
||||
if (!plot) {
|
||||
return;
|
||||
}
|
||||
|
||||
highlightedSeriesIndexRef.current = seriesIndex;
|
||||
|
||||
plot.series.forEach((series, index) => {
|
||||
if (index === 0) {
|
||||
return;
|
||||
}
|
||||
if (!baseSeriesWidthsRef.current.has(index)) {
|
||||
baseSeriesWidthsRef.current.set(index, series.width);
|
||||
}
|
||||
const baseWidth = baseSeriesWidthsRef.current.get(index);
|
||||
const isHighlighted = index === seriesIndex;
|
||||
|
||||
/* eslint-disable no-param-reassign */
|
||||
series.alpha =
|
||||
seriesIndex === null || isHighlighted ? 1 : LEGEND_HIGHLIGHT_DIM_ALPHA;
|
||||
series.width =
|
||||
isHighlighted && baseWidth !== undefined
|
||||
? baseWidth * LEGEND_HIGHLIGHT_WIDTH_RATIO
|
||||
: baseWidth;
|
||||
/* eslint-enable no-param-reassign */
|
||||
});
|
||||
|
||||
// Only the stroke style changed, so the cached paths stay valid.
|
||||
plot.redraw(false);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Leaving the dim on a hidden series leaves every other one faded, which
|
||||
* reads as an isolation rather than as one series being excluded.
|
||||
*/
|
||||
const clearHighlightIfHidden = useCallback((): void => {
|
||||
const plot = uPlotInstanceRef.current;
|
||||
const highlightedIndex = highlightedSeriesIndexRef.current;
|
||||
if (!plot || highlightedIndex === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (plot.series[highlightedIndex]?.show === false) {
|
||||
onHighlightSeries(null);
|
||||
}
|
||||
}, [onHighlightSeries]);
|
||||
|
||||
const onToggleSeriesVisibility = useCallback(
|
||||
(seriesIndex: number): void => {
|
||||
const plot = uPlotInstanceRef.current;
|
||||
@@ -103,14 +174,61 @@ export const PlotContextProvider = ({
|
||||
if (!series) {
|
||||
return;
|
||||
}
|
||||
|
||||
// An empty chart is never worth reaching.
|
||||
const isHiding = series.show !== false;
|
||||
if (isHiding && countShownSeries(plot) <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
plot.setSeries(seriesIndex, { show: !series.show });
|
||||
if (idRef.current && shouldSavePreferencesRef.current) {
|
||||
syncSeriesVisibilityToLocalStorage();
|
||||
}
|
||||
|
||||
clearHighlightIfHidden();
|
||||
},
|
||||
[syncSeriesVisibilityToLocalStorage],
|
||||
[syncSeriesVisibilityToLocalStorage, clearHighlightIfHidden],
|
||||
);
|
||||
|
||||
/** Applies `resolveShow` to every data series in one batch, then persists. */
|
||||
const setSeriesVisibility = useCallback(
|
||||
(resolveShow: (seriesIndex: number) => boolean): void => {
|
||||
const plot = uPlotInstanceRef.current;
|
||||
if (!plot) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeSeriesIndex.current = undefined;
|
||||
|
||||
plot.batch(() => {
|
||||
plot.series.forEach((_, index) => {
|
||||
if (index === 0) {
|
||||
return;
|
||||
}
|
||||
plot.setSeries(index, { show: resolveShow(index) });
|
||||
});
|
||||
if (idRef.current && shouldSavePreferencesRef.current) {
|
||||
syncSeriesVisibilityToLocalStorage();
|
||||
}
|
||||
});
|
||||
|
||||
clearHighlightIfHidden();
|
||||
},
|
||||
[syncSeriesVisibilityToLocalStorage, clearHighlightIfHidden],
|
||||
);
|
||||
|
||||
const onShowOnlySeries = useCallback(
|
||||
(seriesIndex: number): void => {
|
||||
setSeriesVisibility((index) => index === seriesIndex);
|
||||
},
|
||||
[setSeriesVisibility],
|
||||
);
|
||||
|
||||
const onShowAllSeries = useCallback((): void => {
|
||||
setSeriesVisibility(() => true);
|
||||
}, [setSeriesVisibility]);
|
||||
|
||||
const onFocusSeries = useCallback((seriesIndex: number | null): void => {
|
||||
const plot = uPlotInstanceRef.current;
|
||||
if (!plot) {
|
||||
@@ -131,14 +249,20 @@ export const PlotContextProvider = ({
|
||||
onToggleSeriesVisibility,
|
||||
setPlotContextInitialState,
|
||||
onToggleSeriesOnOff,
|
||||
onShowOnlySeries,
|
||||
onShowAllSeries,
|
||||
onFocusSeries,
|
||||
onHighlightSeries,
|
||||
syncSeriesVisibilityToLocalStorage,
|
||||
}),
|
||||
[
|
||||
onToggleSeriesVisibility,
|
||||
setPlotContextInitialState,
|
||||
onToggleSeriesOnOff,
|
||||
onShowOnlySeries,
|
||||
onShowAllSeries,
|
||||
onFocusSeries,
|
||||
onHighlightSeries,
|
||||
syncSeriesVisibilityToLocalStorage,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -26,6 +26,7 @@ const createMockPlot = (series: MockSeries[] = []): uPlot =>
|
||||
series,
|
||||
batch: jest.fn((fn: () => void) => fn()),
|
||||
setSeries: jest.fn(),
|
||||
redraw: jest.fn(),
|
||||
}) as unknown as uPlot;
|
||||
|
||||
interface TestComponentProps {
|
||||
@@ -44,7 +45,10 @@ const TestComponent = ({
|
||||
syncSeriesVisibilityToLocalStorage,
|
||||
onToggleSeriesVisibility,
|
||||
onToggleSeriesOnOff,
|
||||
onShowOnlySeries,
|
||||
onShowAllSeries,
|
||||
onFocusSeries,
|
||||
onHighlightSeries,
|
||||
} = usePlotContext();
|
||||
const handleInit = (): void => {
|
||||
if (!plot || !id || typeof shouldSaveSelectionPreference !== 'boolean') {
|
||||
@@ -84,6 +88,13 @@ const TestComponent = ({
|
||||
>
|
||||
Toggle on/off 1
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="toggle-on-off-2"
|
||||
onClick={(): void => onToggleSeriesOnOff(2)}
|
||||
>
|
||||
Toggle on/off 2
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="toggle-on-off-5"
|
||||
@@ -98,6 +109,34 @@ const TestComponent = ({
|
||||
>
|
||||
Focus series
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="show-only-1"
|
||||
onClick={(): void => onShowOnlySeries(1)}
|
||||
>
|
||||
Show only 1
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="show-all"
|
||||
onClick={(): void => onShowAllSeries()}
|
||||
>
|
||||
Show all
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="highlight-1"
|
||||
onClick={(): void => onHighlightSeries(1)}
|
||||
>
|
||||
Highlight 1
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="clear-highlight"
|
||||
onClick={(): void => onHighlightSeries(null)}
|
||||
>
|
||||
Clear highlight
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -273,6 +312,7 @@ describe('PlotContext', () => {
|
||||
const series: MockSeries[] = [
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: true },
|
||||
];
|
||||
const plot = createMockPlot(series);
|
||||
|
||||
@@ -324,6 +364,7 @@ describe('PlotContext', () => {
|
||||
const series: MockSeries[] = [
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: true },
|
||||
];
|
||||
const plot = createMockPlot(series);
|
||||
|
||||
@@ -343,6 +384,48 @@ describe('PlotContext', () => {
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: false });
|
||||
expect(mockUpdateSeriesVisibilityToLocalStorage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses to hide the last series showing', async () => {
|
||||
const user = userEvent.setup();
|
||||
const plot = createMockPlot([
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: false },
|
||||
]);
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('toggle-on-off-1'));
|
||||
|
||||
// An empty chart is never a state worth reaching.
|
||||
expect(plot.setSeries).not.toHaveBeenCalled();
|
||||
expect(mockUpdateSeriesVisibilityToLocalStorage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still shows a hidden series when only one is left showing', async () => {
|
||||
const user = userEvent.setup();
|
||||
const plot = createMockPlot([
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: false },
|
||||
{ label: 'Memory', show: true },
|
||||
]);
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('toggle-on-off-1'));
|
||||
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('onFocusSeries', () => {
|
||||
@@ -381,4 +464,193 @@ describe('PlotContext', () => {
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(1, { focus: true }, false);
|
||||
});
|
||||
});
|
||||
describe('onShowOnlySeries', () => {
|
||||
const renderWithSeries = (
|
||||
series: MockSeries[],
|
||||
): { plot: uPlot; user: ReturnType<typeof userEvent.setup> } => {
|
||||
const user = userEvent.setup();
|
||||
const plot = createMockPlot(series);
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
return { plot, user };
|
||||
};
|
||||
|
||||
it('hides every other series, leaving the x-axis alone', async () => {
|
||||
const { plot, user } = renderWithSeries([
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: true },
|
||||
]);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('show-only-1'));
|
||||
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: true });
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(2, { show: false });
|
||||
expect(plot.setSeries).not.toHaveBeenCalledWith(0, expect.anything());
|
||||
expect(mockUpdateSeriesVisibilityToLocalStorage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps isolating the series that is already the only one shown', async () => {
|
||||
const { plot, user } = renderWithSeries([
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: false },
|
||||
]);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('show-only-1'));
|
||||
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: true });
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(2, { show: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('onShowAllSeries', () => {
|
||||
it('shows every hidden series again', async () => {
|
||||
const user = userEvent.setup();
|
||||
const plot = createMockPlot([
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: false },
|
||||
]);
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('show-all'));
|
||||
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: true });
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(2, { show: true });
|
||||
expect(plot.setSeries).not.toHaveBeenCalledWith(0, expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
describe('onHighlightSeries', () => {
|
||||
const series = (): MockSeries[] => [
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: true, width: 2 },
|
||||
{ label: 'Memory', show: true, width: 2 },
|
||||
];
|
||||
|
||||
it('dims the other series and thickens the highlighted one', async () => {
|
||||
const user = userEvent.setup();
|
||||
const plot = createMockPlot(series());
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('highlight-1'));
|
||||
|
||||
expect(plot.series[1].alpha).toBe(1);
|
||||
expect(plot.series[1].width).toBe(3.2);
|
||||
expect(plot.series[2].alpha).toBe(0.16);
|
||||
expect(plot.series[2].width).toBe(2);
|
||||
// Only the stroke changed, so the cached paths are reused.
|
||||
expect(plot.redraw).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('restores every series when the highlight is cleared', async () => {
|
||||
const user = userEvent.setup();
|
||||
const plot = createMockPlot(series());
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('highlight-1'));
|
||||
await user.click(screen.getByTestId('clear-highlight'));
|
||||
|
||||
expect(plot.series[1].alpha).toBe(1);
|
||||
expect(plot.series[1].width).toBe(2);
|
||||
expect(plot.series[2].alpha).toBe(1);
|
||||
expect(plot.series[2].width).toBe(2);
|
||||
});
|
||||
|
||||
it('drops the dim when the highlighted series is hidden', async () => {
|
||||
const user = userEvent.setup();
|
||||
const plot = createMockPlot(series());
|
||||
// The mock's setSeries doesn't mutate, so mirror what uPlot would do.
|
||||
(plot.setSeries as jest.Mock).mockImplementation(
|
||||
(index: number, opts: { show?: boolean }) => {
|
||||
if (typeof opts.show === 'boolean') {
|
||||
(plot.series[index] as MockSeries).show = opts.show;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('highlight-1'));
|
||||
await user.click(screen.getByTestId('toggle-on-off-1'));
|
||||
|
||||
// Otherwise every remaining series stays faded and the panel reads as
|
||||
// an isolation instead of one series being excluded.
|
||||
expect(plot.series[2].alpha).toBe(1);
|
||||
expect(plot.series[2].width).toBe(2);
|
||||
});
|
||||
|
||||
it('keeps the dim when a different series is hidden', async () => {
|
||||
const user = userEvent.setup();
|
||||
const plot = createMockPlot(series());
|
||||
(plot.setSeries as jest.Mock).mockImplementation(
|
||||
(index: number, opts: { show?: boolean }) => {
|
||||
if (typeof opts.show === 'boolean') {
|
||||
(plot.series[index] as MockSeries).show = opts.show;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('highlight-1'));
|
||||
await user.click(screen.getByTestId('toggle-on-off-2'));
|
||||
|
||||
expect(plot.series[1].alpha).toBe(1);
|
||||
expect(plot.series[2].alpha).toBe(0.16);
|
||||
});
|
||||
|
||||
it('leaves visibility untouched', async () => {
|
||||
const user = userEvent.setup();
|
||||
const plot = createMockPlot(series());
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('highlight-1'));
|
||||
|
||||
expect(plot.setSeries).not.toHaveBeenCalled();
|
||||
expect(mockUpdateSeriesVisibilityToLocalStorage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { LegendAction } from 'lib/uPlotV2/components/types';
|
||||
import { usePlotContext } from 'lib/uPlotV2/context/PlotContext';
|
||||
import { useLegendActions } from 'lib/uPlotV2/hooks/useLegendActions';
|
||||
|
||||
@@ -11,10 +12,12 @@ const mockUsePlotContext = usePlotContext as jest.MockedFunction<
|
||||
describe('useLegendActions', () => {
|
||||
let onToggleSeriesVisibility: jest.Mock;
|
||||
let onToggleSeriesOnOff: jest.Mock;
|
||||
let onFocusSeriesPlot: jest.Mock;
|
||||
let onShowOnlySeries: jest.Mock;
|
||||
let onShowAllSeries: jest.Mock;
|
||||
let onFocusSeries: jest.Mock;
|
||||
let onHighlightSeries: jest.Mock;
|
||||
let setPlotContextInitialState: jest.Mock;
|
||||
let syncSeriesVisibilityToLocalStorage: jest.Mock;
|
||||
let setFocusedSeriesIndexMock: jest.Mock;
|
||||
let cancelAnimationFrameSpy: jest.SpyInstance<void, [handle: number]>;
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -37,15 +40,20 @@ describe('useLegendActions', () => {
|
||||
beforeEach(() => {
|
||||
onToggleSeriesVisibility = jest.fn();
|
||||
onToggleSeriesOnOff = jest.fn();
|
||||
onFocusSeriesPlot = jest.fn();
|
||||
onShowOnlySeries = jest.fn();
|
||||
onShowAllSeries = jest.fn();
|
||||
onFocusSeries = jest.fn();
|
||||
onHighlightSeries = jest.fn();
|
||||
setPlotContextInitialState = jest.fn();
|
||||
syncSeriesVisibilityToLocalStorage = jest.fn();
|
||||
setFocusedSeriesIndexMock = jest.fn();
|
||||
|
||||
mockUsePlotContext.mockReturnValue({
|
||||
onToggleSeriesVisibility,
|
||||
onToggleSeriesOnOff,
|
||||
onFocusSeries: onFocusSeriesPlot,
|
||||
onShowOnlySeries,
|
||||
onShowAllSeries,
|
||||
onFocusSeries,
|
||||
onHighlightSeries,
|
||||
setPlotContextInitialState,
|
||||
syncSeriesVisibilityToLocalStorage,
|
||||
});
|
||||
@@ -53,149 +61,65 @@ describe('useLegendActions', () => {
|
||||
cancelAnimationFrameSpy.mockClear();
|
||||
});
|
||||
|
||||
const createMouseEvent = (options: {
|
||||
legendItemId?: number;
|
||||
isMarker?: boolean;
|
||||
}): any => {
|
||||
const { legendItemId, isMarker = false } = options;
|
||||
describe('visibility actions', () => {
|
||||
it('toggles a single series on row click', () => {
|
||||
const { result } = renderHook(() => useLegendActions());
|
||||
|
||||
return {
|
||||
target: {
|
||||
dataset: {
|
||||
...(isMarker ? { isLegendMarker: 'true' } : {}),
|
||||
},
|
||||
closest: jest.fn(() =>
|
||||
legendItemId !== undefined
|
||||
? { dataset: { legendItemId: String(legendItemId) } }
|
||||
: null,
|
||||
),
|
||||
},
|
||||
};
|
||||
};
|
||||
result.current({ type: LegendAction.TOGGLE, seriesIndex: 2 });
|
||||
|
||||
describe('onLegendClick', () => {
|
||||
it('toggles series visibility when clicking on legend label', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: null,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.onLegendClick(createMouseEvent({ legendItemId: 0 }));
|
||||
|
||||
expect(onToggleSeriesVisibility).toHaveBeenCalledTimes(1);
|
||||
expect(onToggleSeriesVisibility).toHaveBeenCalledWith(0);
|
||||
expect(onToggleSeriesOnOff).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('toggles series on/off when clicking on marker', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: null,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.onLegendClick(
|
||||
createMouseEvent({ legendItemId: 0, isMarker: true }),
|
||||
);
|
||||
|
||||
expect(onToggleSeriesOnOff).toHaveBeenCalledTimes(1);
|
||||
expect(onToggleSeriesOnOff).toHaveBeenCalledWith(0);
|
||||
expect(onToggleSeriesOnOff).toHaveBeenCalledWith(2);
|
||||
// The row must never isolate — that is what "Only" is for.
|
||||
expect(onToggleSeriesVisibility).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when click target is not inside a legend item', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: null,
|
||||
}),
|
||||
);
|
||||
it('forwards the Only and All actions to the plot', () => {
|
||||
const { result } = renderHook(() => useLegendActions());
|
||||
|
||||
result.current.onLegendClick(createMouseEvent({}));
|
||||
result.current({ type: LegendAction.SHOW_ONLY, seriesIndex: 1 });
|
||||
result.current({ type: LegendAction.SHOW_ALL });
|
||||
|
||||
expect(onToggleSeriesOnOff).not.toHaveBeenCalled();
|
||||
expect(onToggleSeriesVisibility).not.toHaveBeenCalled();
|
||||
expect(onShowOnlySeries).toHaveBeenCalledWith(1);
|
||||
expect(onShowAllSeries).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onFocusSeries', () => {
|
||||
it('schedules focus update and calls plot focus handler via mouse move', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: null,
|
||||
}),
|
||||
);
|
||||
describe('hover highlight', () => {
|
||||
it('highlights the hovered series', () => {
|
||||
const { result } = renderHook(() => useLegendActions());
|
||||
|
||||
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 0 }));
|
||||
result.current({ type: LegendAction.HOVER, seriesIndex: 2 });
|
||||
|
||||
expect(setFocusedSeriesIndexMock).toHaveBeenCalledWith(0);
|
||||
expect(onFocusSeriesPlot).toHaveBeenCalledWith(0);
|
||||
expect(onHighlightSeries).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('cancels previous animation frame before scheduling new one on subsequent mouse moves', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: null,
|
||||
}),
|
||||
);
|
||||
it('clears the highlight on leave', () => {
|
||||
const { result } = renderHook(() => useLegendActions());
|
||||
|
||||
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 0 }));
|
||||
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 1 }));
|
||||
result.current({ type: LegendAction.HOVER, seriesIndex: null });
|
||||
|
||||
expect(onHighlightSeries).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('coalesces rapid hovers into one frame', () => {
|
||||
const { result } = renderHook(() => useLegendActions());
|
||||
|
||||
result.current({ type: LegendAction.HOVER, seriesIndex: 1 });
|
||||
result.current({ type: LegendAction.HOVER, seriesIndex: 2 });
|
||||
|
||||
// Each new hover cancels the frame the previous one queued.
|
||||
expect(cancelAnimationFrameSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onLegendMouseMove', () => {
|
||||
it('focuses new series when hovering over different legend item', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: 0,
|
||||
}),
|
||||
);
|
||||
it('cancels a pending highlight frame on unmount', () => {
|
||||
jest
|
||||
.spyOn(global, 'requestAnimationFrame')
|
||||
.mockImplementation((): number => 7);
|
||||
|
||||
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 1 }));
|
||||
const { result, unmount } = renderHook(() => useLegendActions());
|
||||
result.current({ type: LegendAction.HOVER, seriesIndex: 1 });
|
||||
unmount();
|
||||
|
||||
expect(setFocusedSeriesIndexMock).toHaveBeenCalledWith(1);
|
||||
expect(onFocusSeriesPlot).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('does nothing when hovering over already focused series', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 1 }));
|
||||
|
||||
expect(setFocusedSeriesIndexMock).not.toHaveBeenCalled();
|
||||
expect(onFocusSeriesPlot).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onLegendMouseLeave', () => {
|
||||
it('cancels pending animation frame and clears focus state', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: null,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 0 }));
|
||||
result.current.onLegendMouseLeave();
|
||||
|
||||
expect(cancelAnimationFrameSpy).toHaveBeenCalled();
|
||||
expect(setFocusedSeriesIndexMock).toHaveBeenCalledWith(null);
|
||||
expect(onFocusSeriesPlot).toHaveBeenCalledWith(null);
|
||||
expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(7);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,117 +1,66 @@
|
||||
import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { usePlotContext } from 'lib/uPlotV2/context/PlotContext';
|
||||
|
||||
export function useLegendActions({
|
||||
setFocusedSeriesIndex,
|
||||
focusedSeriesIndex,
|
||||
}: {
|
||||
setFocusedSeriesIndex: Dispatch<SetStateAction<number | null>>;
|
||||
focusedSeriesIndex: number | null;
|
||||
}): {
|
||||
onLegendClick: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
onFocusSeries: (seriesIndex: number | null) => void;
|
||||
onLegendMouseMove: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
onLegendMouseLeave: () => void;
|
||||
} {
|
||||
import {
|
||||
LegendAction,
|
||||
LegendActionPayload,
|
||||
OnLegendAction,
|
||||
} from '../components/types';
|
||||
|
||||
/**
|
||||
* Legend interactions, bound to the plot through PlotContext. Hover is coalesced
|
||||
* to one chart redraw per frame.
|
||||
*/
|
||||
export function useLegendActions(): OnLegendAction {
|
||||
const {
|
||||
onFocusSeries: onFocusSeriesPlot,
|
||||
onToggleSeriesOnOff,
|
||||
onToggleSeriesVisibility,
|
||||
onShowOnlySeries,
|
||||
onShowAllSeries,
|
||||
onHighlightSeries,
|
||||
} = usePlotContext();
|
||||
|
||||
const rafId = useRef<number | null>(null); // requestAnimationFrame id
|
||||
const rafIdRef = useRef<number | null>(null);
|
||||
|
||||
const getLegendItemIdFromEvent = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>): string | undefined => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (!target) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const legendItemElement = target.closest<HTMLElement>(
|
||||
'[data-legend-item-id]',
|
||||
);
|
||||
|
||||
return legendItemElement?.dataset.legendItemId;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onLegendClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>): void => {
|
||||
const legendItemId = getLegendItemIdFromEvent(e);
|
||||
if (!legendItemId) {
|
||||
return;
|
||||
}
|
||||
const isLegendMarker = (e.target as HTMLElement).dataset.isLegendMarker;
|
||||
const seriesIndex = Number(legendItemId);
|
||||
|
||||
if (isLegendMarker) {
|
||||
onToggleSeriesOnOff(seriesIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
onToggleSeriesVisibility(seriesIndex);
|
||||
},
|
||||
[onToggleSeriesVisibility, onToggleSeriesOnOff, getLegendItemIdFromEvent],
|
||||
);
|
||||
|
||||
const onFocusSeries = useCallback(
|
||||
(seriesIndex: number | null): void => {
|
||||
if (rafId.current != null) {
|
||||
cancelAnimationFrame(rafId.current);
|
||||
}
|
||||
rafId.current = requestAnimationFrame(() => {
|
||||
setFocusedSeriesIndex(seriesIndex);
|
||||
onFocusSeriesPlot(seriesIndex);
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[onFocusSeriesPlot],
|
||||
);
|
||||
|
||||
const onLegendMouseMove = (e: React.MouseEvent<HTMLDivElement>): void => {
|
||||
const legendItemId = getLegendItemIdFromEvent(e);
|
||||
const seriesIndex = legendItemId ? Number(legendItemId) : null;
|
||||
if (seriesIndex === focusedSeriesIndex) {
|
||||
return;
|
||||
const cancelPendingHighlight = useCallback((): void => {
|
||||
if (rafIdRef.current != null) {
|
||||
cancelAnimationFrame(rafIdRef.current);
|
||||
rafIdRef.current = null;
|
||||
}
|
||||
onFocusSeries(seriesIndex);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onLegendMouseLeave = useCallback(
|
||||
(): void => {
|
||||
// Cancel any pending RAF from handleFocusSeries to prevent race condition
|
||||
if (rafId.current != null) {
|
||||
cancelAnimationFrame(rafId.current);
|
||||
rafId.current = null;
|
||||
}
|
||||
setFocusedSeriesIndex(null);
|
||||
onFocusSeries(null);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[onFocusSeries],
|
||||
);
|
||||
useEffect(() => cancelPendingHighlight, [cancelPendingHighlight]);
|
||||
|
||||
// Cleanup pending animation frames on unmount
|
||||
useEffect(
|
||||
() => (): void => {
|
||||
if (rafId.current != null) {
|
||||
cancelAnimationFrame(rafId.current);
|
||||
return useCallback(
|
||||
(payload: LegendActionPayload): void => {
|
||||
switch (payload.type) {
|
||||
case LegendAction.TOGGLE:
|
||||
onToggleSeriesOnOff(payload.seriesIndex);
|
||||
break;
|
||||
case LegendAction.SHOW_ONLY:
|
||||
onShowOnlySeries(payload.seriesIndex);
|
||||
break;
|
||||
case LegendAction.SHOW_ALL:
|
||||
onShowAllSeries();
|
||||
break;
|
||||
case LegendAction.HOVER: {
|
||||
const { seriesIndex } = payload;
|
||||
cancelPendingHighlight();
|
||||
rafIdRef.current = requestAnimationFrame(() => {
|
||||
rafIdRef.current = null;
|
||||
onHighlightSeries(seriesIndex);
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[],
|
||||
[
|
||||
cancelPendingHighlight,
|
||||
onHighlightSeries,
|
||||
onShowAllSeries,
|
||||
onShowOnlySeries,
|
||||
onToggleSeriesOnOff,
|
||||
],
|
||||
);
|
||||
return {
|
||||
onLegendClick,
|
||||
onFocusSeries,
|
||||
onLegendMouseMove,
|
||||
onLegendMouseLeave,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,9 +45,7 @@ export default function Pie({
|
||||
visibleData,
|
||||
legendItems,
|
||||
focusedSeriesIndex,
|
||||
onLegendClick,
|
||||
onLegendMouseMove,
|
||||
onLegendMouseLeave,
|
||||
onLegendAction,
|
||||
} = usePieInteractions(data, id);
|
||||
|
||||
const {
|
||||
@@ -227,9 +225,7 @@ export default function Pie({
|
||||
position={position}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
focusedSeriesIndex={focusedSeriesIndex}
|
||||
onClick={onLegendClick}
|
||||
onMouseMove={onLegendMouseMove}
|
||||
onMouseLeave={onLegendMouseLeave}
|
||||
onAction={onLegendAction}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -100,17 +100,29 @@ describe('Pie', () => {
|
||||
expect(screen.getByTestId('pie')).toHaveStyle({ flexDirection: 'column' });
|
||||
});
|
||||
|
||||
it('hides a slice when its legend marker is clicked', () => {
|
||||
it('isolates a slice when its legend row is clicked with everything showing', () => {
|
||||
renderPie();
|
||||
const svg = screen.getByTestId('pie').querySelector('svg') as SVGElement;
|
||||
expect(svg.querySelectorAll('path')).toHaveLength(3);
|
||||
|
||||
const marker = document.querySelector(
|
||||
'[data-legend-item-id="1"] [data-is-legend-marker="true"]',
|
||||
) as HTMLElement;
|
||||
fireEvent.click(marker);
|
||||
fireEvent.click(screen.getByTestId('legend-item-1'));
|
||||
|
||||
// Nothing visible to exclude, so the click isolates: one arc left.
|
||||
expect(svg.querySelectorAll('path')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('excludes a slice when its legend row is clicked with others already hidden', () => {
|
||||
renderPie();
|
||||
const svg = screen.getByTestId('pie').querySelector('svg') as SVGElement;
|
||||
|
||||
// Isolate, then add a second slice back, so nothing is isolated any more.
|
||||
fireEvent.click(screen.getByTestId('legend-item-1'));
|
||||
fireEvent.click(screen.getByTestId('legend-item-0'));
|
||||
expect(svg.querySelectorAll('path')).toHaveLength(2);
|
||||
|
||||
fireEvent.click(screen.getByTestId('legend-item-0'));
|
||||
|
||||
// One slice hidden → one fewer arc drawn.
|
||||
expect(svg.querySelectorAll('path')).toHaveLength(2);
|
||||
expect(svg.querySelectorAll('path')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
|
||||
import { calculateChartDimensions } from 'lib/visualization/charts/utils';
|
||||
import {
|
||||
calculateAverageLegendWidth,
|
||||
calculateChartDimensions,
|
||||
} from 'lib/visualization/charts/utils';
|
||||
|
||||
const labels = (count: number, length = 20): string[] =>
|
||||
Array.from({ length: count }, (_, i) =>
|
||||
@@ -49,63 +52,104 @@ describe('calculateChartDimensions', () => {
|
||||
expect(dims.width).toBe(784);
|
||||
});
|
||||
|
||||
it('RIGHT: never shrinks the column below the 150px floor', () => {
|
||||
it('RIGHT: never shrinks the column below the floor that fits its chrome', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 400,
|
||||
legendConfig: { position: LegendPosition.RIGHT },
|
||||
seriesLabels: labels(3, 3),
|
||||
});
|
||||
expect(dims.legendWidth).toBe(150);
|
||||
expect(dims.width).toBe(850);
|
||||
expect(dims.legendWidth).toBe(190);
|
||||
expect(dims.width).toBe(810);
|
||||
});
|
||||
|
||||
it('RIGHT: on a narrow container the legend never takes more than 40% of the width', () => {
|
||||
it('RIGHT: on a narrow container the legend keeps its chrome, up to half the width', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 300,
|
||||
containerHeight: 400,
|
||||
legendConfig: { position: LegendPosition.RIGHT },
|
||||
seriesLabels: labels(10, 40),
|
||||
});
|
||||
expect(dims.legendWidth).toBe(120);
|
||||
expect(dims.width).toBe(180);
|
||||
// 40% is 120px, too narrow for the column's own toolbar.
|
||||
expect(dims.legendWidth).toBe(150);
|
||||
expect(dims.width).toBe(150);
|
||||
});
|
||||
|
||||
it('BOTTOM: a single row of items reserves one legend row', () => {
|
||||
it('RIGHT: stops widening the column once the panel is narrower than its chrome', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 200,
|
||||
containerHeight: 400,
|
||||
legendConfig: { position: LegendPosition.RIGHT },
|
||||
seriesLabels: labels(10, 40),
|
||||
});
|
||||
expect(dims.legendWidth).toBe(100);
|
||||
expect(dims.width).toBe(100);
|
||||
});
|
||||
|
||||
it('BOTTOM: items that fit one row reserve exactly one row', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 500,
|
||||
legendConfig: { position: LegendPosition.BOTTOM },
|
||||
seriesLabels: labels(3),
|
||||
});
|
||||
// One row = line height (28) + padding (12).
|
||||
// One 28px row + the wrapper's 12px bottom padding.
|
||||
expect(dims.legendHeight).toBe(40);
|
||||
expect(dims.height).toBe(460);
|
||||
expect(dims.legendWidth).toBe(1000);
|
||||
});
|
||||
|
||||
it('BOTTOM: many items cap at two rows on a tall container', () => {
|
||||
it('BOTTOM: more items than one row reserve exactly two rows', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 500,
|
||||
legendConfig: { position: LegendPosition.BOTTOM },
|
||||
seriesLabels: labels(40),
|
||||
});
|
||||
// Two rows = 2 * 40 - 12 (no trailing padding) = 68, under the 80px cap.
|
||||
expect(dims.legendHeight).toBe(68);
|
||||
expect(dims.height).toBe(432);
|
||||
// Two 28px rows + the 2px row gap + 12px bottom padding — no room for a
|
||||
// clipped third row, and none left over.
|
||||
expect(dims.legendHeight).toBe(70);
|
||||
expect(dims.height).toBe(430);
|
||||
});
|
||||
|
||||
it('BOTTOM: on a short container the legend never takes more than 30% of the height', () => {
|
||||
it('BOTTOM: items one past a row still reserve two rows', () => {
|
||||
// 1000px wide fits 5 of these per row, so 6 items need a second row.
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 160,
|
||||
containerHeight: 500,
|
||||
legendConfig: { position: LegendPosition.BOTTOM },
|
||||
seriesLabels: labels(6),
|
||||
});
|
||||
expect(dims.legendHeight).toBe(70);
|
||||
});
|
||||
|
||||
it('BOTTOM: drops to a single row rather than take half a short panel', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 120,
|
||||
legendConfig: { position: LegendPosition.BOTTOM },
|
||||
seriesLabels: labels(40),
|
||||
});
|
||||
// Without the height-relative cap the legend would take 68px of a 160px
|
||||
// panel and the chart (pie especially) collapses to a sliver.
|
||||
expect(dims.legendHeight).toBe(48); // 30% of 160
|
||||
expect(dims.height).toBe(112);
|
||||
// A whole row goes rather than a clipped one being reserved.
|
||||
expect(dims.legendHeight).toBe(40);
|
||||
expect(dims.height).toBe(80);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateAverageLegendWidth', () => {
|
||||
it('scales with the label length', () => {
|
||||
// 16px of chrome + 30 chars at 8px.
|
||||
expect(calculateAverageLegendWidth(labels(4, 30))).toBe(256);
|
||||
});
|
||||
|
||||
it('never drops below what a row needs to contain its hover actions', () => {
|
||||
// Short or unnamed series would otherwise size a column the actions
|
||||
// escape, spilling over the item beside it.
|
||||
expect(calculateAverageLegendWidth(['cpu'])).toBe(110);
|
||||
expect(calculateAverageLegendWidth([''])).toBe(110);
|
||||
});
|
||||
|
||||
it('keeps the default estimate when there are no labels to measure', () => {
|
||||
expect(calculateAverageLegendWidth([])).toBe(120);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { MAX_LEGEND_WIDTH } from 'lib/uPlotV2/components/Legend/Legend';
|
||||
import {
|
||||
LEGEND_MAX_BOTTOM_ROWS,
|
||||
MIN_LEGEND_ITEM_WIDTH,
|
||||
LEGEND_ROW_GAP,
|
||||
LEGEND_ROW_HEIGHT,
|
||||
MAX_LEGEND_WIDTH,
|
||||
} from 'lib/uPlotV2/components/Legend/constants';
|
||||
import { LegendConfig, LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
export interface ChartDimensions {
|
||||
width: number;
|
||||
@@ -13,22 +19,31 @@ const LEGEND_WIDTH_PERCENTILE = 0.85;
|
||||
const DEFAULT_AVG_LABEL_LENGTH = 15;
|
||||
const BASE_LEGEND_WIDTH = 16;
|
||||
const LEGEND_PADDING = 12;
|
||||
const LEGEND_LINE_HEIGHT = 28;
|
||||
// Two rows are worth having, but not at the cost of half the panel.
|
||||
const MAX_SHORT_PANEL_LEGEND_RATIO = 0.5;
|
||||
|
||||
// RIGHT legend is a vertical column with its own width budget (cap protects the donut).
|
||||
const MAX_RIGHT_LEGEND_WIDTH = 320;
|
||||
const RIGHT_LEGEND_WIDTH_RATIO = 0.4;
|
||||
// Column padding + copy button, not covered by the text-length estimate.
|
||||
const RIGHT_LEGEND_RESERVED_WIDTH = 40;
|
||||
// Fits the toolbar's "Showing N of M series" readout plus the wrapper padding.
|
||||
const MIN_RIGHT_LEGEND_WIDTH = 190;
|
||||
// Past this the split inverts and the chart becomes the smaller half.
|
||||
const RIGHT_LEGEND_FLOOR_RATIO = 0.5;
|
||||
|
||||
/**
|
||||
* Calculates the average width of the legend items based on the labels of the series.
|
||||
* Never returns less than a legend row needs to hold its own hover actions.
|
||||
* @param legends - The labels of the series.
|
||||
* @returns The average width of the legend items.
|
||||
*/
|
||||
export function calculateAverageLegendWidth(legends: string[]): number {
|
||||
if (legends.length === 0) {
|
||||
return DEFAULT_AVG_LABEL_LENGTH * AVG_CHAR_WIDTH;
|
||||
return Math.max(
|
||||
MIN_LEGEND_ITEM_WIDTH,
|
||||
DEFAULT_AVG_LABEL_LENGTH * AVG_CHAR_WIDTH,
|
||||
);
|
||||
}
|
||||
|
||||
const lengths = legends.map((l) => l.length).sort((a, b) => a - b);
|
||||
@@ -36,7 +51,10 @@ export function calculateAverageLegendWidth(legends: string[]): number {
|
||||
const index = Math.ceil(LEGEND_WIDTH_PERCENTILE * lengths.length) - 1;
|
||||
const percentileLength = lengths[Math.max(0, index)];
|
||||
|
||||
return BASE_LEGEND_WIDTH + percentileLength * AVG_CHAR_WIDTH;
|
||||
return Math.max(
|
||||
MIN_LEGEND_ITEM_WIDTH,
|
||||
BASE_LEGEND_WIDTH + percentileLength * AVG_CHAR_WIDTH,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,7 +70,9 @@ export function calculateAverageLegendWidth(legends: string[]): number {
|
||||
* - Chart width is `containerWidth - legendWidth`.
|
||||
* - BOTTOM legend:
|
||||
* - Computes how many items fit per row, then uses at most 2 rows.
|
||||
* - `legendHeight` is derived from row count, capped by both a fixed pixel max and a % of container height.
|
||||
* - `legendHeight` is exactly those rows plus the wrapper's bottom padding, so
|
||||
* the rectangle never clips a row or reserves space for half of one. Two
|
||||
* rows that would take half a short panel fall back to one row.
|
||||
* - Chart height is `containerHeight - legendHeight`, never below 0.
|
||||
* - `legendsPerSet` is the number of legend items that fit horizontally, based on the same text-width approximation.
|
||||
*
|
||||
@@ -100,9 +120,14 @@ export function calculateChartDimensions({
|
||||
MAX_RIGHT_LEGEND_WIDTH,
|
||||
containerWidth * RIGHT_LEGEND_WIDTH_RATIO,
|
||||
);
|
||||
// The column's chrome outranks the 40% share on a narrow panel.
|
||||
const floorWidth = Math.min(
|
||||
MIN_RIGHT_LEGEND_WIDTH,
|
||||
containerWidth * RIGHT_LEGEND_FLOOR_RATIO,
|
||||
);
|
||||
const rightLegendWidth = Math.min(
|
||||
Math.max(150, desiredLegendWidth),
|
||||
maxRightLegendWidth,
|
||||
Math.max(MIN_RIGHT_LEGEND_WIDTH, desiredLegendWidth),
|
||||
Math.max(floorWidth, maxRightLegendWidth),
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -115,8 +140,6 @@ export function calculateChartDimensions({
|
||||
};
|
||||
}
|
||||
|
||||
const legendRowHeight = LEGEND_LINE_HEIGHT + LEGEND_PADDING;
|
||||
|
||||
const legendItemWidth = Math.ceil(
|
||||
Math.min(approxLegendItemWidth, MAX_LEGEND_WIDTH),
|
||||
);
|
||||
@@ -125,30 +148,30 @@ export function calculateChartDimensions({
|
||||
Math.floor((containerWidth - LEGEND_PADDING * 2) / legendItemWidth),
|
||||
);
|
||||
|
||||
const legendRowCount = Math.min(
|
||||
2,
|
||||
Math.ceil(legendItemCount / legendItemsPerRow),
|
||||
// The wrapper's bottom padding is inside this height (border-box).
|
||||
const heightForRows = (rowCount: number): number =>
|
||||
rowCount * LEGEND_ROW_HEIGHT +
|
||||
(rowCount - 1) * LEGEND_ROW_GAP +
|
||||
LEGEND_PADDING;
|
||||
|
||||
const neededRowCount = Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
LEGEND_MAX_BOTTOM_ROWS,
|
||||
Math.ceil(legendItemCount / legendItemsPerRow),
|
||||
),
|
||||
);
|
||||
|
||||
const idealBottomLegendHeight =
|
||||
legendRowCount > 1
|
||||
? legendRowCount * legendRowHeight - LEGEND_PADDING
|
||||
: legendRowHeight;
|
||||
// Without this, short grid panels hand most of their area to the legend and
|
||||
// the chart — the pie donut especially — collapses to a sliver. Dropping a
|
||||
// whole row beats clipping one.
|
||||
const legendRowCount =
|
||||
neededRowCount > 1 &&
|
||||
heightForRows(neededRowCount) > containerHeight * MAX_SHORT_PANEL_LEGEND_RATIO
|
||||
? 1
|
||||
: neededRowCount;
|
||||
|
||||
// Cap at two rows / 80px, and never more than 30% of the container height
|
||||
// (the doc above always promised the %-cap; without it, short grid panels
|
||||
// hand most of their area to the legend and the chart — the pie donut
|
||||
// especially — collapses to a sliver). 30% mirrors the RIGHT-legend width cap.
|
||||
const maxAllowedLegendHeight = Math.min(
|
||||
2 * legendRowHeight,
|
||||
80,
|
||||
Math.floor(containerHeight * 0.3),
|
||||
);
|
||||
|
||||
const bottomLegendHeight = Math.min(
|
||||
idealBottomLegendHeight,
|
||||
maxAllowedLegendHeight,
|
||||
);
|
||||
const bottomLegendHeight = heightForRows(legendRowCount);
|
||||
|
||||
return {
|
||||
width: containerWidth,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { LegendAction } from 'lib/uPlotV2/components/types';
|
||||
import {
|
||||
getStoredSeriesVisibility,
|
||||
updateSeriesVisibilityToLocalStorage,
|
||||
} from 'lib/visualization/panels/utils/legendVisibilityUtils';
|
||||
import type { MouseEvent } from 'react';
|
||||
|
||||
import { PieSlice } from 'lib/visualization/charts/types';
|
||||
import { usePieInteractions } from 'lib/visualization/hooks/usePieInteractions';
|
||||
@@ -24,22 +24,6 @@ const DATA: PieSlice[] = [
|
||||
{ label: 'checkout', value: 40, color: '#c' },
|
||||
];
|
||||
|
||||
// Builds a fake legend click/move event: `e.target.closest('[data-legend-item-id]')`
|
||||
// resolves to the item at `index`, and `e.target.dataset.isLegendMarker` flags marker clicks.
|
||||
function legendEvent(
|
||||
index: number | null,
|
||||
isMarker = false,
|
||||
): MouseEvent<HTMLDivElement> {
|
||||
const itemEl =
|
||||
index == null ? null : { dataset: { legendItemId: String(index) } };
|
||||
return {
|
||||
target: {
|
||||
closest: (): unknown => itemEl,
|
||||
dataset: { isLegendMarker: isMarker ? 'true' : undefined },
|
||||
},
|
||||
} as unknown as MouseEvent<HTMLDivElement>;
|
||||
}
|
||||
|
||||
describe('usePieInteractions', () => {
|
||||
beforeEach(() => {
|
||||
mockGetStored.mockReturnValue(null);
|
||||
@@ -59,11 +43,16 @@ describe('usePieInteractions', () => {
|
||||
expect(result.current.active).toBeNull();
|
||||
});
|
||||
|
||||
describe('marker click (toggle one)', () => {
|
||||
describe('row toggle', () => {
|
||||
it('hides then unhides the clicked slice', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA, 'panel-1'));
|
||||
|
||||
act(() => result.current.onLegendClick(legendEvent(1, true)));
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.TOGGLE,
|
||||
seriesIndex: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.visibleData).toStrictEqual([DATA[0], DATA[2]]);
|
||||
expect(result.current.legendItems[1].show).toBe(false);
|
||||
@@ -73,18 +62,50 @@ describe('usePieInteractions', () => {
|
||||
{ label: 'checkout', show: true },
|
||||
]);
|
||||
|
||||
act(() => result.current.onLegendClick(legendEvent(1, true)));
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.TOGGLE,
|
||||
seriesIndex: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.visibleData).toStrictEqual(DATA);
|
||||
expect(result.current.legendItems[1].show).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('label click (isolate / reset)', () => {
|
||||
it('isolates the clicked slice, then resets on a second click', () => {
|
||||
describe('the last slice showing', () => {
|
||||
it('cannot be hidden', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
|
||||
act(() => result.current.onLegendClick(legendEvent(0, false)));
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.SHOW_ONLY,
|
||||
seriesIndex: 0,
|
||||
}),
|
||||
);
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.TOGGLE,
|
||||
seriesIndex: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
// An empty donut is never a state worth reaching.
|
||||
expect(result.current.visibleData).toStrictEqual([DATA[0]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Only', () => {
|
||||
it('isolates the slice', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.SHOW_ONLY,
|
||||
seriesIndex: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.visibleData).toStrictEqual([DATA[0]]);
|
||||
expect(result.current.legendItems.map((i) => i.show)).toStrictEqual([
|
||||
@@ -92,8 +113,39 @@ describe('usePieInteractions', () => {
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
});
|
||||
|
||||
act(() => result.current.onLegendClick(legendEvent(0, false)));
|
||||
it('switches the isolation to another slice', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.SHOW_ONLY,
|
||||
seriesIndex: 0,
|
||||
}),
|
||||
);
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.SHOW_ONLY,
|
||||
seriesIndex: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.visibleData).toStrictEqual([DATA[2]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('All', () => {
|
||||
it('brings every hidden slice back', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.SHOW_ONLY,
|
||||
seriesIndex: 0,
|
||||
}),
|
||||
);
|
||||
act(() => result.current.onLegendAction({ type: LegendAction.SHOW_ALL }));
|
||||
|
||||
expect(result.current.visibleData).toStrictEqual(DATA);
|
||||
});
|
||||
@@ -103,11 +155,37 @@ describe('usePieInteractions', () => {
|
||||
it('focuses the hovered slice and clears on leave', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
|
||||
act(() => result.current.onLegendMouseMove(legendEvent(2)));
|
||||
act(() =>
|
||||
result.current.onLegendAction({ type: LegendAction.HOVER, seriesIndex: 2 }),
|
||||
);
|
||||
expect(result.current.active).toStrictEqual(DATA[2]);
|
||||
expect(result.current.focusedSeriesIndex).toBe(2);
|
||||
|
||||
act(() => result.current.onLegendMouseLeave());
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.HOVER,
|
||||
seriesIndex: null,
|
||||
}),
|
||||
);
|
||||
expect(result.current.active).toBeNull();
|
||||
expect(result.current.focusedSeriesIndex).toBeNull();
|
||||
});
|
||||
|
||||
it('drops the focus when the focused slice is hidden', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
|
||||
act(() =>
|
||||
result.current.onLegendAction({ type: LegendAction.HOVER, seriesIndex: 1 }),
|
||||
);
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.TOGGLE,
|
||||
seriesIndex: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
// Otherwise every remaining arc stays dimmed and the donut reads as an
|
||||
// isolation instead of one slice being excluded.
|
||||
expect(result.current.active).toBeNull();
|
||||
expect(result.current.focusedSeriesIndex).toBeNull();
|
||||
});
|
||||
@@ -115,8 +193,15 @@ describe('usePieInteractions', () => {
|
||||
it('does not focus a hidden slice', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
|
||||
act(() => result.current.onLegendClick(legendEvent(1, true))); // hide cart
|
||||
act(() => result.current.onLegendMouseMove(legendEvent(1)));
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.TOGGLE,
|
||||
seriesIndex: 1,
|
||||
}),
|
||||
);
|
||||
act(() =>
|
||||
result.current.onLegendAction({ type: LegendAction.HOVER, seriesIndex: 1 }),
|
||||
);
|
||||
|
||||
expect(result.current.active).toBeNull();
|
||||
});
|
||||
@@ -125,7 +210,12 @@ describe('usePieInteractions', () => {
|
||||
describe('persistence', () => {
|
||||
it('does not write to storage when no id is provided', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
act(() => result.current.onLegendClick(legendEvent(0, true)));
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.TOGGLE,
|
||||
seriesIndex: 0,
|
||||
}),
|
||||
);
|
||||
expect(mockUpdateStored).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import type { Dispatch, MouseEvent, SetStateAction } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
LegendAction,
|
||||
LegendActionPayload,
|
||||
OnLegendAction,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
getStoredSeriesVisibility,
|
||||
@@ -18,27 +23,15 @@ export interface UsePieInteractionsResult {
|
||||
legendItems: LegendItem[];
|
||||
/** Index of the active slice for the legend's focus highlight, or null. */
|
||||
focusedSeriesIndex: number | null;
|
||||
onLegendClick: (e: MouseEvent<HTMLDivElement>) => void;
|
||||
onLegendMouseMove: (e: MouseEvent<HTMLDivElement>) => void;
|
||||
onLegendMouseLeave: () => void;
|
||||
}
|
||||
|
||||
// Reads the slice index off the nearest `[data-legend-item-id]` ancestor of the
|
||||
// event target (the shared Legend tags each item with its seriesIndex).
|
||||
function getLegendIndex(e: MouseEvent<HTMLDivElement>): number | null {
|
||||
const el = (e.target as HTMLElement | null)?.closest<HTMLElement>(
|
||||
'[data-legend-item-id]',
|
||||
);
|
||||
const id = el?.dataset.legendItemId;
|
||||
return id != null ? Number(id) : null;
|
||||
/** Every legend interaction, dispatched by type. */
|
||||
onLegendAction: OnLegendAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pie interaction + derived state: hover/focus, slice hide/unhide (mirroring the
|
||||
* uPlot legend — marker toggles one, label isolates), and persistence of the
|
||||
* hidden set to localStorage (keyed by `id`, matched by label) so it survives
|
||||
* reloads. Returns the visible slices, legend items, focus index, and the
|
||||
* legend container handlers.
|
||||
* Pie interaction + derived state: hover/focus, slice hide/show driven by the
|
||||
* shared legend's actions, and persistence of the hidden set to localStorage
|
||||
* (keyed by `id`, matched by label) so it survives reloads. Returns the visible
|
||||
* slices, legend items, focus index, and the legend action dispatch.
|
||||
*/
|
||||
export function usePieInteractions(
|
||||
data: PieSlice[],
|
||||
@@ -48,7 +41,6 @@ export function usePieInteractions(
|
||||
const [hiddenIndices, setHiddenIndices] = useState<Set<number>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const isolatedIndexRef = useRef<number | null>(null);
|
||||
|
||||
const legendItems = useMemo<LegendItem[]>(
|
||||
() =>
|
||||
@@ -104,65 +96,88 @@ export function usePieInteractions(
|
||||
[id, data],
|
||||
);
|
||||
|
||||
const onLegendMouseMove = useCallback(
|
||||
(e: MouseEvent<HTMLDivElement>): void => {
|
||||
const index = getLegendIndex(e);
|
||||
const hoverSeries = useCallback(
|
||||
(sliceIndex: number | null): void => {
|
||||
// Don't focus/dim for hidden slices — they aren't on the donut.
|
||||
setActive(index != null && !hiddenIndices.has(index) ? data[index] : null);
|
||||
setActive(
|
||||
sliceIndex != null && !hiddenIndices.has(sliceIndex)
|
||||
? data[sliceIndex]
|
||||
: null,
|
||||
);
|
||||
},
|
||||
[data, hiddenIndices],
|
||||
);
|
||||
|
||||
// Marker click toggles just that slice on/off; label click isolates it
|
||||
// (clicking the isolated one again resets to all) — mirrors the uPlot legend.
|
||||
const onLegendClick = useCallback(
|
||||
(e: MouseEvent<HTMLDivElement>): void => {
|
||||
const index = getLegendIndex(e);
|
||||
if (index == null) {
|
||||
return;
|
||||
}
|
||||
const isMarker = (e.target as HTMLElement).dataset.isLegendMarker;
|
||||
|
||||
if (isMarker) {
|
||||
const next = new Set(hiddenIndices);
|
||||
if (next.has(index)) {
|
||||
next.delete(index);
|
||||
} else {
|
||||
next.add(index);
|
||||
const toggleSeries = useCallback(
|
||||
(sliceIndex: number): void => {
|
||||
const next = new Set(hiddenIndices);
|
||||
if (next.has(sliceIndex)) {
|
||||
next.delete(sliceIndex);
|
||||
} else {
|
||||
// An empty donut is never worth reaching.
|
||||
if (data.length - next.size <= 1) {
|
||||
return;
|
||||
}
|
||||
applyHidden(next);
|
||||
return;
|
||||
next.add(sliceIndex);
|
||||
}
|
||||
applyHidden(next);
|
||||
},
|
||||
[data.length, hiddenIndices, applyHidden],
|
||||
);
|
||||
|
||||
const isReset = isolatedIndexRef.current === index;
|
||||
isolatedIndexRef.current = isReset ? null : index;
|
||||
if (isReset) {
|
||||
applyHidden(new Set());
|
||||
return;
|
||||
}
|
||||
const showOnlySeries = useCallback(
|
||||
(sliceIndex: number): void => {
|
||||
const next = new Set<number>();
|
||||
data.forEach((_, i) => {
|
||||
if (i !== index) {
|
||||
next.add(i);
|
||||
data.forEach((_, index) => {
|
||||
if (index !== sliceIndex) {
|
||||
next.add(index);
|
||||
}
|
||||
});
|
||||
applyHidden(next);
|
||||
},
|
||||
[data, hiddenIndices, applyHidden],
|
||||
[data, applyHidden],
|
||||
);
|
||||
|
||||
const onLegendMouseLeave = useCallback((): void => setActive(null), []);
|
||||
const showAllSeries = useCallback(
|
||||
(): void => applyHidden(new Set()),
|
||||
[applyHidden],
|
||||
);
|
||||
|
||||
const focusedIndex = active ? data.indexOf(active) : -1;
|
||||
const onLegendAction = useCallback(
|
||||
(payload: LegendActionPayload): void => {
|
||||
switch (payload.type) {
|
||||
case LegendAction.TOGGLE:
|
||||
toggleSeries(payload.seriesIndex);
|
||||
break;
|
||||
case LegendAction.SHOW_ONLY:
|
||||
showOnlySeries(payload.seriesIndex);
|
||||
break;
|
||||
case LegendAction.SHOW_ALL:
|
||||
showAllSeries();
|
||||
break;
|
||||
case LegendAction.HOVER:
|
||||
hoverSeries(payload.seriesIndex);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[toggleSeries, showOnlySeries, showAllSeries, hoverSeries],
|
||||
);
|
||||
|
||||
const activeIndex = active ? data.indexOf(active) : -1;
|
||||
// Left active, a hidden slice keeps every other arc dimmed, which reads as an
|
||||
// isolation rather than as one slice being excluded.
|
||||
const effectiveActive =
|
||||
activeIndex >= 0 && !hiddenIndices.has(activeIndex) ? active : null;
|
||||
const focusedIndex = effectiveActive ? activeIndex : -1;
|
||||
|
||||
return {
|
||||
active,
|
||||
active: effectiveActive,
|
||||
setActive,
|
||||
visibleData,
|
||||
legendItems,
|
||||
focusedSeriesIndex: focusedIndex >= 0 ? focusedIndex : null,
|
||||
onLegendClick,
|
||||
onLegendMouseMove,
|
||||
onLegendMouseLeave,
|
||||
onLegendAction,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
box-sizing: border-box;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding-left: 12px;
|
||||
padding-bottom: 12px;
|
||||
padding: 0 12px 12px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { calculateChartDimensions } from 'lib/visualization/charts/utils';
|
||||
import { MAX_LEGEND_WIDTH } from 'lib/uPlotV2/components/Legend/Legend';
|
||||
import { MAX_LEGEND_WIDTH } from 'lib/uPlotV2/components/Legend/constants';
|
||||
import { LegendConfig, LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { rest } from 'msw';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { encode } from 'js-base64';
|
||||
import type { Tags } from 'types/reducer/trace';
|
||||
|
||||
import {
|
||||
choiceControl,
|
||||
countControl,
|
||||
multiChoiceControl,
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
|
||||
import {
|
||||
attributeKeysFor,
|
||||
attributeKeysResponse,
|
||||
attributeValuesFor,
|
||||
attributeValuesResponse,
|
||||
dependencyGraphResponse,
|
||||
MAX_DEPENDENCIES,
|
||||
RESOURCE_FILTERS,
|
||||
type ResourceFilter,
|
||||
resourceFilterQueries,
|
||||
SERVICE_HEALTH,
|
||||
type ServiceHealth,
|
||||
} from './__story_mockdata__/serviceMap';
|
||||
|
||||
const GRAPH = 'Service map · graph';
|
||||
const FILTERS = 'Service map · filters';
|
||||
|
||||
interface DependencyGraphBody {
|
||||
tags?: Tags[];
|
||||
}
|
||||
|
||||
const serviceMapRoute = (filters: readonly ResourceFilter[]): string => {
|
||||
if (filters.length === 0) {
|
||||
return ROUTES.SERVICE_MAP;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
[QueryParams.resourceAttributes]: encode(
|
||||
JSON.stringify(resourceFilterQueries(filters)),
|
||||
),
|
||||
});
|
||||
|
||||
return `${ROUTES.SERVICE_MAP}?${params.toString()}`;
|
||||
};
|
||||
|
||||
export const serviceMapMocks = defineStoryMocks({
|
||||
controls: {
|
||||
services: countControl('Dependencies', {
|
||||
group: GRAPH,
|
||||
description:
|
||||
'Call edges the endpoint answers with. Every one of them is a link and its two nodes; 0 is the "No Service Found" card.',
|
||||
value: MAX_DEPENDENCIES,
|
||||
max: MAX_DEPENDENCIES,
|
||||
}),
|
||||
health: choiceControl<ServiceHealth>('Service health', {
|
||||
group: GRAPH,
|
||||
description:
|
||||
'Error rate on the calls into a service, which is what turns its node red.',
|
||||
options: SERVICE_HEALTH,
|
||||
value: 'degraded',
|
||||
}),
|
||||
filters: multiChoiceControl<ResourceFilter>('Applied filters', {
|
||||
group: FILTERS,
|
||||
description:
|
||||
'Resource attributes the page opens with, as the environment selector and a chip. The graph narrows to what they match.',
|
||||
options: RESOURCE_FILTERS,
|
||||
value: [],
|
||||
}),
|
||||
environments: countControl('Environments', {
|
||||
group: FILTERS,
|
||||
description: 'Values the environment selector offers.',
|
||||
value: 3,
|
||||
max: 5,
|
||||
}),
|
||||
resourceAttributes: toggleControl('Resource attributes ingested', {
|
||||
group: FILTERS,
|
||||
description:
|
||||
'Off answers both autocomplete endpoints with nothing, which is what the filter reports as no resource attributes available.',
|
||||
value: true,
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.post(
|
||||
'http://localhost/api/v1/dependency_graph',
|
||||
response.json(async (req) => {
|
||||
const body = (await req.json()) as DependencyGraphBody;
|
||||
|
||||
return dependencyGraphResponse({
|
||||
count: values.services,
|
||||
health: values.health,
|
||||
tags: body.tags,
|
||||
});
|
||||
}),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v3/autocomplete/attribute_keys',
|
||||
response.json((req) =>
|
||||
attributeKeysResponse(
|
||||
values.resourceAttributes
|
||||
? attributeKeysFor(req.url.searchParams.get('searchText'))
|
||||
: [],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v3/autocomplete/attribute_values',
|
||||
response.json((req) =>
|
||||
attributeValuesResponse(
|
||||
values.resourceAttributes
|
||||
? attributeValuesFor(
|
||||
req.url.searchParams.get('attributeKey'),
|
||||
values.environments,
|
||||
)
|
||||
: [],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
config: (values) => ({ route: serviceMapRoute(values.filters) }),
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { serviceMapMocks } from './ServiceMap.stories.mocks';
|
||||
|
||||
import ServiceMapContainer from '../index';
|
||||
|
||||
type ServiceMapArgs = PageStoryArgs<typeof serviceMapMocks>;
|
||||
|
||||
const pageStory = storyMocks(serviceMapMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* Service to service calls as a force graph over `/api/v1/dependency_graph`,
|
||||
* nodes sized by request rate and coloured by error rate, with link details on
|
||||
* hover.
|
||||
*
|
||||
* Route: `/service-map`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Services/Service Map',
|
||||
tags: ['beta', 'play'],
|
||||
component: ServiceMapContainer,
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<ServiceMapArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<ServiceMapArgs>;
|
||||
|
||||
/** The keys are only fetched once the select opens, past the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
/**
|
||||
* The whole topology: one node per service, sized by how many calls it takes,
|
||||
* red where those calls are failing, and a link per dependency carrying the
|
||||
* latency and error rate its tooltip reports.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/** A topology without service errors, preserving the healthy node treatment. */
|
||||
export const HealthyTopology: Story = {
|
||||
args: { health: 'healthy' },
|
||||
};
|
||||
|
||||
/**
|
||||
* The map narrowed to one environment and one cluster: the environment selector
|
||||
* carries the first, a chip carries the second, and the graph is what is left.
|
||||
*/
|
||||
export const Filtered: Story = {
|
||||
args: { filters: ['environment', 'cluster'] },
|
||||
};
|
||||
|
||||
/** A workspace with no dependencies recorded in the selected time range. */
|
||||
export const NoServices: Story = {
|
||||
args: { services: 0 },
|
||||
};
|
||||
|
||||
/** The filter's real empty branch when no resource attributes have been ingested. */
|
||||
export const NoResourceAttributes: Story = {
|
||||
args: { resourceAttributes: false },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const filter = await within(canvasElement).findByTestId(
|
||||
'resource-attributes-filter',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.click(within(filter).getByRole('combobox'));
|
||||
await screen.findByText(
|
||||
/No resource attributes available to filter/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The attribute filter open: of everything the endpoint returns, the map only
|
||||
* offers the three keys it can send to `/dependency_graph`.
|
||||
*/
|
||||
export const FilterAttributes: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
const filter = await canvas.findByTestId(
|
||||
'resource-attributes-filter',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
// The select opens on a press inside it: a click on the wrapper the test id
|
||||
// sits on never reaches the handler that opens the list.
|
||||
await userEvent.click(within(filter).getByRole('combobox'));
|
||||
await screen.findByText('k8s.cluster.name', undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
@@ -1,352 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import type { IResourceAttribute } from 'hooks/useResourceAttribute/types';
|
||||
import { getResourceDeploymentKeys } from 'hooks/useResourceAttribute/utils';
|
||||
import type { ServicesMapItem } from 'store/actions/serviceMap';
|
||||
import type {
|
||||
TagKeysPayloadProps,
|
||||
TagValuesPayloadProps,
|
||||
} from 'types/api/metrics/getResourceAttributes';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import type { Tags } from 'types/reducer/trace';
|
||||
|
||||
export const SERVICE_HEALTH = ['healthy', 'degraded', 'failing'] as const;
|
||||
|
||||
export type ServiceHealth = (typeof SERVICE_HEALTH)[number];
|
||||
|
||||
interface Dependency {
|
||||
parent: string;
|
||||
child: string;
|
||||
callCount: number;
|
||||
callRate: number;
|
||||
/** Nanoseconds: the link tooltip divides by 1e6 to show milliseconds. */
|
||||
p99: number;
|
||||
environment: string;
|
||||
cluster: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One call edge per entry, parents before children, so slicing the head of the
|
||||
* list keeps the graph connected instead of leaving orphaned nodes behind.
|
||||
*/
|
||||
const DEPENDENCIES: Dependency[] = [
|
||||
{
|
||||
parent: 'gateway',
|
||||
child: 'frontend',
|
||||
callCount: 41200,
|
||||
callRate: 68.4,
|
||||
p99: 184_000_000,
|
||||
environment: 'production',
|
||||
cluster: 'prod-us-east',
|
||||
},
|
||||
{
|
||||
parent: 'frontend',
|
||||
child: 'auth',
|
||||
callCount: 12800,
|
||||
callRate: 21.3,
|
||||
p99: 46_000_000,
|
||||
environment: 'production',
|
||||
cluster: 'prod-us-east',
|
||||
},
|
||||
{
|
||||
parent: 'frontend',
|
||||
child: 'catalogue',
|
||||
callCount: 18600,
|
||||
callRate: 31,
|
||||
p99: 92_000_000,
|
||||
environment: 'production',
|
||||
cluster: 'prod-us-east',
|
||||
},
|
||||
{
|
||||
parent: 'frontend',
|
||||
child: 'cart',
|
||||
callCount: 9400,
|
||||
callRate: 15.6,
|
||||
p99: 58_000_000,
|
||||
environment: 'production',
|
||||
cluster: 'prod-us-east',
|
||||
},
|
||||
{
|
||||
parent: 'cart',
|
||||
child: 'redis',
|
||||
callCount: 7300,
|
||||
callRate: 12.1,
|
||||
p99: 4_000_000,
|
||||
environment: 'production',
|
||||
cluster: 'prod-us-east',
|
||||
},
|
||||
{
|
||||
parent: 'catalogue',
|
||||
child: 'mysql',
|
||||
callCount: 15200,
|
||||
callRate: 25.3,
|
||||
p99: 31_000_000,
|
||||
environment: 'production',
|
||||
cluster: 'prod-us-east',
|
||||
},
|
||||
{
|
||||
parent: 'auth',
|
||||
child: 'mysql',
|
||||
callCount: 8100,
|
||||
callRate: 13.5,
|
||||
p99: 27_000_000,
|
||||
environment: 'production',
|
||||
cluster: 'prod-us-east',
|
||||
},
|
||||
{
|
||||
parent: 'frontend',
|
||||
child: 'checkout',
|
||||
callCount: 6200,
|
||||
callRate: 10.3,
|
||||
p99: 210_000_000,
|
||||
environment: 'production',
|
||||
cluster: 'prod-us-east',
|
||||
},
|
||||
{
|
||||
parent: 'checkout',
|
||||
child: 'payments',
|
||||
callCount: 5900,
|
||||
callRate: 9.8,
|
||||
p99: 340_000_000,
|
||||
environment: 'production',
|
||||
cluster: 'prod-us-east',
|
||||
},
|
||||
{
|
||||
parent: 'checkout',
|
||||
child: 'shipping',
|
||||
callCount: 5400,
|
||||
callRate: 9,
|
||||
p99: 120_000_000,
|
||||
environment: 'production',
|
||||
cluster: 'prod-us-east',
|
||||
},
|
||||
{
|
||||
parent: 'payments',
|
||||
child: 'stripe-proxy',
|
||||
callCount: 5100,
|
||||
callRate: 8.5,
|
||||
p99: 290_000_000,
|
||||
environment: 'production',
|
||||
cluster: 'prod-us-east',
|
||||
},
|
||||
{
|
||||
parent: 'shipping',
|
||||
child: 'geo-service',
|
||||
callCount: 4700,
|
||||
callRate: 7.8,
|
||||
p99: 76_000_000,
|
||||
environment: 'production',
|
||||
cluster: 'prod-eu-west',
|
||||
},
|
||||
{
|
||||
parent: 'geo-service',
|
||||
child: 'redis',
|
||||
callCount: 4300,
|
||||
callRate: 7.1,
|
||||
p99: 3_000_000,
|
||||
environment: 'production',
|
||||
cluster: 'prod-eu-west',
|
||||
},
|
||||
{
|
||||
parent: 'catalogue',
|
||||
child: 'recommendations',
|
||||
callCount: 3800,
|
||||
callRate: 6.3,
|
||||
p99: 150_000_000,
|
||||
environment: 'staging',
|
||||
cluster: 'staging-eu',
|
||||
},
|
||||
{
|
||||
parent: 'recommendations',
|
||||
child: 'ml-inference',
|
||||
callCount: 3500,
|
||||
callRate: 5.8,
|
||||
p99: 480_000_000,
|
||||
environment: 'staging',
|
||||
cluster: 'staging-eu',
|
||||
},
|
||||
{
|
||||
parent: 'notifications',
|
||||
child: 'email-relay',
|
||||
callCount: 900,
|
||||
callRate: 1.5,
|
||||
p99: 65_000_000,
|
||||
environment: 'staging',
|
||||
cluster: 'staging-eu',
|
||||
},
|
||||
];
|
||||
|
||||
export const MAX_DEPENDENCIES = DEPENDENCIES.length;
|
||||
|
||||
const DEGRADED_SERVICES = ['payments', 'redis'];
|
||||
|
||||
const ERROR_RATES = [1.2, 3.4, 0.8, 6.1, 2.5];
|
||||
|
||||
const errorRateFor = (
|
||||
child: string,
|
||||
health: ServiceHealth,
|
||||
index: number,
|
||||
): number => {
|
||||
if (health === 'healthy') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (health === 'degraded' && !DEGRADED_SERVICES.includes(child)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return ERROR_RATES[index % ERROR_RATES.length];
|
||||
};
|
||||
|
||||
const ATTRIBUTE_BY_TAG_KEY: Record<string, 'environment' | 'cluster'> = {
|
||||
'deployment.environment': 'environment',
|
||||
'k8s.cluster.name': 'cluster',
|
||||
};
|
||||
|
||||
/**
|
||||
* The page sends its resource-attribute chips as trace tags, so the response has
|
||||
* to narrow with them: a filter that changed nothing would look broken.
|
||||
*/
|
||||
const matchesTag = (dependency: Dependency, tag: Tags): boolean => {
|
||||
const attribute = ATTRIBUTE_BY_TAG_KEY[tag.Key];
|
||||
|
||||
if (!attribute) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const matched = tag.StringValues.includes(dependency[attribute]);
|
||||
|
||||
return tag.Operator === 'NotIn' ? !matched : matched;
|
||||
};
|
||||
|
||||
interface DependencyGraphOptions {
|
||||
count: number;
|
||||
health: ServiceHealth;
|
||||
tags?: Tags[];
|
||||
}
|
||||
|
||||
export const dependencyGraphResponse = ({
|
||||
count,
|
||||
health,
|
||||
tags = [],
|
||||
}: DependencyGraphOptions): ServicesMapItem[] =>
|
||||
DEPENDENCIES.slice(0, count)
|
||||
.filter((dependency) => tags.every((tag) => matchesTag(dependency, tag)))
|
||||
.map(({ parent, child, callCount, callRate, p99 }, index) => ({
|
||||
parent,
|
||||
child,
|
||||
callCount,
|
||||
callRate,
|
||||
p99,
|
||||
errorRate: errorRateFor(child, health, index),
|
||||
}));
|
||||
|
||||
const ENVIRONMENT_KEY = 'resource_deployment_environment';
|
||||
const CLUSTER_KEY = 'resource_k8s_cluster_name';
|
||||
const NAMESPACE_KEY = 'resource_k8s_cluster_namespace';
|
||||
|
||||
/**
|
||||
* `service.name` and `host.name` are not in the service-map whitelist, so they
|
||||
* are here to be dropped: the page filters the keys it offers down to the three
|
||||
* it can send to `/dependency_graph`.
|
||||
*/
|
||||
const ATTRIBUTE_KEYS = [
|
||||
ENVIRONMENT_KEY,
|
||||
CLUSTER_KEY,
|
||||
NAMESPACE_KEY,
|
||||
'resource_service_name',
|
||||
'resource_host_name',
|
||||
];
|
||||
|
||||
const ENVIRONMENTS = [
|
||||
'production',
|
||||
'staging',
|
||||
'development',
|
||||
'canary',
|
||||
'load-test',
|
||||
];
|
||||
|
||||
const CLUSTERS = ['prod-us-east', 'prod-eu-west', 'staging-eu'];
|
||||
|
||||
const NAMESPACES = ['default', 'checkout', 'ingest'];
|
||||
|
||||
/**
|
||||
* The environment selector asks the same endpoint as the attribute filter, and
|
||||
* the deployment key it matches on is the only thing telling the two apart.
|
||||
*/
|
||||
export const attributeKeysFor = (searchText: string | null): string[] =>
|
||||
searchText === getResourceDeploymentKeys()
|
||||
? [getResourceDeploymentKeys()]
|
||||
: ATTRIBUTE_KEYS;
|
||||
|
||||
export const attributeValuesFor = (
|
||||
attributeKey: string | null,
|
||||
environments: number,
|
||||
): string[] => {
|
||||
if (
|
||||
attributeKey === getResourceDeploymentKeys() ||
|
||||
attributeKey === ENVIRONMENT_KEY
|
||||
) {
|
||||
return ENVIRONMENTS.slice(0, environments);
|
||||
}
|
||||
|
||||
if (attributeKey === CLUSTER_KEY) {
|
||||
return CLUSTERS;
|
||||
}
|
||||
|
||||
return attributeKey === NAMESPACE_KEY ? NAMESPACES : [];
|
||||
};
|
||||
|
||||
export const attributeKeysResponse = (
|
||||
keys: readonly string[],
|
||||
): TagKeysPayloadProps & { status: string } => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
attributeKeys: keys.map((key) => ({
|
||||
key,
|
||||
type: 'resource',
|
||||
dataType: DataTypes.String,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
export const attributeValuesResponse = (
|
||||
values: readonly string[],
|
||||
): TagValuesPayloadProps & { status: string } => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
boolAttributeValues: null,
|
||||
numberAttributeValues: null,
|
||||
stringAttributeValues: [...values],
|
||||
},
|
||||
});
|
||||
|
||||
export const RESOURCE_FILTERS = ['environment', 'cluster'] as const;
|
||||
|
||||
export type ResourceFilter = (typeof RESOURCE_FILTERS)[number];
|
||||
|
||||
/**
|
||||
* The environment query has to carry the deployment key the app derives, since
|
||||
* that is what routes it into the environment selector instead of a chip.
|
||||
*/
|
||||
const FILTER_QUERIES: Record<ResourceFilter, IResourceAttribute> = {
|
||||
environment: {
|
||||
id: 'storybook-environment',
|
||||
tagKey: getResourceDeploymentKeys(),
|
||||
operator: 'IN',
|
||||
tagValue: ['production'],
|
||||
},
|
||||
cluster: {
|
||||
id: 'storybook-cluster',
|
||||
tagKey: CLUSTER_KEY,
|
||||
operator: 'IN',
|
||||
tagValue: ['prod-us-east'],
|
||||
},
|
||||
};
|
||||
|
||||
export const resourceFilterQueries = (
|
||||
filters: readonly ResourceFilter[],
|
||||
): IResourceAttribute[] => filters.map((filter) => FILTER_QUERIES[filter]);
|
||||
@@ -1,47 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import ROUTES from 'constants/routes';
|
||||
import { rest } from 'msw';
|
||||
|
||||
import { countControl } from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
|
||||
import {
|
||||
usageResponse,
|
||||
usageServicesResponse,
|
||||
} from './__story_mockdata__/usage';
|
||||
|
||||
const SPANS = 'Usage · spans';
|
||||
|
||||
export const usageMocks = defineStoryMocks({
|
||||
controls: {
|
||||
spansPerBucket: countControl('Spans per bucket, in thousands', {
|
||||
group: SPANS,
|
||||
description:
|
||||
'What each bar carries, which the total above the chart is the sum of. Zero is a workspace sending nothing.',
|
||||
value: 240,
|
||||
max: 2000,
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.get(
|
||||
'http://localhost/api/v1/usage',
|
||||
response.json((req) =>
|
||||
usageResponse(
|
||||
Number(req.url.searchParams.get('start') ?? 0),
|
||||
Number(req.url.searchParams.get('end') ?? 0),
|
||||
Number(req.url.searchParams.get('step') ?? 3600),
|
||||
values.spansPerBucket * 1000,
|
||||
),
|
||||
),
|
||||
),
|
||||
rest.post(
|
||||
'http://localhost/api/v2/services',
|
||||
response.json(() => usageServicesResponse()),
|
||||
),
|
||||
],
|
||||
config: () => ({ route: ROUTES.USAGE_EXPLORER }),
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import UsageExplorerContainer from '../index';
|
||||
import { usageMocks } from './Usage.stories.mocks';
|
||||
|
||||
type UsageArgs = PageStoryArgs<typeof usageMocks>;
|
||||
|
||||
const pageStory = storyMocks(usageMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* Spans ingested per service over a period, the usage view that predates Cost
|
||||
* Meter.
|
||||
*
|
||||
* Route: `/usage-explorer`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Metering/Usage Explorer',
|
||||
component: UsageExplorerContainer,
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<UsageArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<UsageArgs>;
|
||||
|
||||
/** Spans ingested over the window, and the total they add up to. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** A workspace that has not sent anything yet. */
|
||||
export const NoSpans: Story = {
|
||||
args: { spansPerBucket: 0 },
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import type { UsageDataItem } from 'store/actions';
|
||||
import type { ServicesList } from 'types/api/metrics/getService';
|
||||
|
||||
export const USAGE_SERVICES = [
|
||||
'frontend',
|
||||
'checkout',
|
||||
'cart',
|
||||
'payment',
|
||||
'shipping',
|
||||
];
|
||||
|
||||
/** The select is the only thing this page reads a service for. */
|
||||
export const usageServicesResponse = (): {
|
||||
status: string;
|
||||
data: ServicesList[];
|
||||
} => ({
|
||||
status: 'success',
|
||||
data: USAGE_SERVICES.map((serviceName, index) => ({
|
||||
serviceName,
|
||||
p99: 120_000_000 + index * 9_000_000,
|
||||
avgDuration: 40_000_000,
|
||||
numCalls: 12_000 + index * 3_100,
|
||||
callRate: 6.4 + index,
|
||||
numErrors: 0,
|
||||
errorRate: 0,
|
||||
})),
|
||||
});
|
||||
|
||||
/**
|
||||
* The page asks for its window in nanoseconds and steps through it in seconds,
|
||||
* so the buckets are derived from the request rather than pinned: whichever
|
||||
* range and interval the selects are on, the bars fill it.
|
||||
*/
|
||||
export const usageResponse = (
|
||||
startInNanoseconds: number,
|
||||
endInNanoseconds: number,
|
||||
stepInSeconds: number,
|
||||
spansPerBucket: number,
|
||||
): UsageDataItem[] => {
|
||||
const start = Math.floor(startInNanoseconds / 1e9);
|
||||
const end = Math.floor(endInNanoseconds / 1e9);
|
||||
const step = Math.max(stepInSeconds, 1);
|
||||
const buckets = Math.min(Math.max(Math.floor((end - start) / step), 0), 1000);
|
||||
|
||||
return Array.from({ length: buckets }, (_unused, index) => ({
|
||||
timestamp: (start + index * step) * 1_000_000_000,
|
||||
count: Math.round(spansPerBucket * (0.7 + ((index * 37) % 60) / 100)),
|
||||
}));
|
||||
};
|
||||
@@ -1,272 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { AI_API_PATH, setAIBackendUrl } from 'api/AIAPIInstance';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import { rest, type RequestHandler } from 'msw';
|
||||
|
||||
import {
|
||||
choiceControl,
|
||||
countControl,
|
||||
multiChoiceControl,
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { globalConfigResponse } from '@/storybook/msw/__story_mockdata__/appShell';
|
||||
import { dashboardsForUserResponse } from '@/storybook/msw/__story_mockdata__/dashboards';
|
||||
import type { MockResolver } from '@/storybook/msw/types';
|
||||
|
||||
import {
|
||||
AGENT_STATES,
|
||||
type AgentState,
|
||||
answeredBlocks,
|
||||
chipsResponse,
|
||||
EXECUTION_ID,
|
||||
executionEvents,
|
||||
newConversation,
|
||||
NEW_THREAD_ID,
|
||||
openConversation,
|
||||
THREAD_ID,
|
||||
streamingState,
|
||||
THREAD_PARTS,
|
||||
type ThreadPart,
|
||||
threadDetailResponse,
|
||||
threadListResponse,
|
||||
} from './__story_mockdata__/aiAssistant';
|
||||
|
||||
const THREAD = 'AI assistant · thread';
|
||||
const AGENT = 'AI assistant · agent';
|
||||
const CONVERSATIONS = 'AI assistant · conversations';
|
||||
|
||||
/**
|
||||
* The assistant talks to its own backend, whose host comes from the global
|
||||
* config rather than being the SigNoz API. Pointing it at the same origin is
|
||||
* what puts its calls in front of the story's handlers.
|
||||
*/
|
||||
const AI_BACKEND_URL = 'http://localhost';
|
||||
|
||||
/** The analysis thread, without the turns that carry an interactive block. */
|
||||
const ANALYSIS: ThreadPart[] = [
|
||||
'prose',
|
||||
'table',
|
||||
'code',
|
||||
'activity',
|
||||
'actions',
|
||||
'voted',
|
||||
];
|
||||
|
||||
/** Suggestions the `@` picker offers under Dashboards. */
|
||||
const CONTEXT_DASHBOARDS = [
|
||||
'Checkout overview',
|
||||
'Payments upstream',
|
||||
'Ingestion health',
|
||||
];
|
||||
|
||||
/**
|
||||
* `useIsAIAssistantEnabled` pushes the assistant's host into the axios instance
|
||||
* during render, and pushes `null` for as long as the global config query is in
|
||||
* flight. Every call that leaves in that window goes out against an empty base
|
||||
* and lands on the page's own origin, so each endpoint answers on both paths
|
||||
* rather than the story showing the 404 the app puts there. Reported as an app
|
||||
* bug.
|
||||
*/
|
||||
const onBothBases = (
|
||||
method: 'get' | 'post' | 'patch',
|
||||
path: string,
|
||||
resolver: MockResolver,
|
||||
): RequestHandler[] => [
|
||||
rest[method](`${AI_BACKEND_URL}${AI_API_PATH}${path}`, resolver),
|
||||
rest[method](path, resolver),
|
||||
];
|
||||
|
||||
const ok: MockResolver = (_req, res, ctx) => res(ctx.status(200), ctx.json({}));
|
||||
|
||||
const startedExecution: MockResolver = (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ executionId: EXECUTION_ID }));
|
||||
|
||||
export const aiAssistantMocks = defineStoryMocks({
|
||||
controls: {
|
||||
conversation: toggleControl('Open conversation', {
|
||||
group: THREAD,
|
||||
description:
|
||||
'Off is the empty thread a first visit lands on, with the suggested prompts instead of an exchange.',
|
||||
value: true,
|
||||
}),
|
||||
contents: multiChoiceControl<ThreadPart>('Thread contents', {
|
||||
group: THREAD,
|
||||
description:
|
||||
'What the open thread holds. Each interactive block arrives as its own turn, and `voted` is a rating already on the last answer.',
|
||||
options: THREAD_PARTS,
|
||||
value: ANALYSIS,
|
||||
}),
|
||||
answered: toggleControl('Interactive blocks answered', {
|
||||
group: THREAD,
|
||||
description:
|
||||
'The question, confirm and action blocks after the user has picked. The choice lives in the store, keyed by message, so it survives a remount.',
|
||||
value: false,
|
||||
}),
|
||||
agent: choiceControl<AgentState>('Agent', {
|
||||
group: AGENT,
|
||||
description:
|
||||
'What the agent is doing when the thread opens. Both waiting states block the composer until the user answers.',
|
||||
options: AGENT_STATES,
|
||||
value: 'idle',
|
||||
}),
|
||||
history: countControl('Past conversations', {
|
||||
group: CONVERSATIONS,
|
||||
description:
|
||||
'Threads the sidebar lists, the first being the open one, so an open conversation holds the count at one. Their ages spread across every date group.',
|
||||
value: 6,
|
||||
max: 12,
|
||||
}),
|
||||
archived: countControl('Archived conversations', {
|
||||
group: CONVERSATIONS,
|
||||
description: 'Threads under the archived group at the foot of the sidebar.',
|
||||
value: 2,
|
||||
max: 6,
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.get('http://localhost/api/v1/global/config', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.json({
|
||||
...globalConfigResponse,
|
||||
data: {
|
||||
...globalConfigResponse.data,
|
||||
ai_assistant_url: AI_BACKEND_URL,
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
...onBothBases(
|
||||
'get',
|
||||
'/threads',
|
||||
response.json((req) =>
|
||||
req.url.searchParams.get('archived') === 'true'
|
||||
? threadListResponse(values.archived, true)
|
||||
: // A thread the sidebar does not list is one the server does not
|
||||
// know, and the store drops those, so an open conversation is
|
||||
// always the first row.
|
||||
threadListResponse(
|
||||
values.conversation ? Math.max(1, values.history) : values.history,
|
||||
false,
|
||||
),
|
||||
),
|
||||
),
|
||||
...onBothBases(
|
||||
'get',
|
||||
'/threads/:threadId',
|
||||
response.json(() => threadDetailResponse(values.contents, values.agent)),
|
||||
),
|
||||
...onBothBases(
|
||||
'get',
|
||||
'/empty-state/chips',
|
||||
response.json(() => chipsResponse()),
|
||||
),
|
||||
|
||||
// Everything the user can set off from the page. They answer plainly rather
|
||||
// than through `response`, so a click still lands while the Data control
|
||||
// holds the page's own endpoints on loading or error.
|
||||
// The first send of a new conversation mints a thread, and the page puts the
|
||||
// id it gets back in the pathname: the overlay reports that as a navigation
|
||||
// the story cannot follow, with the answer still streaming underneath.
|
||||
...onBothBases('post', '/threads', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ threadId: THREAD_ID })),
|
||||
),
|
||||
...onBothBases('patch', '/threads/:threadId', ok),
|
||||
...onBothBases('post', '/threads/:threadId/messages', startedExecution),
|
||||
...onBothBases('post', '/messages/:messageId/regenerate', startedExecution),
|
||||
...onBothBases('post', '/messages/:messageId/feedback', ok),
|
||||
...onBothBases('post', '/approve', startedExecution),
|
||||
...onBothBases('post', '/clarify', startedExecution),
|
||||
...onBothBases('post', '/reject', ok),
|
||||
...onBothBases('post', '/cancel', ok),
|
||||
...onBothBases('post', '/undo', ok),
|
||||
...onBothBases('post', '/revert', ok),
|
||||
...onBothBases('post', '/restore', ok),
|
||||
...onBothBases('get', '/executions/:executionId/events', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.set('Content-Type', 'text/event-stream'),
|
||||
ctx.body(executionEvents()),
|
||||
),
|
||||
),
|
||||
|
||||
// The composer's `@` picker. Alert rules and services are answered by the
|
||||
// shared handlers already; the dashboard list is not.
|
||||
rest.get(
|
||||
'http://localhost/api/v2/users/me/dashboards',
|
||||
response.json(() => dashboardsForUserResponse(CONTEXT_DASHBOARDS)),
|
||||
),
|
||||
],
|
||||
config: (values) => ({
|
||||
// The bare `/ai-assistant` always rewrites itself to the thread it opens,
|
||||
// so a story starts on the thread rather than on the redirect.
|
||||
route: ROUTES.AI_ASSISTANT.replace(
|
||||
':conversationId',
|
||||
values.conversation ? THREAD_ID : NEW_THREAD_ID,
|
||||
),
|
||||
}),
|
||||
effect: (values) => {
|
||||
// The layout fetches the thread list in its mount effect, before the global
|
||||
// config query it takes the assistant's host from has answered, so the
|
||||
// first call would go out against an empty base. Setting it here is what
|
||||
// the config response does, one render earlier.
|
||||
setAIBackendUrl(AI_BACKEND_URL);
|
||||
|
||||
// The store is a zustand singleton and persists the answered blocks and the
|
||||
// active thread, so a story's state is put there before the tree mounts
|
||||
// rather than inherited from whichever story ran last.
|
||||
const conversation = values.conversation
|
||||
? openConversation()
|
||||
: newConversation();
|
||||
|
||||
useAIAssistantStore.setState({
|
||||
conversations: { [conversation.id]: conversation },
|
||||
activeConversationId: conversation.id,
|
||||
isLoadingThread: false,
|
||||
isLoadingThreads: false,
|
||||
answeredBlocks: values.answered ? answeredBlocks() : {},
|
||||
// A stream is client state with no response behind it: the events the
|
||||
// reducer folds into it only exist while the SSE connection is open.
|
||||
streams:
|
||||
values.agent === 'streaming' ? { [conversation.id]: streamingState() } : {},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* An action chip's tooltip is the one text on the page the backend writes, and
|
||||
* the thread fixture keeps it to a line. This answers with the same turns and a
|
||||
* chip whose tooltip runs long, so the Thread contents, Interactive blocks
|
||||
* answered and Agent controls do not reach the story that uses it. Both bases
|
||||
* are covered because the assistant's host is empty while the global config
|
||||
* query is in flight, as `onBothBases` above explains.
|
||||
*/
|
||||
const LONG_ACTION_TOOLTIP =
|
||||
'Opens the logs explorer on payment.svc.cluster.local:8080, filtered to the 429s it answered between 14:00 and 15:00, with the retry attempt and the upstream quota window already on the table.';
|
||||
|
||||
export const longActionTooltipHandlers: RequestHandler[] = onBothBases(
|
||||
'get',
|
||||
'/threads/:threadId',
|
||||
(_req, res, ctx) => {
|
||||
const thread = threadDetailResponse(aiAssistantMocks.args.contents, 'idle');
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
...thread,
|
||||
messages: thread.messages?.map((message) => ({
|
||||
...message,
|
||||
actions: message.actions?.map((action) =>
|
||||
action.tooltip ? { ...action, tooltip: LONG_ACTION_TOOLTIP } : action,
|
||||
),
|
||||
})),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1,245 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Route } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import AIAssistantPage from '../AIAssistantPage';
|
||||
import {
|
||||
aiAssistantMocks,
|
||||
longActionTooltipHandlers,
|
||||
} from './AIAssistantPage.stories.mocks';
|
||||
import type { ThreadPart } from './__story_mockdata__/aiAssistant';
|
||||
|
||||
type AIAssistantArgs = PageStoryArgs<typeof aiAssistantMocks>;
|
||||
|
||||
const pageStory = storyMocks(aiAssistantMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* Noz, the assistant: a thread of messages over the workspace's telemetry, tool
|
||||
* calls and the artefacts they produce rendered inline, and the thread list beside
|
||||
* it. The answer arrives as SSE, so it streams inside the story.
|
||||
*
|
||||
* Route: `/ai-assistant/:conversationId`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Noz',
|
||||
tags: ['play'],
|
||||
component: AIAssistantPage,
|
||||
// The conversation id is in the pathname, so the page renders under its own
|
||||
// route rather than being mounted on its own.
|
||||
render: (): JSX.Element => (
|
||||
<Route
|
||||
path={[ROUTES.AI_ASSISTANT_BASE, ROUTES.AI_ASSISTANT]}
|
||||
component={AIAssistantPage}
|
||||
/>
|
||||
),
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<AIAssistantArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<AIAssistantArgs>;
|
||||
|
||||
/** The thread list resolves before the thread does, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
/**
|
||||
* Click something, and keep clicking until what it opens is on screen. The
|
||||
* message list remounts its items while it measures a freshly loaded thread, so
|
||||
* a single click can land on a row that is about to be replaced, taking the
|
||||
* state it just set with it.
|
||||
*/
|
||||
const clickUntil = async (
|
||||
find: () => Promise<HTMLElement>,
|
||||
opens: RegExp,
|
||||
): Promise<void> => {
|
||||
await waitFor(async () => {
|
||||
await userEvent.click(await find());
|
||||
await screen.findByText(opens, undefined, { timeout: 1_000 });
|
||||
}, untilLoaded);
|
||||
};
|
||||
|
||||
/** The blocks the agent renders as cards the user answers in place. */
|
||||
const INTERACTIVE: ThreadPart[] = [
|
||||
'question',
|
||||
'checkboxes',
|
||||
'confirm',
|
||||
'suggested-action',
|
||||
];
|
||||
|
||||
const QUESTIONS: ThreadPart[] = ['question', 'checkboxes'];
|
||||
|
||||
const COMMITMENTS: ThreadPart[] = ['confirm', 'suggested-action'];
|
||||
|
||||
/**
|
||||
* Two short exchanges and nothing else, so the state the story is about sits in
|
||||
* the first screen rather than under a scroll.
|
||||
*/
|
||||
const BRIEF: ThreadPart[] = [];
|
||||
|
||||
/** A thread mid-investigation, with the earlier ones beside it. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** The first visit: the suggested prompts and nothing asked yet. */
|
||||
export const NewConversation: Story = {
|
||||
args: { conversation: false, history: 0, archived: 0 },
|
||||
};
|
||||
|
||||
/**
|
||||
* The cards the agent puts in the thread when it needs the user to pick: one
|
||||
* answer, or several.
|
||||
*/
|
||||
export const QuestionBlocks: Story = {
|
||||
args: { contents: QUESTIONS },
|
||||
};
|
||||
|
||||
/**
|
||||
* The cards that ask the user to commit: a confirmation the agent acts on, and
|
||||
* a page action it will apply here.
|
||||
*/
|
||||
export const ActionBlocks: Story = {
|
||||
args: { contents: COMMITMENTS },
|
||||
};
|
||||
|
||||
/** All four cards once the user has answered them, which the store remembers. */
|
||||
export const AnsweredBlocks: Story = {
|
||||
args: { contents: INTERACTIVE, answered: true },
|
||||
};
|
||||
|
||||
/**
|
||||
* Mid-answer: a step already done, the text so far, and a step still running
|
||||
* with the elapsed clock on it. The composer waits its turn.
|
||||
*/
|
||||
export const Streaming: Story = {
|
||||
args: { agent: 'streaming', contents: BRIEF },
|
||||
};
|
||||
|
||||
/** A change the agent will not make until the user reads the diff and approves. */
|
||||
export const AwaitingApproval: Story = {
|
||||
args: { agent: 'awaiting-approval', contents: BRIEF },
|
||||
};
|
||||
|
||||
/** The agent asking for the details it needs, one field per detail. */
|
||||
export const AwaitingClarification: Story = {
|
||||
args: { agent: 'awaiting-clarification', contents: BRIEF },
|
||||
};
|
||||
|
||||
/** Reopening the page on a thread that has not come back yet. */
|
||||
export const LoadingThread: Story = {
|
||||
args: { dataState: 'loading' },
|
||||
};
|
||||
|
||||
/**
|
||||
* The steps behind an answer: what the agent thought, and each tool it called
|
||||
* with what went in and what came back.
|
||||
*/
|
||||
export const ActivityExpanded: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await clickUntil(
|
||||
() => canvas.findByText(/worked through/i, undefined, untilLoaded),
|
||||
/compared checkout p99/i,
|
||||
);
|
||||
await clickUntil(
|
||||
() => canvas.findByText(/compared checkout p99/i, undefined, untilLoaded),
|
||||
/^Output$/,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** Opens the approval card's diff dialog. */
|
||||
const openApprovalDiff: NonNullable<Story['play']> = async ({
|
||||
canvasElement,
|
||||
}) => {
|
||||
await clickUntil(
|
||||
() =>
|
||||
within(canvasElement).findByLabelText(
|
||||
/expand diff/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
/approval diff/i,
|
||||
);
|
||||
};
|
||||
|
||||
/** The approval diff at full size, before against after. */
|
||||
export const ApprovalDiff: Story = {
|
||||
args: { agent: 'awaiting-approval', contents: BRIEF },
|
||||
play: openApprovalDiff,
|
||||
};
|
||||
|
||||
/** The comment box a thumbs down opens, which a thumbs up does not. */
|
||||
export const NegativeFeedback: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await clickUntil(async () => {
|
||||
// Every assistant message carries the bar; only the last one shows it
|
||||
// without a hover.
|
||||
const bars = await within(canvasElement).findAllByLabelText(
|
||||
/bad response/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
return bars[bars.length - 1];
|
||||
}, /what went wrong/i);
|
||||
},
|
||||
};
|
||||
|
||||
/** What a conversation row offers: rename, a link to it, and archiving. */
|
||||
export const ConversationActions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await clickUntil(async () => {
|
||||
const [actions] = await within(canvasElement).findAllByLabelText(
|
||||
/conversation actions/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
return actions;
|
||||
}, /copy link/i);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The composer's context picker: the dashboards, alerts and services a question
|
||||
* can be pinned to.
|
||||
*/
|
||||
export const AddContext: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await clickUntil(
|
||||
() =>
|
||||
within(canvasElement).findByRole(
|
||||
'button',
|
||||
{ name: /add context/i },
|
||||
untilLoaded,
|
||||
),
|
||||
/checkout overview/i,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Every tooltip the thread carries, held open at once: the composer's voice and
|
||||
* send buttons, the sidebar's new conversation, the copy chip under each user
|
||||
* message, the copy, rate and regenerate bar under each answer, the code block's
|
||||
* copy, and the action chip's own description.
|
||||
*/
|
||||
export const Tooltips: Story = {
|
||||
args: { tooltipsOpen: true },
|
||||
parameters: { msw: { handlers: longActionTooltipHandlers } },
|
||||
};
|
||||
|
||||
/**
|
||||
* The approval diff dialog's copy tooltips, one per side of the split view, with
|
||||
* the card's own expand held open behind it. Switching the dialog to the unified
|
||||
* view replaces the pair with a single Copy diff.
|
||||
*/
|
||||
export const TooltipsInApprovalDiff: Story = {
|
||||
args: { tooltipsOpen: true, agent: 'awaiting-approval', contents: BRIEF },
|
||||
play: openApprovalDiff,
|
||||
};
|
||||
@@ -1,731 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ApprovalSummaryDTO,
|
||||
ChipsResponseDTO,
|
||||
ClarificationSummaryDTO,
|
||||
MessageActionDTO,
|
||||
MessageSummaryDTO,
|
||||
ThreadDetailResponseDTO,
|
||||
ThreadListResponseDTO,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import {
|
||||
ApplyFilterSignalDTO,
|
||||
ApprovalActionTypeDTO,
|
||||
ApprovalStateDTO,
|
||||
ClarificationFieldTypeDTO,
|
||||
ClarificationStateDTO,
|
||||
FeedbackRatingDTO,
|
||||
MessageActionKindDTO,
|
||||
MessageContentTypeDTO,
|
||||
MessageRoleDTO,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import type {
|
||||
Conversation,
|
||||
ConversationStreamState,
|
||||
MessageBlock,
|
||||
} from 'container/AIAssistant/types';
|
||||
|
||||
export const THREAD_ID = 'thread-checkout-latency';
|
||||
|
||||
/** The thread a first visit mints, which the page opens with nothing in it. */
|
||||
export const NEW_THREAD_ID = 'thread-new';
|
||||
|
||||
export const THREAD_TITLE = 'Checkout p99 regression after 14:00';
|
||||
|
||||
/** What the open thread contains, one entry per turn the builder can add. */
|
||||
export const THREAD_PARTS = [
|
||||
'prose',
|
||||
'table',
|
||||
'code',
|
||||
'activity',
|
||||
'actions',
|
||||
'question',
|
||||
'checkboxes',
|
||||
'confirm',
|
||||
'suggested-action',
|
||||
'voted',
|
||||
] as const;
|
||||
|
||||
export type ThreadPart = (typeof THREAD_PARTS)[number];
|
||||
|
||||
/** What the agent is doing when the thread opens. */
|
||||
export const AGENT_STATES = [
|
||||
'idle',
|
||||
'streaming',
|
||||
'awaiting-approval',
|
||||
'awaiting-clarification',
|
||||
] as const;
|
||||
|
||||
export type AgentState = (typeof AGENT_STATES)[number];
|
||||
|
||||
/**
|
||||
* Every interactive block reads `answeredBlocks[messageId]`, so a message holds
|
||||
* at most one of them: answering either block of a pair would otherwise mark
|
||||
* both. Reported as an app bug.
|
||||
*/
|
||||
export const MESSAGE_IDS = {
|
||||
analysis: 'message-analysis',
|
||||
question: 'message-question',
|
||||
checkboxes: 'message-checkboxes',
|
||||
confirm: 'message-confirm',
|
||||
suggestedAction: 'message-suggested-action',
|
||||
final: 'message-final',
|
||||
} as const;
|
||||
|
||||
export const EXECUTION_ID = 'execution-checkout-latency';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assistant prose
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PROSE = `### What changed
|
||||
|
||||
\`checkout\` p99 went from **180 ms to 640 ms** at 14:05, and the whole increase sits in the
|
||||
\`payment.authorize\` span. That span started retrying against a rate-limited upstream, so the
|
||||
extra time is retry wait rather than compute.
|
||||
|
||||
- 12% of calls to \`payment.svc.cluster.local:8080\` answered \`429\`, up from none before 14:00
|
||||
- retries are capped at three, which matches the 3x jump in span duration
|
||||
- no deploy landed in the window, so this is upstream capacity and not a regression you shipped
|
||||
|
||||
> The upstream quota window resets at 14:00 UTC, which is exactly where the 429s begin.
|
||||
|
||||
The escalation path is in the [payment rate limit runbook](https://signoz.io/docs/userguide/payment-rate-limits/).`;
|
||||
|
||||
const TABLE = `| Service | p99 before | p99 after | Change | Error rate | Slowest span |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| checkout | 180 ms | 640 ms | +256% | 0.4% -> 2.1% | payment.authorize |
|
||||
| payment | 95 ms | 410 ms | +331% | 0.1% -> 12.0% | upstream.authorize.retry |
|
||||
| cart | 62 ms | 66 ms | +6% | 0.0% | redis.get |
|
||||
| catalogue | 44 ms | 45 ms | +2% | 0.0% | postgres.query.products |
|
||||
| notifications | 210 ms | 214 ms | +2% | 0.2% | kafka.publish |`;
|
||||
|
||||
/** The first line runs past the chat column, which is what makes the block scroll. */
|
||||
const CODE = `Here is the query that isolates the retries:
|
||||
|
||||
\`\`\`sql
|
||||
SELECT toStartOfMinute(timestamp) AS minute, quantile(0.99)(duration_nano / 1e6) AS p99_ms, countIf(status_code = 429) AS rate_limited, count() AS calls
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE service_name = 'payment'
|
||||
AND name = 'upstream.authorize.retry'
|
||||
AND timestamp >= now() - INTERVAL 2 HOUR
|
||||
GROUP BY minute
|
||||
ORDER BY minute ASC
|
||||
\`\`\``;
|
||||
|
||||
const proseFor = (parts: readonly ThreadPart[]): string =>
|
||||
[
|
||||
parts.includes('prose') ? PROSE : '',
|
||||
parts.includes('table') ? TABLE : '',
|
||||
parts.includes('code') ? CODE : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n') || 'The whole increase sits in the `payment.authorize` span.';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Blocks
|
||||
//
|
||||
// `MessageSummaryDTO.blocks` is `unknown[]`, so the builders type against the
|
||||
// union the renderer narrows to and cast once on the way into the payload.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const asBlocks = (blocks: MessageBlock[]): MessageSummaryDTO['blocks'] =>
|
||||
blocks as unknown as MessageSummaryDTO['blocks'];
|
||||
|
||||
const activityBlocks = (text: string): MessageBlock[] => [
|
||||
{
|
||||
type: 'thinking',
|
||||
content:
|
||||
'The jump is sharp rather than gradual, so a capacity change is more likely than a slow leak. Comparing the span breakdown either side of 14:00 should say which span carries it, and the logs for that span should say why.',
|
||||
},
|
||||
{
|
||||
type: 'tool_call',
|
||||
toolCallId: 'call-service-metrics',
|
||||
toolName: 'signoz_query_service_metrics',
|
||||
displayText: 'Compared checkout p99 either side of 14:00',
|
||||
toolInput: {
|
||||
service: 'checkout',
|
||||
metrics: ['p99', 'error_rate'],
|
||||
window: { from: '2026-08-28T13:30:00Z', to: '2026-08-28T14:30:00Z' },
|
||||
groupBy: ['span_name'],
|
||||
},
|
||||
result: {
|
||||
p99_before_ms: 180.4,
|
||||
p99_after_ms: 640.2,
|
||||
top_span: 'payment.authorize',
|
||||
contribution: 0.97,
|
||||
},
|
||||
success: true,
|
||||
},
|
||||
{
|
||||
type: 'tool_call',
|
||||
toolCallId: 'call-search-logs',
|
||||
toolName: 'signoz_search_logs',
|
||||
toolInput: {
|
||||
expression:
|
||||
"service.name = 'payment' AND severity_text = 'WARN' AND body CONTAINS 'rate limit'",
|
||||
limit: 200,
|
||||
},
|
||||
result:
|
||||
'187 of 200 matching lines read: upstream rate limit hit for tenant=acme quota=payment.authorize window=60s retry_after=1.5s remaining=0 endpoint=payment.svc.cluster.local:8080 request_id=01J9Z4Q0R7X2N8M4K6H1F3D5B7',
|
||||
success: true,
|
||||
},
|
||||
{ type: 'text', content: text },
|
||||
{
|
||||
type: 'tool_call',
|
||||
toolCallId: 'call-quota',
|
||||
toolName: 'signoz_get_upstream_quota',
|
||||
displayText: 'Read the upstream quota window',
|
||||
toolInput: { endpoint: 'payment.svc.cluster.local:8080' },
|
||||
result: { quota: 600, window_seconds: 60, resets_at: '14:00:00Z' },
|
||||
success: true,
|
||||
},
|
||||
];
|
||||
|
||||
const ACTIONS: MessageActionDTO[] = [
|
||||
{
|
||||
kind: MessageActionKindDTO.follow_up,
|
||||
label: 'Show the retry spans',
|
||||
input: {
|
||||
intent: 'Show me the payment.authorize retry spans between 14:00 and 15:00.',
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: MessageActionKindDTO.apply_filter,
|
||||
label: 'Filter logs to the 429s',
|
||||
signal: ApplyFilterSignalDTO.logs,
|
||||
tooltip: 'Opens the logs explorer with the rate-limit filter applied',
|
||||
query: {
|
||||
compositeQuery: {
|
||||
queries: [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: 'logs',
|
||||
filter: {
|
||||
expression: "service.name = 'payment' AND http.status_code = 429",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: MessageActionKindDTO.open_resource,
|
||||
label: 'Open the Checkout dashboard',
|
||||
resourceType: 'dashboard',
|
||||
resourceId: 'storybook-dashboard-1',
|
||||
},
|
||||
{
|
||||
kind: MessageActionKindDTO.open_docs,
|
||||
label: 'Rate limit runbook',
|
||||
url: 'https://signoz.io/docs/userguide/payment-rate-limits/',
|
||||
},
|
||||
{
|
||||
kind: MessageActionKindDTO.undo,
|
||||
label: 'Undo the threshold change',
|
||||
actionMetadataId: 'action-threshold-change',
|
||||
resourceType: 'alert',
|
||||
resourceId: 'alert-checkout-p99',
|
||||
state: 'applied',
|
||||
},
|
||||
{
|
||||
kind: MessageActionKindDTO.revert,
|
||||
label: 'Revert the dashboard panel',
|
||||
actionMetadataId: 'action-dashboard-panel',
|
||||
resourceType: 'dashboard',
|
||||
resourceId: 'storybook-dashboard-1',
|
||||
},
|
||||
{
|
||||
kind: MessageActionKindDTO.restore,
|
||||
label: 'Restore the archived view',
|
||||
actionMetadataId: 'action-archived-view',
|
||||
resourceType: 'saved_view',
|
||||
resourceId: 'view-payment-retries',
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interactive blocks. The agent emits these as fenced `ai-<type>` code blocks,
|
||||
// which `RichCodeBlock` resolves against the block registry.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const fence = (type: string, data: unknown): string =>
|
||||
['```ai-'.concat(type), JSON.stringify(data, null, 2), '```'].join('\n');
|
||||
|
||||
const QUESTION_BLOCK = fence('question', {
|
||||
question: 'Which signal should the alert watch?',
|
||||
type: 'radio',
|
||||
options: [
|
||||
{ value: 'p99', label: 'Trace p99 on checkout' },
|
||||
{ value: 'errors', label: 'Error rate on payment' },
|
||||
{ value: 'rate-limited', label: 'Upstream 429 count' },
|
||||
],
|
||||
});
|
||||
|
||||
const CHECKBOX_BLOCK = fence('question', {
|
||||
question: 'Who should the alert notify?',
|
||||
type: 'checkbox',
|
||||
options: [
|
||||
'#checkout-oncall',
|
||||
'payments-team@signoz.io',
|
||||
'PagerDuty: payments',
|
||||
],
|
||||
});
|
||||
|
||||
const CONFIRM_BLOCK = fence('confirm', {
|
||||
// The block renders its message as plain text, so markdown would show as
|
||||
// literal asterisks.
|
||||
message:
|
||||
"I'll create the alert Checkout p99 > 500 ms, evaluated every minute over a 5 minute window, notifying #checkout-oncall and PagerDuty.",
|
||||
acceptLabel: 'Create the alert',
|
||||
rejectLabel: 'Not now',
|
||||
acceptText: 'Yes, create it.',
|
||||
rejectText: 'No, leave it for now.',
|
||||
});
|
||||
|
||||
const ACTION_BLOCK = fence('action', {
|
||||
actionId: 'logs.applyFilter',
|
||||
description: 'Filter the logs explorer to the failing payment retries.',
|
||||
parameters: {
|
||||
signal: 'logs',
|
||||
expression: "service.name = 'payment' AND http.status_code = 429",
|
||||
from: '2026-08-28T14:00:00Z',
|
||||
to: '2026-08-28T15:00:00Z',
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Messages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Turn = Omit<MessageSummaryDTO, 'createdAt' | 'updatedAt'>;
|
||||
|
||||
const user = (id: string, content: string): Turn => ({
|
||||
messageId: id,
|
||||
role: MessageRoleDTO.user,
|
||||
contentType: MessageContentTypeDTO.markdown,
|
||||
content,
|
||||
});
|
||||
|
||||
const assistant = (
|
||||
id: string,
|
||||
content: string,
|
||||
extra: Partial<Turn> = {},
|
||||
): Turn => ({
|
||||
messageId: id,
|
||||
role: MessageRoleDTO.assistant,
|
||||
contentType: MessageContentTypeDTO.markdown,
|
||||
content,
|
||||
complete: true,
|
||||
...extra,
|
||||
});
|
||||
|
||||
const turnsFor = (parts: readonly ThreadPart[]): Turn[] => {
|
||||
const prose = proseFor(parts);
|
||||
const turns: Turn[] = [
|
||||
user(
|
||||
'message-opening',
|
||||
'Why did checkout get slower after 14:00? Deploys look clean.',
|
||||
),
|
||||
assistant(MESSAGE_IDS.analysis, prose, {
|
||||
blocks: parts.includes('activity')
|
||||
? asBlocks(activityBlocks(prose))
|
||||
: undefined,
|
||||
actions: parts.includes('actions') ? ACTIONS : undefined,
|
||||
}),
|
||||
];
|
||||
|
||||
if (parts.includes('question')) {
|
||||
turns.push(
|
||||
user('message-alert-ask', 'Can you set up an alert so we catch it sooner?'),
|
||||
assistant(
|
||||
MESSAGE_IDS.question,
|
||||
`Before I create it, one choice.\n\n${QUESTION_BLOCK}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (parts.includes('checkboxes')) {
|
||||
turns.push(
|
||||
user('message-signal-pick', 'Trace p99 on checkout.'),
|
||||
assistant(MESSAGE_IDS.checkboxes, `Got it. One more.\n\n${CHECKBOX_BLOCK}`),
|
||||
);
|
||||
}
|
||||
|
||||
if (parts.includes('confirm')) {
|
||||
turns.push(
|
||||
user('message-notify-pick', '#checkout-oncall and PagerDuty.'),
|
||||
assistant(MESSAGE_IDS.confirm, CONFIRM_BLOCK),
|
||||
);
|
||||
}
|
||||
|
||||
if (parts.includes('suggested-action')) {
|
||||
turns.push(
|
||||
user('message-logs-ask', 'Show me the failing retries in the logs.'),
|
||||
assistant(
|
||||
MESSAGE_IDS.suggestedAction,
|
||||
`The retries are all on one endpoint, so a single filter covers them.\n\n${ACTION_BLOCK}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
turns.push(
|
||||
user('message-upstream-ask', 'Which upstream is rate-limiting us?'),
|
||||
assistant(
|
||||
MESSAGE_IDS.final,
|
||||
'`payment.svc.cluster.local:8080`. It answered `429` on 12% of calls between 14:00 and 15:00, and none in the hour before. The quota is 600 requests per minute and checkout alone asked for 780.',
|
||||
{
|
||||
feedbackRating: parts.includes('voted')
|
||||
? FeedbackRatingDTO.positive
|
||||
: undefined,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return turns;
|
||||
};
|
||||
|
||||
/** Chronological, ending a few minutes ago so the feedback bar reads fresh. */
|
||||
const stamped = (turns: Turn[]): MessageSummaryDTO[] => {
|
||||
const last = Date.now() - 4 * 60_000;
|
||||
const step = 40_000;
|
||||
const first = last - (turns.length - 1) * step;
|
||||
|
||||
return turns.map((turn, index) => {
|
||||
const at = new Date(first + index * step).toISOString();
|
||||
return { ...turn, createdAt: at, updatedAt: at };
|
||||
});
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pending user input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const pendingApproval = (): ApprovalSummaryDTO => ({
|
||||
approvalId: 'approval-checkout-alert',
|
||||
executionId: EXECUTION_ID,
|
||||
sourceMessageId: MESSAGE_IDS.final,
|
||||
state: ApprovalStateDTO.pending,
|
||||
actionType: ApprovalActionTypeDTO.modify,
|
||||
resourceType: 'alert',
|
||||
summary:
|
||||
'Raise the Checkout p99 alert threshold to 500 ms and add the upstream 429 count as a second condition.',
|
||||
diff: {
|
||||
before: {
|
||||
alert: 'Checkout p99',
|
||||
condition: {
|
||||
target: 800,
|
||||
op: '>',
|
||||
matchType: 'atleastOnce',
|
||||
evalWindow: '5m0s',
|
||||
},
|
||||
labels: { severity: 'warning', team: 'checkout' },
|
||||
preferredChannels: ['#checkout-oncall'],
|
||||
},
|
||||
after: {
|
||||
alert: 'Checkout p99',
|
||||
condition: {
|
||||
target: 500,
|
||||
op: '>',
|
||||
matchType: 'allTheTimes',
|
||||
evalWindow: '5m0s',
|
||||
secondary: { metric: 'upstream_429_total', target: 50, op: '>' },
|
||||
},
|
||||
labels: {
|
||||
severity: 'critical',
|
||||
team: 'checkout',
|
||||
runbook: 'payment-rate-limits',
|
||||
},
|
||||
preferredChannels: ['#checkout-oncall', 'PagerDuty: payments'],
|
||||
},
|
||||
},
|
||||
createdAt: new Date(Date.now() - 30_000).toISOString(),
|
||||
});
|
||||
|
||||
const pendingClarification = (): ClarificationSummaryDTO => ({
|
||||
clarificationId: 'clarification-alert-scope',
|
||||
executionId: EXECUTION_ID,
|
||||
sourceMessageId: MESSAGE_IDS.final,
|
||||
state: ClarificationStateDTO.pending,
|
||||
message:
|
||||
'I can create the alert, but a few details change what it watches and who hears about it.',
|
||||
fields: [
|
||||
{
|
||||
id: 'service',
|
||||
type: ClarificationFieldTypeDTO.select,
|
||||
label: 'Service to watch',
|
||||
required: true,
|
||||
options: ['checkout', 'payment', 'cart'],
|
||||
default: 'checkout',
|
||||
},
|
||||
{
|
||||
id: 'window',
|
||||
type: ClarificationFieldTypeDTO.number,
|
||||
label: 'Evaluation window (minutes)',
|
||||
required: true,
|
||||
default: '5',
|
||||
},
|
||||
{
|
||||
id: 'severity',
|
||||
type: ClarificationFieldTypeDTO.select,
|
||||
label: 'Severity',
|
||||
options: ['critical', 'warning', 'info'],
|
||||
allowCustom: true,
|
||||
default: 'warning',
|
||||
},
|
||||
{
|
||||
id: 'channels',
|
||||
type: ClarificationFieldTypeDTO.multi_select,
|
||||
label: 'Notify',
|
||||
required: true,
|
||||
options: [
|
||||
'#checkout-oncall',
|
||||
'payments-team@signoz.io',
|
||||
'PagerDuty: payments',
|
||||
],
|
||||
allowCustom: true,
|
||||
default: ['#checkout-oncall'],
|
||||
},
|
||||
{
|
||||
id: 'includeTraces',
|
||||
type: ClarificationFieldTypeDTO.boolean,
|
||||
label: 'Attach example traces to the notification',
|
||||
default: 'true',
|
||||
},
|
||||
{
|
||||
id: 'note',
|
||||
type: ClarificationFieldTypeDTO.text,
|
||||
label: 'Anything else I should know?',
|
||||
},
|
||||
],
|
||||
createdAt: new Date(Date.now() - 30_000).toISOString(),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Responses
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const threadDetailResponse = (
|
||||
parts: readonly ThreadPart[],
|
||||
agent: AgentState,
|
||||
): ThreadDetailResponseDTO => {
|
||||
const messages = stamped(turnsFor(parts));
|
||||
|
||||
return {
|
||||
threadId: THREAD_ID,
|
||||
title: THREAD_TITLE,
|
||||
archived: false,
|
||||
createdAt: messages[0].createdAt,
|
||||
updatedAt: messages[messages.length - 1].createdAt,
|
||||
messages,
|
||||
// `activeExecutionId` would reconnect the stream and overwrite the seeded
|
||||
// one, so the streaming state is left to the store.
|
||||
activeExecutionId: null,
|
||||
pendingApproval: agent === 'awaiting-approval' ? pendingApproval() : null,
|
||||
pendingClarification:
|
||||
agent === 'awaiting-clarification' ? pendingClarification() : null,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Ages that put the list across every bucket `groupByDate` builds: today,
|
||||
* yesterday, last 7 days, last 30 days and older.
|
||||
*/
|
||||
const AGES_IN_MINUTES = [
|
||||
4, 95, 1_700, 4_400, 15_000, 65_000, 30, 300, 2_000, 6_000, 20_000, 90_000,
|
||||
];
|
||||
|
||||
const TITLES = [
|
||||
THREAD_TITLE,
|
||||
'Which endpoints are burning the most ingestion quota?',
|
||||
'Kafka consumer lag on the notifications topic',
|
||||
'Why are the ingestion workers restarting every twenty minutes on the production cluster?',
|
||||
'Trace sampling rate for the cart service',
|
||||
'Postgres connection pool saturation last Friday',
|
||||
'Cost per service for August',
|
||||
'Set up an alert for 5xx on the public API',
|
||||
'Missing spans between gateway and auth',
|
||||
'Log volume spike from the batch importer',
|
||||
'Dashboard for the payments team',
|
||||
'Retention on the debug log pipeline',
|
||||
];
|
||||
|
||||
const ARCHIVED_TITLES = [
|
||||
'Migrating the old APM dashboards',
|
||||
'Alert noise from the staging cluster',
|
||||
'Instrumenting the Go workers',
|
||||
'Trace comparison for release 1.42',
|
||||
'Cost meter setup',
|
||||
'Old runbook questions',
|
||||
];
|
||||
|
||||
export const threadListResponse = (
|
||||
count: number,
|
||||
archived: boolean,
|
||||
): ThreadListResponseDTO => {
|
||||
const titles = archived ? ARCHIVED_TITLES : TITLES;
|
||||
|
||||
return {
|
||||
threads: Array.from({ length: count }, (_unused, index) => {
|
||||
const at = new Date(
|
||||
Date.now() - AGES_IN_MINUTES[index % AGES_IN_MINUTES.length] * 60_000,
|
||||
).toISOString();
|
||||
|
||||
return {
|
||||
threadId:
|
||||
!archived && index === 0
|
||||
? THREAD_ID
|
||||
: `${archived ? 'thread-archived' : 'thread'}-${index}`,
|
||||
title: titles[index % titles.length],
|
||||
createdAt: at,
|
||||
updatedAt: at,
|
||||
archived,
|
||||
};
|
||||
}),
|
||||
hasMore: false,
|
||||
};
|
||||
};
|
||||
|
||||
/** The prompts the empty conversation offers before anything is typed. */
|
||||
export const chipsResponse = (): ChipsResponseDTO => ({
|
||||
chips: [
|
||||
{ id: 'top-errors', text: 'Show me the top errors in the last hour' },
|
||||
{ id: 'slowest-services', text: 'What services have the highest latency?' },
|
||||
{ id: 'slow-queries', text: 'Find slow database queries' },
|
||||
{ id: 'health-overview', text: 'Give me an overview of system health' },
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* One SSE execution, delivered in a single body: msw answers a mocked `fetch`
|
||||
* with the whole stream at once, and the reader splits it back into events. The
|
||||
* text delta still animates word by word, so a send in a story looks like a
|
||||
* send in the app.
|
||||
*/
|
||||
export const executionEvents = (): string =>
|
||||
[
|
||||
{ type: 'status', state: 'running' },
|
||||
{
|
||||
type: 'thinking',
|
||||
content:
|
||||
'The thread already has the span breakdown, so the remaining question is whether the quota is per tenant or per endpoint.',
|
||||
},
|
||||
{
|
||||
type: 'tool_call',
|
||||
toolName: 'signoz_get_upstream_quota',
|
||||
displayText: 'Read the upstream quota window',
|
||||
toolInput: { endpoint: 'payment.svc.cluster.local:8080' },
|
||||
},
|
||||
{
|
||||
type: 'tool_result',
|
||||
toolName: 'signoz_get_upstream_quota',
|
||||
result: { quota: 600, window_seconds: 60, scope: 'per_tenant' },
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
messageId: 'message-streamed',
|
||||
delta:
|
||||
'The quota is per tenant: 600 requests a minute across every endpoint, and checkout alone asked for 780 between 14:00 and 15:00. Raising the retry budget would make it worse, so the fix is either a quota increase or a client-side limiter in front of `payment.authorize`.',
|
||||
done: false,
|
||||
},
|
||||
{ type: 'message', messageId: 'message-streamed', done: true },
|
||||
{ type: 'done' },
|
||||
]
|
||||
.map((event) => `data: ${JSON.stringify(event)}\n\n`)
|
||||
.join('');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The entry the page resumes on. Empty and hydrating is what the app restores
|
||||
* from its persisted active thread, and what makes `fetchThreads` follow up with
|
||||
* the thread detail the handlers answer.
|
||||
*/
|
||||
export const openConversation = (): Conversation => ({
|
||||
id: THREAD_ID,
|
||||
threadId: THREAD_ID,
|
||||
title: THREAD_TITLE,
|
||||
messages: [],
|
||||
createdAt: Date.now() - 20 * 60_000,
|
||||
updatedAt: Date.now() - 4 * 60_000,
|
||||
isHydrating: true,
|
||||
});
|
||||
|
||||
export const newConversation = (): Conversation => ({
|
||||
id: NEW_THREAD_ID,
|
||||
messages: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
|
||||
/**
|
||||
* A stream caught mid-answer: a finished step, some text, and a step still
|
||||
* running, which is the trailing group the elapsed timer ticks on.
|
||||
*/
|
||||
export const streamingState = (): ConversationStreamState => ({
|
||||
isStreaming: true,
|
||||
streamingStatus: 'running',
|
||||
streamingMessageId: 'message-streaming',
|
||||
streamingActions: null,
|
||||
pendingApproval: null,
|
||||
pendingClarification: null,
|
||||
streamingContent:
|
||||
'The quota is per tenant rather than per endpoint, so every service shares the same 600 requests a minute.',
|
||||
streamingEvents: [
|
||||
{
|
||||
kind: 'thinking',
|
||||
content:
|
||||
'The span breakdown is already in the thread, so what is left is whether the quota is scoped to the tenant or to the endpoint.',
|
||||
},
|
||||
{
|
||||
kind: 'tool',
|
||||
toolCall: {
|
||||
toolName: 'signoz_get_upstream_quota',
|
||||
displayText: 'Read the upstream quota window',
|
||||
input: { endpoint: 'payment.svc.cluster.local:8080' },
|
||||
result: { quota: 600, window_seconds: 60, scope: 'per_tenant' },
|
||||
done: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'text',
|
||||
content:
|
||||
'The quota is per tenant rather than per endpoint, so every service shares the same 600 requests a minute.',
|
||||
},
|
||||
{
|
||||
kind: 'thinking',
|
||||
content: 'Checking how much of that budget checkout asked for on its own.',
|
||||
},
|
||||
{
|
||||
kind: 'tool',
|
||||
toolCall: {
|
||||
toolName: 'signoz_query_service_metrics',
|
||||
displayText: 'Counting checkout calls per minute',
|
||||
input: { service: 'checkout', metric: 'upstream_calls_total' },
|
||||
done: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* What each interactive block stores once the user has picked. The shape is
|
||||
* per block: a question keeps the answer text, a confirm the choice, an action
|
||||
* its outcome.
|
||||
*/
|
||||
export const answeredBlocks = (): Record<string, string> => ({
|
||||
[MESSAGE_IDS.question]: 'Trace p99 on checkout',
|
||||
[MESSAGE_IDS.checkboxes]: '#checkout-oncall, PagerDuty: payments',
|
||||
[MESSAGE_IDS.confirm]: 'accepted',
|
||||
[MESSAGE_IDS.suggestedAction]:
|
||||
'applied:Filtered the logs explorer to 429s on payment.',
|
||||
});
|
||||
@@ -1,158 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { TIMELINE_TABLE_PAGE_SIZE } from 'container/AlertHistory/constants';
|
||||
import { rest } from 'msw';
|
||||
|
||||
import { choiceControl, countControl } from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
|
||||
import {
|
||||
ruleHistoryFilterKeysResponse,
|
||||
ruleHistoryFilterValuesResponse,
|
||||
ruleHistoryOverallStatusResponse,
|
||||
ruleHistoryStatsResponse,
|
||||
ruleHistoryTimelineResponse,
|
||||
ruleHistoryTopContributorsResponse,
|
||||
TIMELINE_MAX,
|
||||
TOP_CONTRIBUTOR_MAX,
|
||||
type HistoryWindow,
|
||||
} from './__story_mockdata__/alertHistory';
|
||||
|
||||
import {
|
||||
alertRuleByIdResponse,
|
||||
ALERT_SCHEMAS,
|
||||
channelsResponse,
|
||||
CHANNEL_MAX,
|
||||
FIRST_RULE_NAME,
|
||||
type AlertSchema,
|
||||
} from '../../stories/__story_mockdata__/alerts';
|
||||
|
||||
const STORY_RULE_ID = 'rule-1';
|
||||
const STORY_RELATIVE_TIME = '6h';
|
||||
|
||||
const STATISTICS = 'Alert history · statistics';
|
||||
const TIMELINE = 'Alert history · timeline';
|
||||
|
||||
/** Every history endpoint is asked for the same window the page resolved. */
|
||||
const windowOf = (req: { url: URL }): HistoryWindow => {
|
||||
const end = Number(req.url.searchParams.get('end'));
|
||||
const start = Number(req.url.searchParams.get('start'));
|
||||
|
||||
return { start, end };
|
||||
};
|
||||
|
||||
export const alertHistoryMocks = defineStoryMocks({
|
||||
controls: {
|
||||
triggers: countControl('Times triggered', {
|
||||
group: STATISTICS,
|
||||
description:
|
||||
'Drives the Total Triggered card, the trigger sparkline and the counts the top contributors add up to. Zero is the card that says nothing fired.',
|
||||
value: 48,
|
||||
max: 200,
|
||||
}),
|
||||
resolutionMinutes: countControl('Avg. resolution, minutes', {
|
||||
group: STATISTICS,
|
||||
description: 'Zero is the card that says nothing was resolved.',
|
||||
value: 22,
|
||||
max: 180,
|
||||
}),
|
||||
topContributors: countControl('Top contributors', {
|
||||
group: STATISTICS,
|
||||
description: 'The label sets that fired most often in the window.',
|
||||
value: 5,
|
||||
max: TOP_CONTRIBUTOR_MAX,
|
||||
}),
|
||||
timelineEntries: countControl('Timeline entries', {
|
||||
group: TIMELINE,
|
||||
description: `The table pages at ${TIMELINE_TABLE_PAGE_SIZE}, so anything past that is a second page.`,
|
||||
value: 26,
|
||||
max: TIMELINE_MAX,
|
||||
}),
|
||||
statusWindows: countControl('Status bands', {
|
||||
group: TIMELINE,
|
||||
description: 'How finely the graph above the table slices the window.',
|
||||
value: 30,
|
||||
max: 60,
|
||||
}),
|
||||
alertSchema: choiceControl<AlertSchema>('Alert schema', {
|
||||
group: TIMELINE,
|
||||
description:
|
||||
'Which form the Overview tab opens the rule in. The history tab only shows it in the breadcrumb and the header.',
|
||||
options: ALERT_SCHEMAS,
|
||||
value: 'v2',
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.get(
|
||||
'http://localhost/api/v2/rules/:id/history/stats',
|
||||
response.json((req) =>
|
||||
ruleHistoryStatsResponse(
|
||||
windowOf(req),
|
||||
values.triggers,
|
||||
values.resolutionMinutes,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/rules/:id/history/top_contributors',
|
||||
response.json(() =>
|
||||
ruleHistoryTopContributorsResponse(values.topContributors, values.triggers),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/rules/:id/history/overall_status',
|
||||
response.json((req) =>
|
||||
ruleHistoryOverallStatusResponse(windowOf(req), values.statusWindows),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/rules/:id/history/timeline',
|
||||
response.json((req) =>
|
||||
ruleHistoryTimelineResponse({
|
||||
total: values.timelineEntries,
|
||||
limit: TIMELINE_TABLE_PAGE_SIZE,
|
||||
end: windowOf(req).end,
|
||||
ruleId: String(req.params.id),
|
||||
ruleName: FIRST_RULE_NAME,
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/rules/:id/history/filter_keys',
|
||||
response.json(() => ruleHistoryFilterKeysResponse()),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/rules/:id/history/filter_values',
|
||||
response.json((req) =>
|
||||
ruleHistoryFilterValuesResponse(req.url.searchParams.get('key') ?? ''),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/rules/:id',
|
||||
response.json((req) =>
|
||||
alertRuleByIdResponse(String(req.params.id), {
|
||||
severity: 'mixed',
|
||||
state: 'mixed',
|
||||
schema: values.alertSchema,
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/channels',
|
||||
response.json(() => channelsResponse(CHANNEL_MAX)),
|
||||
),
|
||||
],
|
||||
config: () => ({
|
||||
route: `/alerts/history?ruleId=${STORY_RULE_ID}&relativeTime=${STORY_RELATIVE_TIME}`,
|
||||
}),
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { alertHistoryMocks } from './AlertHistory.stories.mocks';
|
||||
|
||||
import AlertList from '../../index';
|
||||
|
||||
type AlertHistoryArgs = PageStoryArgs<typeof alertHistoryMocks>;
|
||||
|
||||
const pageStory = storyMocks(alertHistoryMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* One rule's firing history: the timeline of state changes, the overall status for
|
||||
* the period, and the series contributing most to it.
|
||||
*
|
||||
* Route: `/alerts/history?ruleId=...`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Alerts/History',
|
||||
component: AlertList,
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<AlertHistoryArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<AlertHistoryArgs>;
|
||||
|
||||
/**
|
||||
* How one rule behaved over the selected window: how often it fired, how long
|
||||
* it took to resolve, what contributed most, and every state change in order.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/** A rule that never fired in the window: both cards say so and the table is empty. */
|
||||
export const NeverTriggered: Story = {
|
||||
args: {
|
||||
triggers: 0,
|
||||
resolutionMinutes: 0,
|
||||
topContributors: 0,
|
||||
timelineEntries: 0,
|
||||
statusWindows: 0,
|
||||
},
|
||||
};
|
||||
|
||||
/** A rule firing constantly, where the timeline pages rather than fits. */
|
||||
export const Noisy: Story = {
|
||||
args: { triggers: 184, timelineEntries: 40, topContributors: 8 },
|
||||
};
|
||||
@@ -1,216 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import {
|
||||
RuletypesAlertStateDTO,
|
||||
TelemetrytypesFieldContextDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
type GetRuleHistoryFilterKeys200,
|
||||
type GetRuleHistoryFilterValues200,
|
||||
type GetRuleHistoryOverallStatus200,
|
||||
type GetRuleHistoryStats200,
|
||||
type GetRuleHistoryTimeline200,
|
||||
type GetRuleHistoryTopContributors200,
|
||||
type Querybuildertypesv5LabelDTO,
|
||||
type Querybuildertypesv5TimeSeriesDTO,
|
||||
type RulestatehistorytypesGettableRuleStateHistoryDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
const MINUTE = 60 * 1000;
|
||||
|
||||
/** The labels a rule's history is broken down by, both in the table and the filters. */
|
||||
export const HISTORY_LABEL_VALUES: Record<string, string[]> = {
|
||||
'service.name': ['checkout', 'payments', 'auth', 'search'],
|
||||
'deployment.environment': ['prod', 'staging'],
|
||||
'host.name': ['ip-10-0-1-14', 'ip-10-0-2-31', 'ip-10-0-3-77'],
|
||||
severity: ['critical', 'error', 'warning'],
|
||||
};
|
||||
|
||||
const HISTORY_LABEL_KEYS = Object.keys(HISTORY_LABEL_VALUES);
|
||||
|
||||
const labelsFor = (index: number): Querybuildertypesv5LabelDTO[] =>
|
||||
HISTORY_LABEL_KEYS.map((name) => {
|
||||
const values = HISTORY_LABEL_VALUES[name];
|
||||
|
||||
return { key: { name }, value: values[index % values.length] };
|
||||
});
|
||||
|
||||
/** Points spread evenly across the window, derived from the index so a re-render redraws the same line. */
|
||||
const series = (
|
||||
start: number,
|
||||
end: number,
|
||||
points: number,
|
||||
base: number,
|
||||
amplitude: number,
|
||||
): Querybuildertypesv5TimeSeriesDTO => {
|
||||
const step = (end - start) / Math.max(points - 1, 1);
|
||||
|
||||
return {
|
||||
labels: [],
|
||||
values: Array.from({ length: points }, (_unused, index) => ({
|
||||
timestamp: Math.round(start + index * step),
|
||||
value: Math.max(
|
||||
0,
|
||||
Math.round(base + amplitude * Math.sin(index / 2.5) + amplitude * 0.4),
|
||||
),
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
export interface HistoryWindow {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/** `currentAvgResolutionTime` is seconds: `formatTime` picks the unit it prints. */
|
||||
export const ruleHistoryStatsResponse = (
|
||||
{ start, end }: HistoryWindow,
|
||||
triggers: number,
|
||||
avgResolutionMinutes: number,
|
||||
): GetRuleHistoryStats200 => {
|
||||
const current = avgResolutionMinutes * 60;
|
||||
const past = Math.round(current * 1.35);
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
data: {
|
||||
totalCurrentTriggers: triggers,
|
||||
totalPastTriggers: Math.round(triggers * 0.7),
|
||||
currentAvgResolutionTime: current,
|
||||
pastAvgResolutionTime: past,
|
||||
currentTriggersSeries: series(start, end, 24, triggers / 12, triggers / 8),
|
||||
pastTriggersSeries: series(start, end, 24, triggers / 16, triggers / 10),
|
||||
currentAvgResolutionTimeSeries: series(start, end, 24, current, current / 3),
|
||||
pastAvgResolutionTimeSeries: series(start, end, 24, past, past / 3),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const TOP_CONTRIBUTOR_MAX = 8;
|
||||
|
||||
export const ruleHistoryTopContributorsResponse = (
|
||||
count: number,
|
||||
totalTriggers: number,
|
||||
): GetRuleHistoryTopContributors200 => ({
|
||||
status: 'success',
|
||||
data: Array.from({ length: count }, (_unused, index) => ({
|
||||
fingerprint: 100_000 + index,
|
||||
count: Math.max(1, Math.round(totalTriggers / (index + 2))),
|
||||
labels: labelsFor(index),
|
||||
relatedLogsLink: 'http://localhost/logs/logs-explorer',
|
||||
relatedTracesLink: 'http://localhost/traces-explorer',
|
||||
})),
|
||||
});
|
||||
|
||||
/**
|
||||
* The graph draws one band per window, so the windows have to tile the range
|
||||
* end to end: a gap reads as a hole in the timeline rather than a quiet period.
|
||||
*/
|
||||
export const ruleHistoryOverallStatusResponse = (
|
||||
{ start, end }: HistoryWindow,
|
||||
windows: number,
|
||||
): GetRuleHistoryOverallStatus200 => {
|
||||
const step = (end - start) / Math.max(windows, 1);
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
data: Array.from({ length: windows }, (_unused, index) => ({
|
||||
start: Math.round(start + index * step),
|
||||
end: Math.round(start + (index + 1) * step),
|
||||
state:
|
||||
index % 5 === 0
|
||||
? RuletypesAlertStateDTO.firing
|
||||
: RuletypesAlertStateDTO.inactive,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
export const TIMELINE_MAX = 40;
|
||||
|
||||
export interface TimelineShape {
|
||||
total: number;
|
||||
limit: number;
|
||||
end: number;
|
||||
state?: RuletypesAlertStateDTO;
|
||||
ruleId: string;
|
||||
ruleName: string;
|
||||
}
|
||||
|
||||
const timelineItem = (
|
||||
index: number,
|
||||
shape: TimelineShape,
|
||||
): RulestatehistorytypesGettableRuleStateHistoryDTO => {
|
||||
const state =
|
||||
shape.state ??
|
||||
(index % 2 === 0
|
||||
? RuletypesAlertStateDTO.firing
|
||||
: RuletypesAlertStateDTO.inactive);
|
||||
|
||||
return {
|
||||
ruleId: shape.ruleId,
|
||||
ruleName: shape.ruleName,
|
||||
fingerprint: 100_000 + (index % TOP_CONTRIBUTOR_MAX),
|
||||
labels: labelsFor(index),
|
||||
overallState: state,
|
||||
overallStateChanged: index % 3 === 0,
|
||||
state,
|
||||
stateChanged: index % 2 === 0,
|
||||
unixMilli: shape.end - index * 7 * MINUTE,
|
||||
value: Number((60 + (index % 9) * 4.5).toFixed(2)),
|
||||
relatedLogsLink: 'http://localhost/logs/logs-explorer',
|
||||
relatedTracesLink: 'http://localhost/traces-explorer',
|
||||
};
|
||||
};
|
||||
|
||||
export const ruleHistoryTimelineResponse = (
|
||||
shape: TimelineShape,
|
||||
): GetRuleHistoryTimeline200 => {
|
||||
const size = Math.min(shape.limit, shape.total);
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
data: {
|
||||
total: shape.total,
|
||||
nextCursor: shape.total > size ? 'next-page-cursor' : '',
|
||||
items: Array.from({ length: size }, (_unused, index) =>
|
||||
timelineItem(index, shape),
|
||||
),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const ruleHistoryFilterKeysResponse =
|
||||
(): GetRuleHistoryFilterKeys200 => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
keys: Object.fromEntries(
|
||||
HISTORY_LABEL_KEYS.map((name) => [
|
||||
name,
|
||||
[
|
||||
{
|
||||
name,
|
||||
signal: TelemetrytypesSignalDTO.traces,
|
||||
fieldContext: TelemetrytypesFieldContextDTO.resource,
|
||||
},
|
||||
],
|
||||
]),
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
export const ruleHistoryFilterValuesResponse = (
|
||||
key: string,
|
||||
): GetRuleHistoryFilterValues200 => {
|
||||
const values = HISTORY_LABEL_VALUES[key] ?? [];
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
values: { stringValues: values, relatedValues: values },
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,148 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { rest } from 'msw';
|
||||
|
||||
import { choiceControl, countControl } from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
|
||||
import {
|
||||
alertRuleByIdResponse,
|
||||
ALERT_SCHEMAS,
|
||||
channelsResponse,
|
||||
CHANNEL_MAX,
|
||||
RULE_STATE_CHOICES,
|
||||
SEVERITY_CHOICES,
|
||||
type AlertSchema,
|
||||
type RuleStateChoice,
|
||||
type SeverityChoice,
|
||||
} from '../../stories/__story_mockdata__/alerts';
|
||||
import {
|
||||
alertFieldKeysResponse,
|
||||
alertFieldValuesResponse,
|
||||
alertMetricMetadataResponse,
|
||||
alertMetricsResponse,
|
||||
alertPreviewSeries,
|
||||
} from '../../stories/__story_mockdata__/alertQuery';
|
||||
|
||||
const STORY_RULE_ID = 'rule-1';
|
||||
const STORY_RELATIVE_TIME = '6h';
|
||||
|
||||
const RULE = 'Alert overview · rule';
|
||||
const PREVIEW = 'Alert overview · preview';
|
||||
|
||||
export const alertOverviewMocks = defineStoryMocks({
|
||||
controls: {
|
||||
alertSchema: choiceControl<AlertSchema>('Alert schema', {
|
||||
group: RULE,
|
||||
description:
|
||||
'`v2` opens the stepper the new alert form uses; `classic` is the single-form page rules written before it still open in.',
|
||||
options: ALERT_SCHEMAS,
|
||||
value: 'v2',
|
||||
}),
|
||||
ruleState: choiceControl<RuleStateChoice>('State', {
|
||||
group: RULE,
|
||||
description: 'The badge next to the rule name in the header.',
|
||||
options: RULE_STATE_CHOICES,
|
||||
value: 'firing',
|
||||
}),
|
||||
ruleSeverity: choiceControl<SeverityChoice>('Severity', {
|
||||
group: RULE,
|
||||
options: SEVERITY_CHOICES,
|
||||
value: 'critical',
|
||||
}),
|
||||
channels: countControl('Notification channels', {
|
||||
group: RULE,
|
||||
description: 'What the thresholds can be routed to.',
|
||||
value: 5,
|
||||
max: CHANNEL_MAX,
|
||||
}),
|
||||
previewSeries: countControl('Preview series', {
|
||||
group: PREVIEW,
|
||||
description:
|
||||
'Lines the chart above the condition draws. Zero is the preview with nothing to plot.',
|
||||
value: 3,
|
||||
max: 6,
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.get(
|
||||
'http://localhost/api/v2/rules/:id',
|
||||
response.json((req) =>
|
||||
alertRuleByIdResponse(String(req.params.id), {
|
||||
severity: values.ruleSeverity,
|
||||
state: values.ruleState,
|
||||
schema: values.alertSchema,
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
rest.put('http://localhost/api/v2/rules/:id', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
|
||||
),
|
||||
|
||||
rest.patch('http://localhost/api/v2/rules/:id', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
|
||||
),
|
||||
|
||||
rest.delete('http://localhost/api/v2/rules/:id', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
|
||||
),
|
||||
|
||||
rest.post('http://localhost/api/v2/rules/test', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: { alertCount: 2, message: 'Rule tested against the last 6 hours' },
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/channels',
|
||||
response.json(() => channelsResponse(values.channels)),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v5/query_range',
|
||||
response.json(async (req) => alertPreviewSeries(values.previewSeries, req)),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/metrics',
|
||||
response.json((req) =>
|
||||
alertMetricsResponse(req.url.searchParams.get('searchText') ?? ''),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/metrics/metadata',
|
||||
response.json((req) =>
|
||||
alertMetricMetadataResponse(req.url.searchParams.get('metricName') ?? ''),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/fields/keys',
|
||||
response.json((req) =>
|
||||
alertFieldKeysResponse(req.url.searchParams.get('searchText') ?? ''),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/fields/values',
|
||||
response.json((req) =>
|
||||
alertFieldValuesResponse(
|
||||
req.url.searchParams.get('name') ?? '',
|
||||
req.url.searchParams.get('searchText') ?? '',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
config: () => ({
|
||||
route: `/alerts/overview?ruleId=${STORY_RULE_ID}&relativeTime=${STORY_RELATIVE_TIME}`,
|
||||
}),
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { alertOverviewMocks } from './AlertOverview.stories.mocks';
|
||||
|
||||
import AlertList from '../../index';
|
||||
|
||||
type AlertOverviewArgs = PageStoryArgs<typeof alertOverviewMocks>;
|
||||
|
||||
const pageStory = storyMocks(alertOverviewMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* One rule read only: its condition, the series it evaluates against, its state
|
||||
* and the channels it notifies.
|
||||
*
|
||||
* Route: `/alerts/overview?ruleId=...`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Alerts/Overview',
|
||||
component: AlertList,
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<AlertOverviewArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<AlertOverviewArgs>;
|
||||
|
||||
/**
|
||||
* One alert rule opened up: the query it watches, the condition it fires on and
|
||||
* where the notification goes.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/** A rule written before the current schema, which opens in the classic form. */
|
||||
export const ClassicSchema: Story = {
|
||||
args: { alertSchema: 'classic' },
|
||||
};
|
||||
|
||||
/** A rule someone turned off: the toggle in the header is what turns it back on. */
|
||||
export const Disabled: Story = {
|
||||
args: { ruleState: 'disabled' },
|
||||
};
|
||||
|
||||
/** A rule with no matching series in the window, so the preview has nothing to draw. */
|
||||
export const NoPreviewData: Story = {
|
||||
args: { previewSeries: 0 },
|
||||
};
|
||||
|
||||
/** The rule id in the URL does not resolve, which is where the page gives up. */
|
||||
export const RuleNotFound: Story = {
|
||||
args: { dataState: 'error' },
|
||||
// The mocked rule request intentionally fails; the resulting console error is
|
||||
// the point of the story, not a regression.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { rest } from 'msw';
|
||||
|
||||
import { choiceControl, countControl } from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
|
||||
import {
|
||||
alertRulesResponse,
|
||||
RULE_MAX,
|
||||
RULE_STATE_CHOICES,
|
||||
SEVERITY_CHOICES,
|
||||
type RuleStateChoice,
|
||||
type SeverityChoice,
|
||||
} from '../../stories/__story_mockdata__/alerts';
|
||||
import { AlertListTabs } from '../../types';
|
||||
|
||||
const LIST = 'Alert rules · list';
|
||||
|
||||
export const alertRulesMocks = defineStoryMocks({
|
||||
controls: {
|
||||
rules: countControl('Alert rules', {
|
||||
group: LIST,
|
||||
value: 8,
|
||||
max: RULE_MAX,
|
||||
}),
|
||||
ruleSeverity: choiceControl<SeverityChoice>('Severity', {
|
||||
group: LIST,
|
||||
description:
|
||||
'The severity label every rule carries. `mixed` leaves each rule with its own.',
|
||||
options: SEVERITY_CHOICES,
|
||||
value: 'mixed',
|
||||
}),
|
||||
ruleState: choiceControl<RuleStateChoice>('State', {
|
||||
group: LIST,
|
||||
description:
|
||||
'The evaluation state the Status column shows. `disabled` also switches the row action to Enable.',
|
||||
options: RULE_STATE_CHOICES,
|
||||
value: 'mixed',
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.get(
|
||||
'http://localhost/api/v2/rules',
|
||||
response.json(() =>
|
||||
alertRulesResponse(values.rules, {
|
||||
severity: values.ruleSeverity,
|
||||
state: values.ruleState,
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
config: () => ({ route: `/alerts?tab=${AlertListTabs.ALERT_RULES}` }),
|
||||
});
|
||||
@@ -1,135 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { alertRulesMocks } from './AlertRules.stories.mocks';
|
||||
|
||||
import AlertList from '../../index';
|
||||
import { RULE_MAX } from '../../stories/__story_mockdata__/alerts';
|
||||
|
||||
type AlertRulesArgs = PageStoryArgs<typeof alertRulesMocks>;
|
||||
|
||||
const pageStory = storyMocks(alertRulesMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* The rule list tab: every rule with its severity, state and channels. Creating
|
||||
* and editing follow the legacy editor role.
|
||||
*
|
||||
* Route: `/alerts?tab=AlertRules`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Alerts/Rules',
|
||||
tags: ['role-gated', 'play'],
|
||||
component: AlertList,
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<AlertRulesArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<AlertRulesArgs>;
|
||||
|
||||
/** The page fetches before it renders a row, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
/**
|
||||
* Every alert rule the org has configured, with the state each one evaluated to
|
||||
* on its last run and the severity it fires at.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/** A workspace with no rule yet, which is where the tab explains itself. */
|
||||
export const NoRules: Story = {
|
||||
args: { rules: 0 },
|
||||
};
|
||||
|
||||
/** Search: an unmatched query retains the filters and renders the no-results branch. */
|
||||
export const SearchNoResults: Story = {
|
||||
parameters: {
|
||||
signoz: { route: '/alerts?tab=AlertRules&search=no-matching-alert-rule' },
|
||||
},
|
||||
};
|
||||
|
||||
/** Data: the list remains mounted while its initial request is pending. */
|
||||
export const Loading: Story = {
|
||||
args: { dataState: 'loading' },
|
||||
};
|
||||
|
||||
/** Data: the table's retryable error state after the rule request fails. */
|
||||
export const LoadError: Story = {
|
||||
args: { dataState: 'error' },
|
||||
// The mocked rule request intentionally fails; the resulting console error is
|
||||
// the point of the story, not a regression.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
/** Density: a second page of rules with the shared pagination controls visible. */
|
||||
export const Paginated: Story = {
|
||||
args: { rules: RULE_MAX },
|
||||
parameters: {
|
||||
signoz: { route: '/alerts?tab=AlertRules&page=2&limit=10' },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A viewer: the row actions and the New Alert button are gone, so the tab is
|
||||
* read-only.
|
||||
*/
|
||||
export const Viewer: Story = {
|
||||
args: { access: 'viewer' },
|
||||
};
|
||||
|
||||
/** The per-rule actions: enable or disable, edit, clone and delete. */
|
||||
export const RowActions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const [actions] = await within(canvasElement).findAllByTestId(
|
||||
'alert-actions',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.click(actions);
|
||||
await screen.findByText(/clone/i);
|
||||
},
|
||||
};
|
||||
|
||||
/** Interaction: a disabled rule exposes Enable in its real row-action menu. */
|
||||
export const RowActionsDisabledRule: Story = {
|
||||
args: { ruleState: 'disabled' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const [actions] = await within(canvasElement).findAllByTestId(
|
||||
'alert-actions',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.click(actions);
|
||||
await screen.findByText(/^enable$/i);
|
||||
},
|
||||
};
|
||||
|
||||
/** The columns the table can show, including the audit ones it hides by default. */
|
||||
export const ColumnPicker: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'alert-columns-button',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByText(/toggle columns/i);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Every label badge in the Labels column, held open: each one repeats its own
|
||||
* `key: value` with a copy button. The rules carry two labels apiece, which fit
|
||||
* the column, so the overflow chip and its list are on the Triggered tab
|
||||
* instead.
|
||||
*/
|
||||
export const Tooltips: Story = {
|
||||
args: { tooltipsOpen: true },
|
||||
};
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { rest } from 'msw';
|
||||
|
||||
import { countControl } from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
|
||||
import {
|
||||
CHANNEL_MAX,
|
||||
channelsResponse,
|
||||
} from '../../stories/__story_mockdata__/alerts';
|
||||
import { AlertListTabs } from '../../types';
|
||||
|
||||
const LIST = 'Channels · list';
|
||||
|
||||
export const channelsMocks = defineStoryMocks({
|
||||
controls: {
|
||||
channels: countControl('Notification channels', {
|
||||
group: LIST,
|
||||
description: 'One per channel type, in the order the seeds declare them.',
|
||||
value: 5,
|
||||
max: CHANNEL_MAX,
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.get(
|
||||
'http://localhost/api/v1/channels',
|
||||
response.json(() => channelsResponse(values.channels)),
|
||||
),
|
||||
],
|
||||
config: () => ({ route: `/alerts?tab=${AlertListTabs.CHANNELS}` }),
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { channelsMocks } from './Channels.stories.mocks';
|
||||
|
||||
import AlertList from '../../index';
|
||||
|
||||
type ChannelsArgs = PageStoryArgs<typeof channelsMocks>;
|
||||
|
||||
const pageStory = storyMocks(channelsMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* Notification channels tab: what a rule can notify, one row per channel.
|
||||
*
|
||||
* Route: `/alerts?tab=Channels`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Alerts/Channels/List',
|
||||
tags: ['role-gated'],
|
||||
component: AlertList,
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<ChannelsArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<ChannelsArgs>;
|
||||
|
||||
/**
|
||||
* Where notifications go: every configured channel with the integration it
|
||||
* sends through.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/** A workspace with nowhere to send an alert yet. */
|
||||
export const NoChannels: Story = {
|
||||
args: { channels: 0 },
|
||||
};
|
||||
|
||||
/** Data: the channel list's existing loading spinner. */
|
||||
export const Loading: Story = {
|
||||
args: { dataState: 'loading' },
|
||||
};
|
||||
|
||||
/** Data: the channel list's retryable request-error branch. */
|
||||
export const LoadError: Story = {
|
||||
args: { dataState: 'error' },
|
||||
// The mocked channels request intentionally fails; the resulting console error
|
||||
// is the point of the story, not a regression.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
/**
|
||||
* A viewer: the Action column and the New Alert Channel button are gone, and
|
||||
* the button explains who to ask.
|
||||
*/
|
||||
export const Viewer: Story = {
|
||||
args: { access: 'viewer' },
|
||||
};
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { rest } from 'msw';
|
||||
|
||||
import { choiceControl } from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { MockResolver } from '@/storybook/msw/types';
|
||||
|
||||
import {
|
||||
CHANNEL_ACTION_OUTCOMES,
|
||||
channelActionError,
|
||||
CHANNEL_TYPES,
|
||||
channelResponse,
|
||||
type ChannelActionOutcome,
|
||||
type ChannelType,
|
||||
} from '../../stories/__story_mockdata__/alerts';
|
||||
|
||||
const STORY_CHANNEL_ID = '1';
|
||||
|
||||
const CHANNEL = 'Channel · integration';
|
||||
const ACTIONS = 'Channel · actions';
|
||||
|
||||
const resolveChannelActionSuccess: MockResolver = (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: null }));
|
||||
|
||||
const rejectChannelAction: MockResolver = (_req, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json(channelActionError()));
|
||||
|
||||
export const channelsEditMocks = defineStoryMocks({
|
||||
controls: {
|
||||
channelType: choiceControl<ChannelType>('Channel type', {
|
||||
group: CHANNEL,
|
||||
description:
|
||||
'The integration the saved channel uses, which decides every field below the type picker.',
|
||||
options: CHANNEL_TYPES,
|
||||
value: 'slack',
|
||||
}),
|
||||
saveOutcome: choiceControl<ChannelActionOutcome>('Saving the channel', {
|
||||
group: ACTIONS,
|
||||
options: CHANNEL_ACTION_OUTCOMES,
|
||||
value: 'succeeds',
|
||||
}),
|
||||
testOutcome: choiceControl<ChannelActionOutcome>('Testing the channel', {
|
||||
group: ACTIONS,
|
||||
options: CHANNEL_ACTION_OUTCOMES,
|
||||
value: 'succeeds',
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.get(
|
||||
'http://localhost/api/v1/channels/:id',
|
||||
response.json((req) =>
|
||||
channelResponse(String(req.params.id), values.channelType),
|
||||
),
|
||||
),
|
||||
|
||||
rest.put(
|
||||
'http://localhost/api/v1/channels/:id',
|
||||
values.saveOutcome === 'fails'
|
||||
? rejectChannelAction
|
||||
: resolveChannelActionSuccess,
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v1/testChannel',
|
||||
values.testOutcome === 'fails'
|
||||
? rejectChannelAction
|
||||
: resolveChannelActionSuccess,
|
||||
),
|
||||
],
|
||||
config: () => ({ route: `/alerts/channels/edit/${STORY_CHANNEL_ID}` }),
|
||||
});
|
||||
@@ -1,161 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { channelsEditMocks } from './ChannelsEdit.stories.mocks';
|
||||
|
||||
import AlertList from '../../index';
|
||||
|
||||
type ChannelsEditArgs = PageStoryArgs<typeof channelsEditMocks>;
|
||||
|
||||
const pageStory = storyMocks(channelsEditMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* One channel's settings, with the fields its type asks for and the test call the
|
||||
* form makes before saving.
|
||||
*
|
||||
* Route: `/alerts/channels/edit/:id`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Alerts/Channels/Edit',
|
||||
tags: ['play'],
|
||||
component: AlertList,
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<ChannelsEditArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
/** The page loads the saved channel before it renders the form. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
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.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Mutation: clearing the required webhook URL surfaces the form's validation feedback. */
|
||||
export const InvalidRequiredFields: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
const webhookUrl = await canvas.findByTestId(
|
||||
'webhook-url-textbox',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.clear(webhookUrl);
|
||||
await userEvent.click(
|
||||
await canvas.findByTestId('save-channel-button', undefined, untilLoaded),
|
||||
);
|
||||
await screen.findByText('Webhook URL is mandatory');
|
||||
},
|
||||
};
|
||||
|
||||
/** Mutation: a failed test request keeps the edit form open and renders its error feedback. */
|
||||
export const TestChannelFailure: Story = {
|
||||
args: { testOutcome: 'fails' },
|
||||
// The mocked test request intentionally fails; the resulting console error is
|
||||
// the point of the story, not a regression.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'test-channel-button',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByText('Storybook forced channel failure');
|
||||
},
|
||||
};
|
||||
|
||||
/** Mutation: a failed save leaves the saved channel editable and surfaces the request error. */
|
||||
export const SaveFailure: Story = {
|
||||
args: { saveOutcome: 'fails' },
|
||||
// The mocked save request intentionally fails; the resulting console error is
|
||||
// the point of the story, not a regression.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'save-channel-button',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByText('Storybook forced channel failure');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The PagerDuty channel, whose form carries the routing key and the extra
|
||||
* details sent with the incident.
|
||||
*/
|
||||
export const PagerDuty: Story = {
|
||||
args: { channelType: 'pagerduty' },
|
||||
};
|
||||
|
||||
/** The webhook channel, saved with basic auth on the outgoing request. */
|
||||
export const Webhook: Story = {
|
||||
args: { channelType: 'webhook' },
|
||||
};
|
||||
|
||||
/**
|
||||
* The Opsgenie channel, whose form carries the integration API key and the
|
||||
* priority the alert is raised at.
|
||||
*/
|
||||
export const Opsgenie: Story = {
|
||||
args: { channelType: 'opsgenie' },
|
||||
};
|
||||
|
||||
/**
|
||||
* The email channel, whose only editable field is the comma-separated recipient
|
||||
* list: the form keeps the HTML body and the headers it was saved with.
|
||||
*/
|
||||
export const Email: Story = {
|
||||
args: { channelType: 'email' },
|
||||
};
|
||||
|
||||
/**
|
||||
* The Microsoft Teams channel, stored under `msteamsv2_configs` and filled from
|
||||
* the channel's incoming webhook.
|
||||
*/
|
||||
export const MicrosoftTeams: Story = {
|
||||
args: { channelType: 'msteams' },
|
||||
};
|
||||
|
||||
/** The Google Chat channel, filled from the space's incoming webhook. */
|
||||
export const GoogleChat: Story = {
|
||||
args: { channelType: 'googlechat' },
|
||||
};
|
||||
|
||||
/**
|
||||
* The Jira channel, which files an issue: the site and project it files into,
|
||||
* the transitions that resolve and reopen it, and the API token behind the
|
||||
* Atlassian account, which the form reads off the basic auth block.
|
||||
*/
|
||||
export const Jira: Story = {
|
||||
args: { channelType: 'jira' },
|
||||
};
|
||||
|
||||
/**
|
||||
* The Jira Service Management Ops channel, whose tags are stored as one
|
||||
* comma-separated string and come back as chips.
|
||||
*/
|
||||
export const JiraServiceManagementOps: Story = {
|
||||
args: { channelType: 'jsmops' },
|
||||
};
|
||||
|
||||
/**
|
||||
* The incident.io channel, pointed at one alert source's events URL with the
|
||||
* token for it and the metadata merged over the alert's labels.
|
||||
*/
|
||||
export const IncidentIO: Story = {
|
||||
args: { channelType: 'incidentio' },
|
||||
};
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { rest } from 'msw';
|
||||
|
||||
import { choiceControl } from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { MockResolver } from '@/storybook/msw/types';
|
||||
|
||||
import {
|
||||
CHANNEL_ACTION_OUTCOMES,
|
||||
channelActionError,
|
||||
type ChannelActionOutcome,
|
||||
} from '../../stories/__story_mockdata__/alerts';
|
||||
|
||||
const ACTIONS = 'Channel · actions';
|
||||
|
||||
const resolveChannelCreated: MockResolver = (_req, res, ctx) =>
|
||||
res(ctx.status(201), ctx.json({ status: 'success', data: null }));
|
||||
|
||||
const resolveChannelTested: MockResolver = (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: null }));
|
||||
|
||||
const rejectChannelAction: MockResolver = (_req, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json(channelActionError()));
|
||||
|
||||
/**
|
||||
* The form holds the channel type in component state, so the type itself is
|
||||
* stories with a `play` that picks one rather than a control; saving and
|
||||
* testing the channel are.
|
||||
*/
|
||||
export const channelsNewMocks = defineStoryMocks({
|
||||
controls: {
|
||||
saveOutcome: choiceControl<ChannelActionOutcome>('Saving the channel', {
|
||||
group: ACTIONS,
|
||||
options: CHANNEL_ACTION_OUTCOMES,
|
||||
value: 'succeeds',
|
||||
}),
|
||||
testOutcome: choiceControl<ChannelActionOutcome>('Testing the channel', {
|
||||
group: ACTIONS,
|
||||
options: CHANNEL_ACTION_OUTCOMES,
|
||||
value: 'succeeds',
|
||||
}),
|
||||
},
|
||||
handlers: (values) => [
|
||||
rest.post(
|
||||
'http://localhost/api/v1/channels',
|
||||
values.saveOutcome === 'fails' ? rejectChannelAction : resolveChannelCreated,
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v1/testChannel',
|
||||
values.testOutcome === 'fails' ? rejectChannelAction : resolveChannelTested,
|
||||
),
|
||||
],
|
||||
config: () => ({ route: '/alerts/channels/new' }),
|
||||
});
|
||||
@@ -1,178 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { channelsNewMocks } from './ChannelsNew.stories.mocks';
|
||||
|
||||
import AlertList from '../../index';
|
||||
|
||||
type ChannelsNewArgs = PageStoryArgs<typeof channelsNewMocks>;
|
||||
|
||||
const pageStory = storyMocks(channelsNewMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* The new channel form: pick a type, fill its fields, test it, save.
|
||||
*
|
||||
* Route: `/alerts/channels/new`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Alerts/Channels/New',
|
||||
tags: ['play'],
|
||||
component: AlertList,
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<ChannelsNewArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<ChannelsNewArgs>;
|
||||
|
||||
/**
|
||||
* The type is an antd Select: clicking the element carrying the test id does
|
||||
* nothing, the combobox inside it is what opens the list.
|
||||
*/
|
||||
const selectChannelType = async (
|
||||
canvasElement: HTMLElement,
|
||||
label: RegExp,
|
||||
): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
const select = await canvas.findByTestId('channel-type-select');
|
||||
|
||||
await userEvent.click(within(select).getByRole('combobox'));
|
||||
await userEvent.click(await screen.findByTitle(label));
|
||||
// The form under the Select swaps a render after the option is taken, so the
|
||||
// Select's own value is what says the story is on the type it names.
|
||||
await within(select).findByTitle(label);
|
||||
};
|
||||
|
||||
/** A new notification channel, on the Slack form the page opens with. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Interaction: the channel-type Select opens its real portal-backed option list. */
|
||||
export const ChannelTypeSelectOpen: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const select = await within(canvasElement).findByTestId(
|
||||
'channel-type-select',
|
||||
);
|
||||
|
||||
await userEvent.click(within(select).getByRole('combobox'));
|
||||
await screen.findByRole('listbox');
|
||||
},
|
||||
};
|
||||
|
||||
/** Mutation: saving without a channel name shows the form's required-field feedback. */
|
||||
export const InvalidRequiredFields: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId('save-channel-button'),
|
||||
);
|
||||
await screen.findByText('Channel name is mandatory');
|
||||
},
|
||||
};
|
||||
|
||||
/** Mutation: a failed test request opens the application's error feedback. */
|
||||
export const TestChannelFailure: Story = {
|
||||
args: { testOutcome: 'fails' },
|
||||
// The mocked test request intentionally fails; the resulting console error is
|
||||
// the point of the story, not a regression.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId('test-channel-button'),
|
||||
);
|
||||
await screen.findByText('Storybook forced channel failure');
|
||||
},
|
||||
};
|
||||
|
||||
/** Mutation: a failed create request leaves the form visible with error feedback. */
|
||||
export const SaveFailure: Story = {
|
||||
args: { saveOutcome: 'fails' },
|
||||
// The mocked save request intentionally fails; the resulting console error is
|
||||
// the point of the story, not a regression.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.type(
|
||||
await canvas.findByTestId('channel-name-textbox'),
|
||||
'Storybook channel',
|
||||
);
|
||||
await userEvent.type(
|
||||
await canvas.findByTestId('webhook-url-textbox'),
|
||||
'https://hooks.slack.com/services/storybook',
|
||||
);
|
||||
await userEvent.click(await canvas.findByTestId('save-channel-button'));
|
||||
await screen.findByText('Storybook forced channel failure');
|
||||
},
|
||||
};
|
||||
|
||||
/** The webhook form: the URL to post to and the auth to send with it. */
|
||||
export const Webhook: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await selectChannelType(canvasElement, /^Webhook$/);
|
||||
},
|
||||
};
|
||||
|
||||
/** The PagerDuty form: routing key, severity and the incident details. */
|
||||
export const PagerDuty: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await selectChannelType(canvasElement, /^Pagerduty$/);
|
||||
},
|
||||
};
|
||||
|
||||
/** The Opsgenie form: the integration API key, the alert body and its priority. */
|
||||
export const Opsgenie: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await selectChannelType(canvasElement, /^Opsgenie$/);
|
||||
},
|
||||
};
|
||||
|
||||
/** The email form: the recipients and the HTML body the alert is sent as. */
|
||||
export const Email: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await selectChannelType(canvasElement, /^Email$/);
|
||||
},
|
||||
};
|
||||
|
||||
/** The Microsoft Teams form: the channel's incoming webhook and the card text. */
|
||||
export const MicrosoftTeams: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await selectChannelType(canvasElement, /^Microsoft Teams$/);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The Google Chat form, whose webhook URL is rejected unless it is an https URL
|
||||
* on `chat.googleapis.com`.
|
||||
*/
|
||||
export const GoogleChat: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await selectChannelType(canvasElement, /^Google Chat$/);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The Jira form: where the issue is filed, the transitions that close and
|
||||
* reopen it, and the Atlassian account the API token belongs to.
|
||||
*/
|
||||
export const Jira: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await selectChannelType(canvasElement, /^Jira$/);
|
||||
},
|
||||
};
|
||||
|
||||
/** The Jira Service Management Ops form: the API key, priority and tags. */
|
||||
export const JiraServiceManagementOps: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await selectChannelType(canvasElement, /^Jira Service Management Ops$/);
|
||||
},
|
||||
};
|
||||
|
||||
/** The incident.io form: the alert source's events URL and its token. */
|
||||
export const IncidentIO: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await selectChannelType(canvasElement, /^incident\.io$/);
|
||||
},
|
||||
};
|
||||
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { rest } from 'msw';
|
||||
|
||||
import { choiceControl, countControl } from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
|
||||
import {
|
||||
DOWNTIME_KINDS,
|
||||
DOWNTIME_MAX,
|
||||
downtimeSchedulesResponse,
|
||||
type DowntimeKind,
|
||||
} from './__story_mockdata__/plannedDowntime';
|
||||
|
||||
import {
|
||||
alertRulesResponse,
|
||||
RULE_MAX,
|
||||
} from '../../stories/__story_mockdata__/alerts';
|
||||
import { AlertListSubTabs, AlertListTabs } from '../../types';
|
||||
|
||||
const LIST = 'Planned downtime · list';
|
||||
const REQUEST = 'Planned downtime · requests';
|
||||
|
||||
const REQUEST_STATES = ['loaded', 'error'] as const;
|
||||
type RequestState = (typeof REQUEST_STATES)[number];
|
||||
|
||||
export const plannedDowntimeMocks = defineStoryMocks({
|
||||
controls: {
|
||||
schedules: countControl('Planned downtimes', {
|
||||
group: LIST,
|
||||
value: 4,
|
||||
max: DOWNTIME_MAX,
|
||||
}),
|
||||
downtimeKind: choiceControl<DowntimeKind>('Kind', {
|
||||
group: LIST,
|
||||
description:
|
||||
'A recurring downtime carries a repeat rule instead of an end time, which is what the Repeats row shows.',
|
||||
options: DOWNTIME_KINDS,
|
||||
value: 'mixed',
|
||||
}),
|
||||
silencedRules: countControl('Alert rules to silence', {
|
||||
group: LIST,
|
||||
description:
|
||||
'The rules the form offers, and the names a downtime resolves its silenced ids to.',
|
||||
value: 8,
|
||||
max: RULE_MAX,
|
||||
}),
|
||||
schedulesState: choiceControl<RequestState>('Schedules request', {
|
||||
group: REQUEST,
|
||||
options: REQUEST_STATES,
|
||||
value: 'loaded',
|
||||
}),
|
||||
rulesState: choiceControl<RequestState>('Alert rules request', {
|
||||
group: REQUEST,
|
||||
options: REQUEST_STATES,
|
||||
value: 'loaded',
|
||||
}),
|
||||
},
|
||||
handlers: (values, _response) => [
|
||||
rest.get('http://localhost/api/v1/downtime_schedules', (_req, res, ctx) =>
|
||||
values.schedulesState === 'error'
|
||||
? res(ctx.status(500), ctx.json({ status: 'error' }))
|
||||
: res(
|
||||
ctx.json(
|
||||
downtimeSchedulesResponse(values.schedules, values.downtimeKind),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
rest.post('http://localhost/api/v1/downtime_schedules', (_req, res, ctx) =>
|
||||
res(ctx.status(201), ctx.json({ status: 'success', data: null })),
|
||||
),
|
||||
|
||||
rest.put('http://localhost/api/v1/downtime_schedules/:id', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
|
||||
),
|
||||
|
||||
rest.delete(
|
||||
'http://localhost/api/v1/downtime_schedules/:id',
|
||||
(_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
|
||||
),
|
||||
|
||||
rest.get('http://localhost/api/v2/rules', (_req, res, ctx) =>
|
||||
values.rulesState === 'error'
|
||||
? res(ctx.status(500), ctx.json({ status: 'error' }))
|
||||
: res(
|
||||
ctx.json(
|
||||
alertRulesResponse(values.silencedRules, {
|
||||
severity: 'mixed',
|
||||
state: 'mixed',
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
config: () => ({
|
||||
route: `/alerts?tab=${AlertListTabs.CONFIGURATION}&subTab=${AlertListSubTabs.PLANNED_DOWNTIME}`,
|
||||
}),
|
||||
});
|
||||
@@ -1,157 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { plannedDowntimeMocks } from './PlannedDowntime.stories.mocks';
|
||||
import { FIRST_DOWNTIME_NAME } from './__story_mockdata__/plannedDowntime';
|
||||
|
||||
import AlertList from '../../index';
|
||||
|
||||
type PlannedDowntimeArgs = PageStoryArgs<typeof plannedDowntimeMocks>;
|
||||
|
||||
const pageStory = storyMocks(plannedDowntimeMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* Windows that silence rules on a schedule, one off or recurring, with the rules
|
||||
* each window covers.
|
||||
*
|
||||
* Route: `/alerts?tab=Configuration&subTab=PlannedDowntime`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Alerts/Planned Downtime',
|
||||
tags: ['role-gated', 'play'],
|
||||
component: AlertList,
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<PlannedDowntimeArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<PlannedDowntimeArgs>;
|
||||
|
||||
/** The page fetches before it renders a row, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
/**
|
||||
* The windows where alerting is held back: what is running now, what is
|
||||
* scheduled, and which rules each one silences.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/** A workspace that has never scheduled a downtime. */
|
||||
export const NoDowntimes: Story = {
|
||||
args: { schedules: 0 },
|
||||
};
|
||||
|
||||
/**
|
||||
* A viewer: the edit and delete actions on a row and the New downtime button
|
||||
* are gone.
|
||||
*/
|
||||
export const Viewer: Story = {
|
||||
args: { access: 'viewer' },
|
||||
};
|
||||
|
||||
/** A downtime opened up: who scheduled it, the window, and what it silences. */
|
||||
export const Expanded: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.click(
|
||||
await canvas.findByText(FIRST_DOWNTIME_NAME, undefined, untilLoaded),
|
||||
);
|
||||
await canvas.findByText(/alerts silenced/i);
|
||||
},
|
||||
};
|
||||
|
||||
/** The form a downtime is scheduled in: the window, the repeat and the rules. */
|
||||
export const NewDowntime: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByText(
|
||||
/new downtime/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByText(/new planned downtime/i);
|
||||
},
|
||||
};
|
||||
|
||||
/** The deletion confirmation opened from the first schedule's real row action. */
|
||||
export const DeleteDowntimeConfirm: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const action = (
|
||||
await within(canvasElement).findByText(
|
||||
FIRST_DOWNTIME_NAME,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
)
|
||||
)
|
||||
.closest('.header-content')
|
||||
// The row action holds edit then delete, neither of them labelled.
|
||||
?.querySelectorAll('.action-btn svg')[1];
|
||||
|
||||
if (!action) {
|
||||
throw new Error('Downtime delete action did not render');
|
||||
}
|
||||
|
||||
await userEvent.click(action);
|
||||
// The modal titles itself and its confirm button the same.
|
||||
await screen.findByRole('button', { name: 'Delete Schedule' });
|
||||
},
|
||||
};
|
||||
|
||||
/** A client-side search with no matching downtime schedule. */
|
||||
export const SearchNoResults: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
const search = await canvas.findByPlaceholderText(
|
||||
'Search for a planned downtime...',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.type(search, 'no matching downtime');
|
||||
await canvas.findByRole('table');
|
||||
},
|
||||
};
|
||||
|
||||
/** The schedule list request failed. */
|
||||
export const LoadError: Story = {
|
||||
args: { schedulesState: 'error' },
|
||||
// The mocked schedules request intentionally fails; the resulting console error
|
||||
// is the point of the story, not a regression.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
/** The new-downtime form with its alert-rules request failed. */
|
||||
export const RulesLoadError: Story = {
|
||||
args: { rulesState: 'error' },
|
||||
// The mocked rules request intentionally fails; the resulting console error is
|
||||
// the point of the story, not a regression.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: NewDowntime.play,
|
||||
};
|
||||
|
||||
/** A recurring schedule exposes its recurrence and duration treatment. */
|
||||
export const RecurringSchedule: Story = {
|
||||
args: { downtimeKind: 'recurring' },
|
||||
};
|
||||
|
||||
/** A schedule currently in effect. */
|
||||
export const ActiveNow: Story = {
|
||||
args: { schedules: 1 },
|
||||
};
|
||||
|
||||
/** Native form validation after attempting to save an empty downtime. */
|
||||
export const FormValidationError: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await NewDowntime.play?.({ canvasElement } as never);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Add downtime schedule' }),
|
||||
);
|
||||
await screen.findByText('Please enter Name');
|
||||
},
|
||||
};
|
||||
@@ -1,164 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import {
|
||||
AlertmanagertypesMaintenanceKindDTO,
|
||||
AlertmanagertypesMaintenanceStatusDTO,
|
||||
AlertmanagertypesRepeatOnDTO,
|
||||
AlertmanagertypesRepeatTypeDTO,
|
||||
type AlertmanagertypesPlannedMaintenanceDTO,
|
||||
type ListDowntimeSchedules200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
const MINUTE = 60 * 1000;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
const at = (offsetMs: number): string =>
|
||||
new Date(Date.now() + offsetMs).toISOString();
|
||||
|
||||
export const DOWNTIME_KINDS = ['mixed', 'fixed', 'recurring'] as const;
|
||||
|
||||
export type DowntimeKind = (typeof DOWNTIME_KINDS)[number];
|
||||
|
||||
interface DowntimeSeed {
|
||||
name: string;
|
||||
description: string;
|
||||
kind: AlertmanagertypesMaintenanceKindDTO;
|
||||
status: AlertmanagertypesMaintenanceStatusDTO;
|
||||
timezone: string;
|
||||
/** Relative to now, so a story always has a live, an upcoming and a past one. */
|
||||
startsInMs: number;
|
||||
lastsMs: number;
|
||||
repeatType?: AlertmanagertypesRepeatTypeDTO;
|
||||
repeatOn?: AlertmanagertypesRepeatOnDTO[];
|
||||
/** Rule ids from the shared alert seeds; empty silences every rule. */
|
||||
alertIds: string[];
|
||||
}
|
||||
|
||||
const SEEDS: DowntimeSeed[] = [
|
||||
{
|
||||
name: 'Postgres major version upgrade',
|
||||
description: 'Primary and replicas are cycled one at a time.',
|
||||
kind: AlertmanagertypesMaintenanceKindDTO.fixed,
|
||||
status: AlertmanagertypesMaintenanceStatusDTO.active,
|
||||
timezone: 'UTC',
|
||||
startsInMs: -2 * HOUR,
|
||||
lastsMs: 6 * HOUR,
|
||||
alertIds: ['rule-5', 'rule-3'],
|
||||
},
|
||||
{
|
||||
name: 'Nightly ETL window',
|
||||
description:
|
||||
'The warehouse load runs every night and saturates the ingesters.',
|
||||
kind: AlertmanagertypesMaintenanceKindDTO.recurring,
|
||||
status: AlertmanagertypesMaintenanceStatusDTO.upcoming,
|
||||
timezone: 'Europe/Berlin',
|
||||
startsInMs: 8 * HOUR,
|
||||
lastsMs: 3 * HOUR,
|
||||
repeatType: AlertmanagertypesRepeatTypeDTO.daily,
|
||||
alertIds: ['rule-12'],
|
||||
},
|
||||
{
|
||||
name: 'Weekend cluster drain',
|
||||
description: 'Nodes are drained for kernel patching.',
|
||||
kind: AlertmanagertypesMaintenanceKindDTO.recurring,
|
||||
status: AlertmanagertypesMaintenanceStatusDTO.upcoming,
|
||||
timezone: 'America/New_York',
|
||||
startsInMs: 3 * DAY,
|
||||
lastsMs: 4 * HOUR,
|
||||
repeatType: AlertmanagertypesRepeatTypeDTO.weekly,
|
||||
repeatOn: [
|
||||
AlertmanagertypesRepeatOnDTO.saturday,
|
||||
AlertmanagertypesRepeatOnDTO.sunday,
|
||||
],
|
||||
alertIds: [],
|
||||
},
|
||||
{
|
||||
name: 'Checkout release freeze',
|
||||
description: 'Deploy window for the checkout rewrite.',
|
||||
kind: AlertmanagertypesMaintenanceKindDTO.fixed,
|
||||
status: AlertmanagertypesMaintenanceStatusDTO.expired,
|
||||
timezone: 'UTC',
|
||||
startsInMs: -9 * DAY,
|
||||
lastsMs: 2 * HOUR,
|
||||
alertIds: ['rule-1', 'rule-2'],
|
||||
},
|
||||
{
|
||||
name: 'Kafka broker rebalance',
|
||||
description: 'Partitions move between brokers, lag spikes are expected.',
|
||||
kind: AlertmanagertypesMaintenanceKindDTO.fixed,
|
||||
status: AlertmanagertypesMaintenanceStatusDTO.upcoming,
|
||||
timezone: 'Asia/Kolkata',
|
||||
startsInMs: 26 * HOUR,
|
||||
lastsMs: 90 * MINUTE,
|
||||
alertIds: ['rule-4'],
|
||||
},
|
||||
{
|
||||
name: 'Monthly billing reconciliation',
|
||||
description: 'Batch jobs run long on the first of the month.',
|
||||
kind: AlertmanagertypesMaintenanceKindDTO.recurring,
|
||||
status: AlertmanagertypesMaintenanceStatusDTO.upcoming,
|
||||
timezone: 'UTC',
|
||||
startsInMs: 5 * DAY,
|
||||
lastsMs: 12 * HOUR,
|
||||
repeatType: AlertmanagertypesRepeatTypeDTO.monthly,
|
||||
alertIds: ['rule-9'],
|
||||
},
|
||||
];
|
||||
|
||||
export const DOWNTIME_MAX = SEEDS.length;
|
||||
|
||||
/** The list sorts by last update, and the seeds are built newest first. */
|
||||
export const FIRST_DOWNTIME_NAME = SEEDS[0].name;
|
||||
|
||||
const durationLabel = (ms: number): string =>
|
||||
ms % HOUR === 0 ? `${ms / HOUR}h0m0s` : `${Math.round(ms / MINUTE)}m0s`;
|
||||
|
||||
const buildSchedule = (
|
||||
index: number,
|
||||
kind: DowntimeKind,
|
||||
): AlertmanagertypesPlannedMaintenanceDTO => {
|
||||
const seed = SEEDS[index % SEEDS.length];
|
||||
const resolvedKind =
|
||||
kind === 'mixed' ? seed.kind : (kind as AlertmanagertypesMaintenanceKindDTO);
|
||||
const isRecurring =
|
||||
resolvedKind === AlertmanagertypesMaintenanceKindDTO.recurring;
|
||||
|
||||
return {
|
||||
id: `downtime-${index + 1}`,
|
||||
name: seed.name,
|
||||
description: seed.description,
|
||||
kind: resolvedKind,
|
||||
status: seed.status,
|
||||
alertIds: seed.alertIds,
|
||||
createdAt: at(-(index + 4) * DAY),
|
||||
createdBy: 'ada@signoz.io',
|
||||
updatedAt: at(-(index + 1) * DAY),
|
||||
updatedBy: 'grace@signoz.io',
|
||||
schedule: {
|
||||
timezone: seed.timezone,
|
||||
startTime: at(seed.startsInMs),
|
||||
endTime: isRecurring ? undefined : at(seed.startsInMs + seed.lastsMs),
|
||||
recurrence: isRecurring
|
||||
? {
|
||||
duration: durationLabel(seed.lastsMs),
|
||||
repeatType: seed.repeatType ?? AlertmanagertypesRepeatTypeDTO.daily,
|
||||
repeatOn: seed.repeatOn ?? null,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const downtimeSchedulesResponse = (
|
||||
count: number,
|
||||
kind: DowntimeKind,
|
||||
): ListDowntimeSchedules200 => ({
|
||||
status: 'success',
|
||||
data: Array.from({ length: count }, (_unused, index) =>
|
||||
buildSchedule(index, kind),
|
||||
),
|
||||
});
|
||||
@@ -1,87 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { rest } from 'msw';
|
||||
|
||||
import { choiceControl, countControl } from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
|
||||
import {
|
||||
ROUTING_POLICY_MAX,
|
||||
routingPoliciesResponse,
|
||||
} from './__story_mockdata__/routingPolicies';
|
||||
|
||||
import {
|
||||
CHANNEL_MAX,
|
||||
channelNames,
|
||||
channelsResponse,
|
||||
} from '../../stories/__story_mockdata__/alerts';
|
||||
import { AlertListSubTabs, AlertListTabs } from '../../types';
|
||||
|
||||
const LIST = 'Routing policies · list';
|
||||
const REQUEST = 'Routing policies · requests';
|
||||
|
||||
const REQUEST_STATES = ['loaded', 'error'] as const;
|
||||
type RequestState = (typeof REQUEST_STATES)[number];
|
||||
|
||||
export const routingPoliciesMocks = defineStoryMocks({
|
||||
controls: {
|
||||
policies: countControl('Routing policies', {
|
||||
group: LIST,
|
||||
description: 'The table paginates at five, so the cap is past that.',
|
||||
value: 4,
|
||||
max: ROUTING_POLICY_MAX,
|
||||
}),
|
||||
channels: countControl('Notification channels', {
|
||||
group: LIST,
|
||||
description:
|
||||
'The channels a policy can route to, and the ones its Channels row names.',
|
||||
value: 6,
|
||||
max: CHANNEL_MAX,
|
||||
}),
|
||||
policiesState: choiceControl<RequestState>('Policies request', {
|
||||
group: REQUEST,
|
||||
options: REQUEST_STATES,
|
||||
value: 'loaded',
|
||||
}),
|
||||
channelsState: choiceControl<RequestState>('Channels request', {
|
||||
group: REQUEST,
|
||||
options: REQUEST_STATES,
|
||||
value: 'loaded',
|
||||
}),
|
||||
},
|
||||
handlers: (values, _response) => [
|
||||
rest.get('http://localhost/api/v1/route_policies', (_req, res, ctx) =>
|
||||
values.policiesState === 'error'
|
||||
? res(ctx.status(500), ctx.json({ status: 'error' }))
|
||||
: res(
|
||||
ctx.json(
|
||||
routingPoliciesResponse(values.policies, channelNames(values.channels)),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
rest.post('http://localhost/api/v1/route_policies', (_req, res, ctx) =>
|
||||
res(ctx.status(201), ctx.json({ status: 'success', data: null })),
|
||||
),
|
||||
|
||||
rest.put('http://localhost/api/v1/route_policies/:id', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
|
||||
),
|
||||
|
||||
rest.delete('http://localhost/api/v1/route_policies/:id', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
|
||||
),
|
||||
|
||||
rest.get('http://localhost/api/v1/channels', (_req, res, ctx) =>
|
||||
values.channelsState === 'error'
|
||||
? res(ctx.status(500), ctx.json({ status: 'error' }))
|
||||
: res(ctx.json(channelsResponse(values.channels))),
|
||||
),
|
||||
],
|
||||
config: () => ({
|
||||
route: `/alerts?tab=${AlertListTabs.CONFIGURATION}&subTab=${AlertListSubTabs.ROUTING_POLICIES}`,
|
||||
}),
|
||||
});
|
||||
@@ -1,139 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { routingPoliciesMocks } from './RoutingPolicies.stories.mocks';
|
||||
import { FIRST_POLICY_NAME } from './__story_mockdata__/routingPolicies';
|
||||
|
||||
import AlertList from '../../index';
|
||||
|
||||
type RoutingPoliciesArgs = PageStoryArgs<typeof routingPoliciesMocks>;
|
||||
|
||||
const pageStory = storyMocks(routingPoliciesMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* Policies that route a firing alert to channels by expression, in the order they
|
||||
* are evaluated.
|
||||
*
|
||||
* Route: `/alerts?tab=Configuration&subTab=RoutingPolicies`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Alerts/Routing Policies',
|
||||
tags: ['role-gated', 'play'],
|
||||
component: AlertList,
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<RoutingPoliciesArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<RoutingPoliciesArgs>;
|
||||
|
||||
/** The page fetches before it renders a row, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
/**
|
||||
* The rules that decide which channel an alert reaches, matched on the labels
|
||||
* the alert carries.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/** A workspace routing everything through the rule's own channels. */
|
||||
export const NoPolicies: Story = {
|
||||
args: { policies: 0 },
|
||||
};
|
||||
|
||||
/** A viewer: the row actions and the New routing policy button are gone. */
|
||||
export const Viewer: Story = {
|
||||
args: { access: 'viewer' },
|
||||
};
|
||||
|
||||
/** A policy opened up: the expression it matches on and where it sends. */
|
||||
export const Expanded: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.click(
|
||||
await canvas.findByText(FIRST_POLICY_NAME, undefined, untilLoaded),
|
||||
);
|
||||
await canvas.findByText(/expression/i);
|
||||
},
|
||||
};
|
||||
|
||||
/** The form a policy is written in: the expression and the channels it routes to. */
|
||||
export const NewPolicy: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByText(
|
||||
/new routing policy/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByText(/create routing policy/i);
|
||||
},
|
||||
};
|
||||
|
||||
/** The policy deletion confirmation, opened from the first row's real action. */
|
||||
export const DeletePolicyConfirm: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.click(
|
||||
(
|
||||
await canvas.findAllByTestId(
|
||||
'delete-routing-policy',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
)
|
||||
)[0],
|
||||
);
|
||||
// The modal titles itself and its confirm button the same.
|
||||
await screen.findByRole('button', { name: 'Delete Routing Policy' });
|
||||
},
|
||||
};
|
||||
|
||||
/** A client-side search that has no matching routing policies. */
|
||||
export const SearchNoResults: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
const search = await canvas.findByPlaceholderText(
|
||||
'Search for a routing policy...',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.type(search, 'no matching policy');
|
||||
await canvas.findByText('No matching routing policies found.');
|
||||
},
|
||||
};
|
||||
|
||||
/** The list request failed while the rest of the alerts shell remains available. */
|
||||
export const LoadError: Story = {
|
||||
args: { policiesState: 'error' },
|
||||
// The mocked policies request intentionally fails; the resulting console error
|
||||
// is the point of the story, not a regression.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
/** The create form with its notification-channel request failed. */
|
||||
export const ChannelsLoadError: Story = {
|
||||
args: { channelsState: 'error' },
|
||||
// The mocked channels request intentionally fails; the resulting console error
|
||||
// is the point of the story, not a regression.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: NewPolicy.play,
|
||||
};
|
||||
|
||||
/** Native form validation after submitting an empty routing-policy form. */
|
||||
export const FormValidationError: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await NewPolicy.play?.({ canvasElement } as never);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Save Routing Policy' }),
|
||||
);
|
||||
await screen.findByText('Please provide a name for the routing policy');
|
||||
},
|
||||
};
|
||||
@@ -1,96 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ApiRoutingPolicy,
|
||||
GetRoutingPoliciesResponse,
|
||||
} from 'api/routingPolicies/getRoutingPolicies';
|
||||
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
const ago = (ms: number): string => new Date(Date.now() - ms).toISOString();
|
||||
|
||||
interface PolicySeed {
|
||||
name: string;
|
||||
description: string;
|
||||
expression: string;
|
||||
/** Indexes into the channel seeds the shared alert builders publish. */
|
||||
channels: number[];
|
||||
}
|
||||
|
||||
const SEEDS: PolicySeed[] = [
|
||||
{
|
||||
name: 'Critical production to on-call',
|
||||
description: 'Anything critical in prod pages whoever is on call.',
|
||||
expression: 'severity = "critical" AND env = "prod"',
|
||||
channels: [1, 0],
|
||||
},
|
||||
{
|
||||
name: 'Payments team ownership',
|
||||
description: 'Payment alerts go to the team that owns the service.',
|
||||
expression: 'team = "payments"',
|
||||
channels: [0],
|
||||
},
|
||||
{
|
||||
name: 'Platform warnings to chat',
|
||||
description: 'Warnings from the platform team stay in chat.',
|
||||
expression: 'team = "platform" AND severity = "warning"',
|
||||
channels: [5],
|
||||
},
|
||||
{
|
||||
name: 'Staging is email only',
|
||||
description: 'Nothing from staging is allowed to page.',
|
||||
expression: 'env = "staging"',
|
||||
channels: [3],
|
||||
},
|
||||
{
|
||||
name: 'Database incidents',
|
||||
description: 'Anything touching Postgres opens an incident.',
|
||||
expression: 'component = "database"',
|
||||
channels: [4, 2],
|
||||
},
|
||||
{
|
||||
name: 'Catch-all',
|
||||
description: 'Everything not matched above lands in the ops channel.',
|
||||
expression: 'severity != ""',
|
||||
channels: [0],
|
||||
},
|
||||
];
|
||||
|
||||
export const ROUTING_POLICY_MAX = SEEDS.length;
|
||||
|
||||
export const FIRST_POLICY_NAME = SEEDS[0].name;
|
||||
|
||||
const buildPolicy = (
|
||||
index: number,
|
||||
channelNames: string[],
|
||||
): ApiRoutingPolicy => {
|
||||
const seed = SEEDS[index % SEEDS.length];
|
||||
|
||||
return {
|
||||
id: `routing-policy-${index + 1}`,
|
||||
name: seed.name,
|
||||
description: seed.description,
|
||||
expression: seed.expression,
|
||||
channels: seed.channels
|
||||
.map((channelIndex) => channelNames[channelIndex])
|
||||
.filter(Boolean),
|
||||
createdAt: ago((index + 6) * DAY),
|
||||
updatedAt: ago((index + 1) * HOUR),
|
||||
createdBy: 'ada@signoz.io',
|
||||
updatedBy: 'grace@signoz.io',
|
||||
};
|
||||
};
|
||||
|
||||
export const routingPoliciesResponse = (
|
||||
count: number,
|
||||
channelNames: string[],
|
||||
): GetRoutingPoliciesResponse => ({
|
||||
status: 'success',
|
||||
data: Array.from({ length: count }, (_unused, index) =>
|
||||
buildPolicy(index, channelNames),
|
||||
),
|
||||
});
|
||||
@@ -1,90 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { rest } from 'msw';
|
||||
|
||||
import { choiceControl, countControl } from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
|
||||
import {
|
||||
SEVERITY_CHOICES,
|
||||
TRIGGERED_ALERT_MAX,
|
||||
TRIGGERED_STATES,
|
||||
triggeredAlertsResponse,
|
||||
type SeverityChoice,
|
||||
type TriggeredState,
|
||||
} from '../../stories/__story_mockdata__/alerts';
|
||||
import { AlertListTabs } from '../../types';
|
||||
|
||||
const LIST = 'Triggered alerts · list';
|
||||
|
||||
export const triggeredAlertsMocks = defineStoryMocks({
|
||||
controls: {
|
||||
alerts: countControl('Triggered alerts', {
|
||||
group: LIST,
|
||||
value: 9,
|
||||
max: TRIGGERED_ALERT_MAX,
|
||||
}),
|
||||
alertSeverity: choiceControl<SeverityChoice>('Severity', {
|
||||
group: LIST,
|
||||
description:
|
||||
'The severity label every alert carries. `mixed` leaves each alert with its own, which is what the tag filter has something to narrow.',
|
||||
options: SEVERITY_CHOICES,
|
||||
value: 'mixed',
|
||||
}),
|
||||
alertState: choiceControl<TriggeredState>('Alert state', {
|
||||
group: LIST,
|
||||
description: 'Suppressed alerts are the ones a silence is holding back.',
|
||||
options: TRIGGERED_STATES,
|
||||
value: 'mixed',
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.get(
|
||||
'http://localhost/api/v1/alerts',
|
||||
response.json(() =>
|
||||
triggeredAlertsResponse(values.alerts, {
|
||||
severity: values.alertSeverity,
|
||||
state: values.alertState,
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
config: () => ({ route: `/alerts?tab=${AlertListTabs.TRIGGERED_ALERTS}` }),
|
||||
});
|
||||
|
||||
/**
|
||||
* Long enough that the column can only fit the first badge, so the rest land in
|
||||
* the overflow tooltip as one joined line.
|
||||
*/
|
||||
const OVERFLOW_LABELS: Record<string, string> = {
|
||||
environment: 'production-eu-central-1',
|
||||
team: 'platform-observability-oncall',
|
||||
owner: 'sre-primary@signoz.io',
|
||||
runbook: 'runbooks.internal.example.com/checkout/latency-budget',
|
||||
tier: 'tier-0-revenue-critical',
|
||||
compliance: 'soc2-type-2-in-scope',
|
||||
};
|
||||
|
||||
export const overflowingLabels = rest.get(
|
||||
'http://localhost/api/v1/alerts',
|
||||
(_req, res, ctx) => {
|
||||
const list = triggeredAlertsResponse(3, {
|
||||
severity: 'mixed',
|
||||
state: 'mixed',
|
||||
});
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
...list,
|
||||
data: list.data.map((alert) => ({
|
||||
...alert,
|
||||
labels: { ...alert.labels, ...OVERFLOW_LABELS },
|
||||
})),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1,150 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
overflowingLabels,
|
||||
triggeredAlertsMocks,
|
||||
} from './TriggeredAlerts.stories.mocks';
|
||||
|
||||
import AlertList from '../../index';
|
||||
import { AlertListTabs } from '../../types';
|
||||
|
||||
type TriggeredAlertsArgs = PageStoryArgs<typeof triggeredAlertsMocks>;
|
||||
|
||||
const pageStory = storyMocks(triggeredAlertsMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* Alerts firing now, grouped and filtered from the query string, with severity and
|
||||
* state per row.
|
||||
*
|
||||
* Route: `/alerts?tab=TriggeredAlerts`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Alerts/Triggered',
|
||||
tags: ['play'],
|
||||
component: AlertList,
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<TriggeredAlertsArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<TriggeredAlertsArgs>;
|
||||
|
||||
const tab = `/alerts?tab=${AlertListTabs.TRIGGERED_ALERTS}`;
|
||||
|
||||
/**
|
||||
* The alerts firing right now, newest first, with how long each one has been
|
||||
* firing and the labels the rule attached to it.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Nothing firing, which is the state an on-call engineer wants to see. */
|
||||
export const NoAlerts: Story = {
|
||||
args: { alerts: 0 },
|
||||
};
|
||||
|
||||
/** Search: an unmatched term retains the filters and renders the no-results state. */
|
||||
export const SearchNoResults: Story = {
|
||||
parameters: {
|
||||
signoz: { route: `${tab}&search=no-matching-alert` },
|
||||
},
|
||||
};
|
||||
|
||||
/** Data: the table's initial loading branch. */
|
||||
export const Loading: Story = {
|
||||
args: { dataState: 'loading' },
|
||||
};
|
||||
|
||||
/** Data: the retryable error branch when the alert request fails. */
|
||||
export const LoadError: Story = {
|
||||
args: { dataState: 'error' },
|
||||
// The mocked alerts request intentionally fails; the resulting console error is
|
||||
// the point of the story, not a regression.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
/**
|
||||
* The same alerts collapsed under the service they came from: one row per
|
||||
* group, each expanding to the alerts inside it.
|
||||
*/
|
||||
export const GroupedByService: Story = {
|
||||
parameters: {
|
||||
signoz: { route: `${tab}&groupBy=${JSON.stringify(['service'])}` },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A tag filter narrowing the list to the critical alerts, which is how the tab
|
||||
* is read during an incident.
|
||||
*/
|
||||
export const FilteredToCritical: Story = {
|
||||
parameters: {
|
||||
signoz: {
|
||||
route: `${tab}&alertFilters=${JSON.stringify(['severity:critical'])}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Interaction: the tag-filter menu is mounted in its portal. */
|
||||
export const FilterComboboxOpen: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
within(canvasElement).getByTestId('triggered-alerts-filter-combobox'),
|
||||
);
|
||||
await screen.findByRole('listbox');
|
||||
},
|
||||
};
|
||||
|
||||
/** Interaction: the group-by menu is mounted in its portal. */
|
||||
export const GroupByComboboxOpen: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
within(canvasElement).getByTestId('triggered-alerts-groupby-combobox'),
|
||||
);
|
||||
await screen.findByRole('listbox');
|
||||
},
|
||||
};
|
||||
|
||||
/** Interaction: a grouped row expands to its nested alert table. */
|
||||
export const GroupedExpanded: Story = {
|
||||
parameters: {
|
||||
signoz: { route: `${tab}&groupBy=${JSON.stringify(['service'])}` },
|
||||
},
|
||||
play: async (): Promise<void> => {
|
||||
const [firstGroup] = await screen.findAllByTestId('group-expand-toggle');
|
||||
|
||||
await userEvent.click(firstGroup);
|
||||
// The nested table is what the group opens, and it carries its own count.
|
||||
await screen.findByText(/showing 1 - 1 of 1/i);
|
||||
},
|
||||
};
|
||||
|
||||
/** Density: four selected severity filters exercise collapsed filter-pill overflow. */
|
||||
export const ManyFilterPills: Story = {
|
||||
parameters: {
|
||||
signoz: {
|
||||
route: `${tab}&alertFilters=${JSON.stringify([
|
||||
'severity:critical',
|
||||
'severity:error',
|
||||
'severity:warning',
|
||||
'severity:info',
|
||||
])}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Both tooltips the Labels column has, held open: the badge that fits, which
|
||||
* repeats its own `key: value`, and the overflow chip, which lists every label
|
||||
* that did not fit as one line. The alerts here carry far more labels than the
|
||||
* tab's own fixture, which is why the Triggered alerts, Severity and Alert
|
||||
* state controls do not reach this story.
|
||||
*/
|
||||
export const Tooltips: Story = {
|
||||
args: { tooltipsOpen: true },
|
||||
parameters: { msw: { handlers: [overflowingLabels] } },
|
||||
};
|
||||
@@ -1,225 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import {
|
||||
MetrictypesTemporalityDTO,
|
||||
MetrictypesTypeDTO,
|
||||
TelemetrytypesFieldContextDTO,
|
||||
TelemetrytypesFieldDataTypeDTO,
|
||||
type GetFieldsKeys200,
|
||||
type GetFieldsValues200,
|
||||
type GetMetricMetadata200,
|
||||
type ListMetrics200,
|
||||
type MetricsexplorertypesListMetricDTO,
|
||||
type TelemetrytypesTelemetryFieldKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
MetricRangePayloadV5,
|
||||
QueryRangeRequestV5,
|
||||
} from 'types/api/v5/queryRange';
|
||||
|
||||
import {
|
||||
queryRangeV5EmptyResponse,
|
||||
queryRangeV5TimeSeriesResponse,
|
||||
timeSeriesPoints,
|
||||
} from '@/storybook/msw/__story_mockdata__/queryRange';
|
||||
|
||||
const HOSTS = [
|
||||
'ip-10-0-1-14',
|
||||
'ip-10-0-2-31',
|
||||
'ip-10-0-3-77',
|
||||
'ip-10-0-4-08',
|
||||
'ip-10-0-5-52',
|
||||
'ip-10-0-6-19',
|
||||
];
|
||||
|
||||
/**
|
||||
* The chart the alert form previews the condition against, plotted over the
|
||||
* window the form asked for and named after the query the request carried.
|
||||
*/
|
||||
export const alertPreviewSeries = async (
|
||||
count: number,
|
||||
req: { json: () => Promise<unknown> },
|
||||
): Promise<MetricRangePayloadV5> => {
|
||||
const body = (await req.json()) as QueryRangeRequestV5;
|
||||
const queryName =
|
||||
(body.compositeQuery?.queries?.[0]?.spec as { name?: string } | undefined)
|
||||
?.name ?? 'A';
|
||||
|
||||
if (count === 0) {
|
||||
return queryRangeV5EmptyResponse(queryName);
|
||||
}
|
||||
|
||||
return queryRangeV5TimeSeriesResponse([
|
||||
{
|
||||
queryName,
|
||||
series: Array.from({ length: count }, (_unused, index) => ({
|
||||
labels: [
|
||||
{ key: { name: 'host.name' }, value: HOSTS[index % HOSTS.length] },
|
||||
],
|
||||
values: timeSeriesPoints({
|
||||
start: body.start,
|
||||
end: body.end,
|
||||
base: 55 + index * 6,
|
||||
amplitude: 12,
|
||||
seed: index * 3,
|
||||
}),
|
||||
})),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const METRIC_SEEDS: MetricsexplorertypesListMetricDTO[] = [
|
||||
{
|
||||
metricName: 'system_cpu_utilization',
|
||||
description: 'Ratio of the CPU that is in use, per host.',
|
||||
unit: 'percent',
|
||||
type: MetrictypesTypeDTO.gauge,
|
||||
temporality: MetrictypesTemporalityDTO.unspecified,
|
||||
isMonotonic: false,
|
||||
},
|
||||
{
|
||||
metricName: 'system_memory_usage',
|
||||
description: 'Memory in use, per host.',
|
||||
unit: 'bytes',
|
||||
type: MetrictypesTypeDTO.gauge,
|
||||
temporality: MetrictypesTemporalityDTO.unspecified,
|
||||
isMonotonic: false,
|
||||
},
|
||||
{
|
||||
metricName: 'http_server_duration',
|
||||
description: 'Duration of inbound HTTP requests.',
|
||||
unit: 'ms',
|
||||
type: MetrictypesTypeDTO.histogram,
|
||||
temporality: MetrictypesTemporalityDTO.cumulative,
|
||||
isMonotonic: false,
|
||||
},
|
||||
{
|
||||
metricName: 'kafka_consumer_lag',
|
||||
description: 'Messages a consumer group is behind.',
|
||||
unit: '',
|
||||
type: MetrictypesTypeDTO.gauge,
|
||||
temporality: MetrictypesTemporalityDTO.unspecified,
|
||||
isMonotonic: false,
|
||||
},
|
||||
{
|
||||
metricName: 'postgresql_backends',
|
||||
description: 'Connections open against the database.',
|
||||
unit: '',
|
||||
type: MetrictypesTypeDTO.sum,
|
||||
temporality: MetrictypesTemporalityDTO.cumulative,
|
||||
isMonotonic: true,
|
||||
},
|
||||
];
|
||||
|
||||
/** The metric picker in the query section, narrowed by whatever was typed. */
|
||||
export const alertMetricsResponse = (searchText: string): ListMetrics200 => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
metrics: METRIC_SEEDS.filter((metric) =>
|
||||
metric.metricName.includes(searchText.toLowerCase()),
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
/** The unit the chart's y-axis defaults to when a metric is selected. */
|
||||
export const alertMetricMetadataResponse = (
|
||||
metricName: string,
|
||||
): GetMetricMetadata200 => {
|
||||
const metric =
|
||||
METRIC_SEEDS.find((seed) => seed.metricName === metricName) ??
|
||||
METRIC_SEEDS[0];
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
data: {
|
||||
description: metric.description,
|
||||
unit: metric.unit,
|
||||
type: metric.type,
|
||||
temporality: metric.temporality,
|
||||
isMonotonic: metric.isMonotonic,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const FIELD_SEEDS: TelemetrytypesTelemetryFieldKeyDTO[] = [
|
||||
{
|
||||
name: 'service.name',
|
||||
fieldContext: TelemetrytypesFieldContextDTO.resource,
|
||||
fieldDataType: TelemetrytypesFieldDataTypeDTO.string,
|
||||
},
|
||||
{
|
||||
name: 'deployment.environment',
|
||||
fieldContext: TelemetrytypesFieldContextDTO.resource,
|
||||
fieldDataType: TelemetrytypesFieldDataTypeDTO.string,
|
||||
},
|
||||
{
|
||||
name: 'host.name',
|
||||
fieldContext: TelemetrytypesFieldContextDTO.resource,
|
||||
fieldDataType: TelemetrytypesFieldDataTypeDTO.string,
|
||||
},
|
||||
{
|
||||
name: 'http.route',
|
||||
fieldContext: TelemetrytypesFieldContextDTO.attribute,
|
||||
fieldDataType: TelemetrytypesFieldDataTypeDTO.string,
|
||||
},
|
||||
{
|
||||
name: 'http.status_code',
|
||||
fieldContext: TelemetrytypesFieldContextDTO.attribute,
|
||||
fieldDataType: TelemetrytypesFieldDataTypeDTO.int64,
|
||||
},
|
||||
{
|
||||
name: 'severity_text',
|
||||
fieldContext: TelemetrytypesFieldContextDTO.log,
|
||||
fieldDataType: TelemetrytypesFieldDataTypeDTO.string,
|
||||
},
|
||||
];
|
||||
|
||||
const FIELD_VALUES: Record<string, string[]> = {
|
||||
'service.name': ['checkout', 'payments', 'auth', 'search'],
|
||||
'deployment.environment': ['production', 'staging'],
|
||||
'host.name': ['ip-10-0-1-14', 'ip-10-0-2-31', 'ip-10-0-3-77'],
|
||||
'http.route': ['/checkout', '/payments/charge', '/v1/login'],
|
||||
severity_text: ['ERROR', 'WARN', 'INFO'],
|
||||
};
|
||||
|
||||
const NUMBER_FIELD_VALUES: Record<string, number[]> = {
|
||||
'http.status_code': [200, 404, 500, 503],
|
||||
};
|
||||
|
||||
const matching = <T>(values: T[], searchText: string): T[] =>
|
||||
values.filter((value) =>
|
||||
String(value).toLowerCase().includes(searchText.toLowerCase()),
|
||||
);
|
||||
|
||||
/** What the filter box in the alert's query section completes on. */
|
||||
export const alertFieldKeysResponse = (
|
||||
searchText: string,
|
||||
): GetFieldsKeys200 => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
keys: Object.fromEntries(
|
||||
FIELD_SEEDS.filter((field) =>
|
||||
field.name.includes(searchText.toLowerCase()),
|
||||
).map((field) => [field.name, [field]]),
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
export const alertFieldValuesResponse = (
|
||||
name: string,
|
||||
searchText: string,
|
||||
): GetFieldsValues200 => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
values: {
|
||||
stringValues: matching(FIELD_VALUES[name] ?? [], searchText),
|
||||
numberValues: matching(NUMBER_FIELD_VALUES[name] ?? [], searchText),
|
||||
relatedValues: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,744 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import {
|
||||
MetrictypesSpaceAggregationDTO,
|
||||
MetrictypesTemporalityDTO,
|
||||
MetrictypesTimeAggregationDTO,
|
||||
Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregationDTOSignal as MetricsSignal,
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
|
||||
Querybuildertypesv5ReduceToDTO,
|
||||
RuletypesAlertStateDTO,
|
||||
RuletypesAlertTypeDTO,
|
||||
RuletypesCompareOperatorDTO,
|
||||
RuletypesMatchTypeDTO,
|
||||
RuletypesPanelTypeDTO,
|
||||
RuletypesQueryTypeDTO,
|
||||
RuletypesRuleTypeDTO,
|
||||
RuletypesThresholdBasicDTOKind,
|
||||
type AlertmanagertypesDeprecatedGettableAlertDTO,
|
||||
type GetAlerts200,
|
||||
type GetRuleByID200,
|
||||
type ListRules200,
|
||||
type RenderErrorResponseDTO,
|
||||
type RuletypesAlertCompositeQueryDTO,
|
||||
type RuletypesRuleConditionDTO,
|
||||
type RuletypesRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { NEW_ALERT_SCHEMA_VERSION } from 'types/api/alerts/alertTypesV2';
|
||||
import type { Channels } from 'types/api/channels/getAll';
|
||||
|
||||
const MINUTE = 60 * 1000;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
const ago = (ms: number): string => new Date(Date.now() - ms).toISOString();
|
||||
|
||||
export const ALERT_SEVERITIES = [
|
||||
'critical',
|
||||
'error',
|
||||
'warning',
|
||||
'info',
|
||||
] as const;
|
||||
|
||||
export type AlertSeverity = (typeof ALERT_SEVERITIES)[number];
|
||||
|
||||
/** `mixed` spreads the seeds' own severities instead of forcing one. */
|
||||
export const SEVERITY_CHOICES = ['mixed', ...ALERT_SEVERITIES] as const;
|
||||
|
||||
export type SeverityChoice = (typeof SEVERITY_CHOICES)[number];
|
||||
|
||||
export const RULE_STATES = [
|
||||
'firing',
|
||||
'pending',
|
||||
'inactive',
|
||||
'disabled',
|
||||
'nodata',
|
||||
] as const;
|
||||
|
||||
export type RuleState = (typeof RULE_STATES)[number];
|
||||
|
||||
export const RULE_STATE_CHOICES = ['mixed', ...RULE_STATES] as const;
|
||||
|
||||
export type RuleStateChoice = (typeof RULE_STATE_CHOICES)[number];
|
||||
|
||||
export const ALERT_SCHEMAS = ['v2', 'classic'] as const;
|
||||
|
||||
export type AlertSchema = (typeof ALERT_SCHEMAS)[number];
|
||||
|
||||
export const CHANNEL_TYPES = [
|
||||
'slack',
|
||||
'webhook',
|
||||
'pagerduty',
|
||||
'opsgenie',
|
||||
'email',
|
||||
'msteams',
|
||||
'googlechat',
|
||||
'jira',
|
||||
'jsmops',
|
||||
'incidentio',
|
||||
] as const;
|
||||
|
||||
export type ChannelType = (typeof CHANNEL_TYPES)[number];
|
||||
|
||||
/**
|
||||
* One query envelope is enough for the alert form to mount its query builder
|
||||
* over the rule, and it is the query the preview chart is drawn for.
|
||||
*/
|
||||
const compositeQuery = (seed: RuleSeed): RuletypesAlertCompositeQueryDTO => ({
|
||||
queryType: RuletypesQueryTypeDTO.builder,
|
||||
panelType: RuletypesPanelTypeDTO.graph,
|
||||
unit: seed.unit,
|
||||
queries: [
|
||||
{
|
||||
type: Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query,
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: MetricsSignal.metrics,
|
||||
disabled: false,
|
||||
aggregations: [
|
||||
{
|
||||
metricName: seed.metric,
|
||||
temporality: MetrictypesTemporalityDTO.unspecified,
|
||||
timeAggregation: MetrictypesTimeAggregationDTO.avg,
|
||||
spaceAggregation: MetrictypesSpaceAggregationDTO.avg,
|
||||
reduceTo: Querybuildertypesv5ReduceToDTO.last,
|
||||
},
|
||||
],
|
||||
filter: { expression: '' },
|
||||
groupBy: [],
|
||||
order: [],
|
||||
stepInterval: 60,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const condition = (seed: RuleSeed): RuletypesRuleConditionDTO => ({
|
||||
compositeQuery: compositeQuery(seed),
|
||||
op: RuletypesCompareOperatorDTO.above,
|
||||
matchType: RuletypesMatchTypeDTO.at_least_once,
|
||||
selectedQueryName: 'A',
|
||||
target: seed.target,
|
||||
targetUnit: seed.unit,
|
||||
alertOnAbsent: false,
|
||||
requireMinPoints: false,
|
||||
thresholds: {
|
||||
kind: RuletypesThresholdBasicDTOKind.basic,
|
||||
spec: [
|
||||
{
|
||||
name: 'critical',
|
||||
matchType: RuletypesMatchTypeDTO.at_least_once,
|
||||
op: RuletypesCompareOperatorDTO.above,
|
||||
target: seed.target,
|
||||
targetUnit: seed.unit,
|
||||
channels: ['ops-slack'],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
interface RuleSeed {
|
||||
alert: string;
|
||||
alertType: RuletypesAlertTypeDTO;
|
||||
state: RuletypesAlertStateDTO;
|
||||
severity: AlertSeverity;
|
||||
labels: Record<string, string>;
|
||||
metric: string;
|
||||
unit: string;
|
||||
target: number;
|
||||
}
|
||||
|
||||
const RULE_SEEDS: RuleSeed[] = [
|
||||
{
|
||||
alert: 'Node CPU saturation',
|
||||
alertType: RuletypesAlertTypeDTO.METRIC_BASED_ALERT,
|
||||
state: RuletypesAlertStateDTO.firing,
|
||||
severity: 'critical',
|
||||
labels: { team: 'platform', env: 'prod' },
|
||||
metric: 'system_cpu_utilization',
|
||||
unit: 'percent',
|
||||
target: 85,
|
||||
},
|
||||
{
|
||||
alert: 'Checkout API latency above 2s',
|
||||
alertType: RuletypesAlertTypeDTO.TRACES_BASED_ALERT,
|
||||
state: RuletypesAlertStateDTO.firing,
|
||||
severity: 'critical',
|
||||
labels: { team: 'checkout', env: 'prod' },
|
||||
metric: 'http_server_duration',
|
||||
unit: 'ms',
|
||||
target: 2000,
|
||||
},
|
||||
{
|
||||
alert: 'Payment service error rate',
|
||||
alertType: RuletypesAlertTypeDTO.TRACES_BASED_ALERT,
|
||||
state: RuletypesAlertStateDTO.pending,
|
||||
severity: 'critical',
|
||||
labels: { team: 'payments', env: 'prod' },
|
||||
metric: 'http_server_duration',
|
||||
unit: 'percent',
|
||||
target: 5,
|
||||
},
|
||||
{
|
||||
alert: 'Kafka consumer lag',
|
||||
alertType: RuletypesAlertTypeDTO.METRIC_BASED_ALERT,
|
||||
state: RuletypesAlertStateDTO.pending,
|
||||
severity: 'error',
|
||||
labels: { team: 'platform', component: 'kafka' },
|
||||
metric: 'kafka_consumer_lag',
|
||||
unit: '',
|
||||
target: 10_000,
|
||||
},
|
||||
{
|
||||
alert: 'Postgres connections near limit',
|
||||
alertType: RuletypesAlertTypeDTO.METRIC_BASED_ALERT,
|
||||
state: RuletypesAlertStateDTO.inactive,
|
||||
severity: 'warning',
|
||||
labels: { team: 'platform', component: 'database' },
|
||||
metric: 'postgresql_backends',
|
||||
unit: '',
|
||||
target: 90,
|
||||
},
|
||||
{
|
||||
alert: 'Auth service 5xx spike',
|
||||
alertType: RuletypesAlertTypeDTO.LOGS_BASED_ALERT,
|
||||
state: RuletypesAlertStateDTO.inactive,
|
||||
severity: 'error',
|
||||
labels: { team: 'identity', env: 'prod' },
|
||||
metric: 'http_server_duration',
|
||||
unit: '',
|
||||
target: 20,
|
||||
},
|
||||
{
|
||||
alert: 'Unhandled exceptions in web',
|
||||
alertType: RuletypesAlertTypeDTO.EXCEPTIONS_BASED_ALERT,
|
||||
state: RuletypesAlertStateDTO.firing,
|
||||
severity: 'error',
|
||||
labels: { team: 'web', env: 'prod' },
|
||||
metric: 'http_server_duration',
|
||||
unit: '',
|
||||
target: 15,
|
||||
},
|
||||
{
|
||||
alert: 'Ingest pipeline dropped logs',
|
||||
alertType: RuletypesAlertTypeDTO.LOGS_BASED_ALERT,
|
||||
state: RuletypesAlertStateDTO.nodata,
|
||||
severity: 'warning',
|
||||
labels: { team: 'platform', component: 'collector' },
|
||||
metric: 'system_memory_usage',
|
||||
unit: '',
|
||||
target: 1,
|
||||
},
|
||||
{
|
||||
alert: 'Nightly batch job overran',
|
||||
alertType: RuletypesAlertTypeDTO.TRACES_BASED_ALERT,
|
||||
state: RuletypesAlertStateDTO.disabled,
|
||||
severity: 'info',
|
||||
labels: { team: 'data' },
|
||||
metric: 'http_server_duration',
|
||||
unit: 's',
|
||||
target: 3600,
|
||||
},
|
||||
{
|
||||
alert: 'Search p99 above budget',
|
||||
alertType: RuletypesAlertTypeDTO.TRACES_BASED_ALERT,
|
||||
state: RuletypesAlertStateDTO.inactive,
|
||||
severity: 'info',
|
||||
labels: { team: 'search', env: 'staging' },
|
||||
metric: 'http_server_duration',
|
||||
unit: 'ms',
|
||||
target: 800,
|
||||
},
|
||||
{
|
||||
alert: 'Cache hit ratio dropped',
|
||||
alertType: RuletypesAlertTypeDTO.METRIC_BASED_ALERT,
|
||||
state: RuletypesAlertStateDTO.inactive,
|
||||
severity: 'info',
|
||||
labels: { team: 'platform', component: 'redis' },
|
||||
metric: 'system_memory_usage',
|
||||
unit: 'percent',
|
||||
target: 70,
|
||||
},
|
||||
{
|
||||
alert: 'Disk usage on ingesters',
|
||||
alertType: RuletypesAlertTypeDTO.METRIC_BASED_ALERT,
|
||||
state: RuletypesAlertStateDTO.pending,
|
||||
severity: 'critical',
|
||||
labels: { team: 'platform', env: 'prod' },
|
||||
metric: 'system_memory_usage',
|
||||
unit: 'percent',
|
||||
target: 92,
|
||||
},
|
||||
];
|
||||
|
||||
export const RULE_MAX = RULE_SEEDS.length;
|
||||
|
||||
/** The rule `rule-1` resolves to, which is the one the detail stories open. */
|
||||
export const FIRST_RULE_NAME = RULE_SEEDS[0].alert;
|
||||
|
||||
const seedAt = (index: number): RuleSeed =>
|
||||
RULE_SEEDS[index % RULE_SEEDS.length];
|
||||
|
||||
const ruleName = (index: number): string => {
|
||||
const seed = seedAt(index);
|
||||
const round = Math.floor(index / RULE_SEEDS.length);
|
||||
|
||||
return round === 0 ? seed.alert : `${seed.alert} (${round + 1})`;
|
||||
};
|
||||
|
||||
export interface RuleShape {
|
||||
severity: SeverityChoice;
|
||||
state: RuleStateChoice;
|
||||
schema?: AlertSchema;
|
||||
}
|
||||
|
||||
const buildRule = (index: number, shape: RuleShape): RuletypesRuleDTO => {
|
||||
const seed = seedAt(index);
|
||||
const severity = shape.severity === 'mixed' ? seed.severity : shape.severity;
|
||||
const state =
|
||||
shape.state === 'mixed'
|
||||
? seed.state
|
||||
: (shape.state as RuletypesAlertStateDTO);
|
||||
|
||||
return {
|
||||
id: `rule-${index + 1}`,
|
||||
alert: ruleName(index),
|
||||
alertType: seed.alertType,
|
||||
ruleType: RuletypesRuleTypeDTO.threshold_rule,
|
||||
state,
|
||||
disabled: state === RuletypesAlertStateDTO.disabled,
|
||||
condition: condition(seed),
|
||||
labels: { severity, ...seed.labels },
|
||||
annotations: {
|
||||
summary: `${seed.alert} crossed its threshold of ${seed.target}`,
|
||||
description:
|
||||
'The rule threshold is set to {{$threshold}}, and the observed metric value is {{$value}}',
|
||||
},
|
||||
evalWindow: '5m0s',
|
||||
frequency: '1m0s',
|
||||
createdAt: ago((index + 3) * DAY),
|
||||
updatedAt: ago((index + 1) * HOUR),
|
||||
createdBy: 'ada@signoz.io',
|
||||
updatedBy: 'grace@signoz.io',
|
||||
schemaVersion:
|
||||
shape.schema === 'classic' ? undefined : NEW_ALERT_SCHEMA_VERSION,
|
||||
version: 'v5',
|
||||
source: 'http://localhost/alerts',
|
||||
preferredChannels: ['ops-slack'],
|
||||
notificationSettings: {
|
||||
groupBy: ['alertname'],
|
||||
usePolicy: false,
|
||||
renotify: { enabled: false, interval: '30m0s' },
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const alertRulesResponse = (
|
||||
count: number,
|
||||
shape: RuleShape,
|
||||
): ListRules200 => ({
|
||||
status: 'success',
|
||||
data: Array.from({ length: count }, (_unused, index) =>
|
||||
buildRule(index, shape),
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* The detail endpoint answers for whatever id the URL carries, so a story keeps
|
||||
* rendering after a row click lands on a rule the list never returned.
|
||||
*/
|
||||
export const alertRuleByIdResponse = (
|
||||
ruleId: string,
|
||||
shape: RuleShape,
|
||||
): GetRuleByID200 => {
|
||||
const index = Math.max(Number.parseInt(ruleId.replace(/\D/g, ''), 10) - 1, 0);
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
data: { ...buildRule(Number.isNaN(index) ? 0 : index, shape), id: ruleId },
|
||||
};
|
||||
};
|
||||
|
||||
interface TriggeredSeed {
|
||||
alertname: string;
|
||||
severity: AlertSeverity;
|
||||
labels: Record<string, string>;
|
||||
summary: string;
|
||||
firingForMinutes: number;
|
||||
}
|
||||
|
||||
const TRIGGERED_SEEDS: TriggeredSeed[] = [
|
||||
{
|
||||
alertname: 'Checkout API latency above 2s',
|
||||
severity: 'critical',
|
||||
labels: { service: 'checkout', env: 'prod', team: 'checkout' },
|
||||
summary: 'p99 latency is 3.4s against a 2s budget',
|
||||
firingForMinutes: 14,
|
||||
},
|
||||
{
|
||||
alertname: 'Payment service error rate',
|
||||
severity: 'critical',
|
||||
labels: { service: 'payments', env: 'prod', team: 'payments' },
|
||||
summary: '7.2% of payment spans failed in the last 5 minutes',
|
||||
firingForMinutes: 42,
|
||||
},
|
||||
{
|
||||
alertname: 'Node CPU saturation',
|
||||
severity: 'warning',
|
||||
labels: { service: 'kubelet', env: 'prod', team: 'platform' },
|
||||
summary: 'CPU utilisation held above 85% on 3 nodes',
|
||||
firingForMinutes: 128,
|
||||
},
|
||||
{
|
||||
alertname: 'Kafka consumer lag',
|
||||
severity: 'error',
|
||||
labels: { service: 'events-consumer', env: 'prod', team: 'platform' },
|
||||
summary: 'Lag is 24k messages and climbing',
|
||||
firingForMinutes: 300,
|
||||
},
|
||||
{
|
||||
alertname: 'Unhandled exceptions in web',
|
||||
severity: 'error',
|
||||
labels: { service: 'web', env: 'prod', team: 'web' },
|
||||
summary: '31 unhandled exceptions in the last 10 minutes',
|
||||
firingForMinutes: 8,
|
||||
},
|
||||
{
|
||||
alertname: 'Auth service 5xx spike',
|
||||
severity: 'error',
|
||||
labels: { service: 'auth', env: 'prod', team: 'identity' },
|
||||
summary: '5xx rate is 22 requests per second',
|
||||
firingForMinutes: 55,
|
||||
},
|
||||
{
|
||||
alertname: 'Search p99 above budget',
|
||||
severity: 'info',
|
||||
labels: { service: 'search', env: 'staging', team: 'search' },
|
||||
summary: 'p99 is 940ms against an 800ms budget',
|
||||
firingForMinutes: 1_450,
|
||||
},
|
||||
{
|
||||
alertname: 'Cache hit ratio dropped',
|
||||
severity: 'info',
|
||||
labels: { service: 'redis', env: 'prod', team: 'platform' },
|
||||
summary: 'Hit ratio fell to 61%',
|
||||
firingForMinutes: 620,
|
||||
},
|
||||
{
|
||||
alertname: 'Disk usage on ingesters',
|
||||
severity: 'critical',
|
||||
labels: { service: 'ingester', env: 'prod', team: 'platform' },
|
||||
summary: 'Two ingesters are above 92% disk',
|
||||
firingForMinutes: 3,
|
||||
},
|
||||
{
|
||||
alertname: 'Postgres connections near limit',
|
||||
severity: 'warning',
|
||||
labels: { service: 'postgres', env: 'prod', team: 'platform' },
|
||||
summary: '91% of the connection pool is in use',
|
||||
firingForMinutes: 240,
|
||||
},
|
||||
{
|
||||
alertname: 'Ingest pipeline dropped logs',
|
||||
severity: 'warning',
|
||||
labels: { service: 'otel-collector', env: 'prod', team: 'platform' },
|
||||
summary: 'The collector dropped 4.1k log records',
|
||||
firingForMinutes: 76,
|
||||
},
|
||||
{
|
||||
alertname: 'Nightly batch job overran',
|
||||
severity: 'info',
|
||||
labels: { service: 'batch-runner', env: 'prod', team: 'data' },
|
||||
summary: 'The nightly job ran 41 minutes past its window',
|
||||
firingForMinutes: 900,
|
||||
},
|
||||
];
|
||||
|
||||
export const TRIGGERED_ALERT_MAX = TRIGGERED_SEEDS.length;
|
||||
|
||||
/** A resolved alert is one alertmanager still lists with an `endsAt` in the past. */
|
||||
export const TRIGGERED_STATES = ['mixed', 'active', 'suppressed'] as const;
|
||||
|
||||
export type TriggeredState = (typeof TRIGGERED_STATES)[number];
|
||||
|
||||
export interface TriggeredShape {
|
||||
severity: SeverityChoice;
|
||||
state: TriggeredState;
|
||||
}
|
||||
|
||||
const buildTriggeredAlert = (
|
||||
index: number,
|
||||
shape: TriggeredShape,
|
||||
): AlertmanagertypesDeprecatedGettableAlertDTO => {
|
||||
const seed = TRIGGERED_SEEDS[index % TRIGGERED_SEEDS.length];
|
||||
const severity = shape.severity === 'mixed' ? seed.severity : shape.severity;
|
||||
const mixedState = index % 4 === 3 ? 'suppressed' : 'active';
|
||||
const state = shape.state === 'mixed' ? mixedState : shape.state;
|
||||
const ruleId = `rule-${(index % RULE_MAX) + 1}`;
|
||||
|
||||
return {
|
||||
fingerprint: `fingerprint-${index + 1}`,
|
||||
startsAt: ago(seed.firingForMinutes * MINUTE),
|
||||
endsAt: new Date(Date.now() + HOUR).toISOString(),
|
||||
generatorURL: `http://localhost/alerts/overview?ruleId=${ruleId}`,
|
||||
labels: {
|
||||
alertname: seed.alertname,
|
||||
severity,
|
||||
ruleId,
|
||||
...seed.labels,
|
||||
},
|
||||
annotations: {
|
||||
summary: seed.summary,
|
||||
description: `${seed.alertname} has been firing for ${seed.firingForMinutes} minutes`,
|
||||
},
|
||||
status: {
|
||||
state,
|
||||
silencedBy: state === 'suppressed' ? ['silence-1'] : [],
|
||||
inhibitedBy: [],
|
||||
},
|
||||
receivers: ['ops-slack'],
|
||||
};
|
||||
};
|
||||
|
||||
export const triggeredAlertsResponse = (
|
||||
count: number,
|
||||
shape: TriggeredShape,
|
||||
): GetAlerts200 => ({
|
||||
status: 'success',
|
||||
data: Array.from({ length: count }, (_unused, index) =>
|
||||
buildTriggeredAlert(index, shape),
|
||||
),
|
||||
});
|
||||
|
||||
interface ChannelSeed {
|
||||
name: string;
|
||||
type: ChannelType;
|
||||
/** The alertmanager receiver the channel serialises into its `data` field. */
|
||||
receiver: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const CHANNEL_SEEDS: ChannelSeed[] = [
|
||||
{
|
||||
name: 'ops-slack',
|
||||
type: 'slack',
|
||||
receiver: {
|
||||
slack_configs: [
|
||||
{
|
||||
api_url: 'https://hooks.slack.com/services/T000/B000/story-token',
|
||||
channel: '#ops-alerts',
|
||||
send_resolved: true,
|
||||
title: '[{{ .Status | toUpper }}] {{ .CommonLabels.alertname }}',
|
||||
text: '{{ range .Alerts -}}*Alert:* {{ .Labels.alertname }}\n{{ end }}',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'oncall-pagerduty',
|
||||
type: 'pagerduty',
|
||||
receiver: {
|
||||
pagerduty_configs: [
|
||||
{
|
||||
routing_key: 'story-routing-key',
|
||||
send_resolved: true,
|
||||
client: 'SigNoz',
|
||||
description: '{{ .CommonLabels.alertname }}',
|
||||
severity: 'critical',
|
||||
details: { firing: '{{ .Alerts.Firing | len }}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'platform-webhook',
|
||||
type: 'webhook',
|
||||
receiver: {
|
||||
webhook_configs: [
|
||||
{
|
||||
url: 'https://hooks.example.com/signoz',
|
||||
send_resolved: true,
|
||||
http_config: {
|
||||
basic_auth: { username: 'signoz', password: 'story-password' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'sre-email',
|
||||
type: 'email',
|
||||
receiver: {
|
||||
email_configs: [
|
||||
{
|
||||
to: 'sre@signoz.io',
|
||||
send_resolved: true,
|
||||
html: '<p>{{ .CommonLabels.alertname }}</p>',
|
||||
headers: { Subject: '[SigNoz] {{ .CommonLabels.alertname }}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'incident-opsgenie',
|
||||
type: 'opsgenie',
|
||||
receiver: {
|
||||
opsgenie_configs: [
|
||||
{
|
||||
api_key: 'story-api-key',
|
||||
send_resolved: true,
|
||||
message: '{{ .CommonLabels.alertname }}',
|
||||
description: '{{ .CommonLabels.alertname }} is firing',
|
||||
priority: 'P2',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'eng-msteams',
|
||||
type: 'msteams',
|
||||
receiver: {
|
||||
msteamsv2_configs: [
|
||||
{
|
||||
webhook_url: 'https://signoz.webhook.office.com/story',
|
||||
send_resolved: true,
|
||||
title: '{{ .CommonLabels.alertname }}',
|
||||
text: '{{ .CommonAnnotations.summary }}',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'support-googlechat',
|
||||
type: 'googlechat',
|
||||
receiver: {
|
||||
googlechat_configs: [
|
||||
{
|
||||
webhook_url: 'https://chat.googleapis.com/v1/spaces/story',
|
||||
send_resolved: true,
|
||||
title: '{{ .CommonLabels.alertname }}',
|
||||
text: '{{ .CommonAnnotations.summary }}',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'tickets-jira',
|
||||
type: 'jira',
|
||||
receiver: {
|
||||
jira_configs: [
|
||||
{
|
||||
site: 'https://signoz.atlassian.net',
|
||||
project: 'ALERT',
|
||||
issue_type: 'Task',
|
||||
send_resolved: true,
|
||||
summary: '{{ .CommonLabels.alertname }}',
|
||||
description: '{{ .CommonAnnotations.summary }}',
|
||||
priority: 'High',
|
||||
labels: ['signoz', 'platform'],
|
||||
resolve_transition: 'Done',
|
||||
reopen_transition: 'Reopen',
|
||||
wont_fix_resolution: "Won't Do",
|
||||
reopen_duration: '72h',
|
||||
// The form reads the credentials off the basic auth block rather than
|
||||
// off the config itself, which is where the backend stores them.
|
||||
http_config: {
|
||||
basic_auth: {
|
||||
username: 'alerts@signoz.io',
|
||||
password: 'story-api-token',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'oncall-jsmops',
|
||||
type: 'jsmops',
|
||||
receiver: {
|
||||
jsmops_configs: [
|
||||
{
|
||||
api_key: 'story-jsm-api-key',
|
||||
send_resolved: true,
|
||||
message: '{{ .CommonLabels.alertname }}',
|
||||
description: '{{ .CommonAnnotations.summary }}',
|
||||
priority: 'P2',
|
||||
// Stored comma-separated, which is what the form splits into chips.
|
||||
tags: 'signoz,platform',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'oncall-incidentio',
|
||||
type: 'incidentio',
|
||||
receiver: {
|
||||
incidentio_configs: [
|
||||
{
|
||||
url: 'https://api.incident.io/v2/alert_events/http/story-source-config-id',
|
||||
token: 'story-source-token',
|
||||
send_resolved: true,
|
||||
title: '{{ .CommonLabels.alertname }}',
|
||||
description: '{{ .CommonAnnotations.summary }}',
|
||||
metadata: { team: 'platform' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const CHANNEL_MAX = CHANNEL_SEEDS.length;
|
||||
|
||||
const buildChannel = (index: number): Channels => {
|
||||
const seed = CHANNEL_SEEDS[index % CHANNEL_SEEDS.length];
|
||||
|
||||
return {
|
||||
id: String(index + 1),
|
||||
name: seed.name,
|
||||
type: seed.type,
|
||||
created_at: ago((index + 10) * DAY),
|
||||
updated_at: ago((index + 1) * DAY),
|
||||
data: JSON.stringify({ name: seed.name, ...seed.receiver }),
|
||||
};
|
||||
};
|
||||
|
||||
export const channelsResponse = (
|
||||
count: number,
|
||||
): { status: string; data: Channels[] } => ({
|
||||
status: 'success',
|
||||
data: Array.from({ length: count }, (_unused, index) => buildChannel(index)),
|
||||
});
|
||||
|
||||
/** Channel names the alert form and the routing policies pick from. */
|
||||
export const channelNames = (count: number): string[] =>
|
||||
channelsResponse(count).data.map((channel) => channel.name);
|
||||
|
||||
export const channelResponse = (
|
||||
id: string,
|
||||
type: ChannelType,
|
||||
): { status: string; data: Channels } => {
|
||||
const index = CHANNEL_SEEDS.findIndex((seed) => seed.type === type);
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
data: { ...buildChannel(Math.max(index, 0)), id },
|
||||
};
|
||||
};
|
||||
|
||||
export const CHANNEL_ACTION_OUTCOMES = ['succeeds', 'fails'] as const;
|
||||
|
||||
export type ChannelActionOutcome = (typeof CHANNEL_ACTION_OUTCOMES)[number];
|
||||
|
||||
export const channelActionError = (): RenderErrorResponseDTO => ({
|
||||
status: 'error',
|
||||
error: {
|
||||
code: 'STORYBOOK_FAILURE',
|
||||
type: 'internal',
|
||||
message: 'Storybook forced channel failure',
|
||||
url: '',
|
||||
errors: [],
|
||||
suggestions: [],
|
||||
},
|
||||
});
|
||||
@@ -1,116 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { rest } from 'msw';
|
||||
import set from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
import { countControl, toggleControl } from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { fieldValuesResponse } from '@/storybook/msw/__story_mockdata__/fields';
|
||||
|
||||
import {
|
||||
EXCEPTION_CATALOGUE_SIZE,
|
||||
EXCEPTION_QUICK_FILTER_CAP,
|
||||
exceptionAttributeKeysResponse,
|
||||
exceptionAttributeValuesResponse,
|
||||
exceptionFieldKeysResponse,
|
||||
exceptionQuickFiltersResponse,
|
||||
exceptionRows,
|
||||
exceptionTotal,
|
||||
type ListErrorsBody,
|
||||
} from './__story_mockdata__/exceptions';
|
||||
|
||||
const LIST = 'Exceptions · list';
|
||||
const FILTERS = 'Exceptions · filters';
|
||||
|
||||
export const exceptionsMocks = defineStoryMocks({
|
||||
controls: {
|
||||
exceptions: countControl('Exception groups', {
|
||||
group: LIST,
|
||||
description:
|
||||
'Groups the endpoint holds. The table asks for one page at a time and pages against `/countErrors`, so a count past ten paginates.',
|
||||
value: EXCEPTION_CATALOGUE_SIZE,
|
||||
max: EXCEPTION_CATALOGUE_SIZE,
|
||||
}),
|
||||
quickFilters: countControl('Quick filters', {
|
||||
group: FILTERS,
|
||||
description:
|
||||
'Filters the org has configured for exceptions. At 0 the panel has nothing to render, which is what a workspace that never customised them shows.',
|
||||
value: 6,
|
||||
max: EXCEPTION_QUICK_FILTER_CAP,
|
||||
}),
|
||||
filterPanel: toggleControl('Quick filters panel', {
|
||||
group: FILTERS,
|
||||
description:
|
||||
'Whether the panel starts expanded. The page keeps this in local storage, so it survives the collapse arrow being clicked.',
|
||||
value: true,
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.post(
|
||||
'http://localhost/api/v1/listErrors',
|
||||
response.json(async (req) => {
|
||||
const body = (await req.json()) as ListErrorsBody;
|
||||
|
||||
return exceptionRows(values.exceptions, body);
|
||||
}),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v1/countErrors',
|
||||
response.json(async (req) => {
|
||||
const body = (await req.json()) as ListErrorsBody;
|
||||
|
||||
return exceptionTotal(values.exceptions, body);
|
||||
}),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/quick_filters/:source',
|
||||
response.json(() => exceptionQuickFiltersResponse(values.quickFilters)),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/fields/keys',
|
||||
response.json((req) =>
|
||||
exceptionFieldKeysResponse(req.url.searchParams.get('searchText')),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/fields/values',
|
||||
response.json((req) =>
|
||||
fieldValuesResponse(
|
||||
exceptionAttributeValuesResponse(req.url.searchParams.get('name'), null)
|
||||
.data.stringAttributeValues ?? [],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v3/autocomplete/attribute_keys',
|
||||
response.json((req) =>
|
||||
exceptionAttributeKeysResponse(req.url.searchParams.get('searchText')),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v3/autocomplete/attribute_values',
|
||||
response.json((req) =>
|
||||
exceptionAttributeValuesResponse(
|
||||
req.url.searchParams.get('attributeKey'),
|
||||
req.url.searchParams.get('searchText'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
effect: (values) => {
|
||||
set(LOCALSTORAGE.SHOW_EXCEPTIONS_QUICK_FILTERS, String(values.filterPanel));
|
||||
// The quick-filter settings announcement is a first-run popover that covers
|
||||
// the toolbar until it is closed, and closing it is what the app persists.
|
||||
set(LOCALSTORAGE.QUICK_FILTERS_SETTINGS_ANNOUNCEMENT, 'false');
|
||||
},
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { exceptionsMocks } from './AllErrors.stories.mocks';
|
||||
import AllErrors from '../index';
|
||||
|
||||
type AllErrorsArgs = PageStoryArgs<typeof exceptionsMocks>;
|
||||
|
||||
const pageStory = storyMocks(exceptionsMocks, {
|
||||
route: ROUTES.ALL_ERROR,
|
||||
layout: 'app',
|
||||
});
|
||||
|
||||
/**
|
||||
* Exception groups over the period, with the quick filters and the filter panel
|
||||
* the explorers share.
|
||||
*
|
||||
* Route: `/exceptions`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Exceptions/List',
|
||||
tags: ['play'],
|
||||
component: AllErrors,
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<AllErrorsArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<AllErrorsArgs>;
|
||||
|
||||
/** The page fetches before it renders a row, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
const openQuickFiltersSettings = async (): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await screen.findByTestId('settings-icon', undefined, untilLoaded),
|
||||
);
|
||||
await screen.findByText('Edit quick filters', undefined, untilLoaded);
|
||||
};
|
||||
|
||||
/**
|
||||
* Every exception group in the window: the org's quick filters down the left, the
|
||||
* resource filter and the time range above, and the table sorted by application
|
||||
* with each type linking to its detail page.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/**
|
||||
* A workspace with nothing thrown in the window and no quick filters configured,
|
||||
* so the table and the filter panel both show their empty states.
|
||||
*/
|
||||
export const NoExceptions: Story = {
|
||||
args: { exceptions: 0, quickFilters: 0 },
|
||||
};
|
||||
|
||||
/** The query area after the quick-filters panel is collapsed. */
|
||||
export const FiltersCollapsed: Story = {
|
||||
args: { filterPanel: false },
|
||||
};
|
||||
|
||||
/** The table mid-query, with the cancel action the toolbar offers while it runs. */
|
||||
export const Loading: Story = {
|
||||
args: { dataState: 'loading' },
|
||||
};
|
||||
|
||||
/**
|
||||
* What cancelling a running query leaves behind: the table is dropped for a
|
||||
* placeholder until Run Query is pressed again.
|
||||
*/
|
||||
export const QueryCancelled: Story = {
|
||||
args: { dataState: 'loading' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByText(/cancel/i, undefined, untilLoaded),
|
||||
);
|
||||
|
||||
await screen.findByText(/query cancelled/i, undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/** The editable quick-filter settings panel. */
|
||||
export const QuickFiltersSettings: Story = {
|
||||
play: openQuickFiltersSettings,
|
||||
};
|
||||
|
||||
/** Settings with an unsaved filter removal and the fixed action footer. */
|
||||
export const QuickFiltersSettingsDirty: Story = {
|
||||
play: 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' });
|
||||
},
|
||||
};
|
||||
@@ -1,341 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import {
|
||||
QuickfiltertypesSourceDTO,
|
||||
TelemetrytypesFieldContextDTO,
|
||||
TelemetrytypesFieldDataTypeDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
GetFieldsKeys200,
|
||||
GetQuickFilters200,
|
||||
TelemetrytypesTelemetryFieldKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
type BaseAutocompleteData,
|
||||
DataTypes,
|
||||
type IQueryAutocompleteResponse,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import type { Exception, Order, OrderBy } from 'types/api/errors/getAll';
|
||||
import type { IAttributeValuesResponse } from 'types/api/queryBuilder/getAttributesValues';
|
||||
|
||||
import { fieldKeysResponse } from '@/storybook/msw/__story_mockdata__/fields';
|
||||
import { quickFiltersResponse } from '@/storybook/msw/__story_mockdata__/quickFilters';
|
||||
|
||||
export interface ExceptionShape {
|
||||
exceptionType: string;
|
||||
exceptionMessage: string;
|
||||
exceptionCount: number;
|
||||
serviceName: string;
|
||||
groupID: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered by count, the way the backend answers an unsorted request, so a slice
|
||||
* keeps a spread of services, languages and counts.
|
||||
*/
|
||||
export const EXCEPTION_CATALOGUE: ExceptionShape[] = [
|
||||
{
|
||||
exceptionType: '*errors.errorString',
|
||||
exceptionMessage: 'redis timeout',
|
||||
exceptionCount: 2510,
|
||||
serviceName: 'redis-manual',
|
||||
groupID: '511b9c91a92b9c5166ecb77235f5743b',
|
||||
},
|
||||
{
|
||||
exceptionType: 'ConnectionError',
|
||||
exceptionMessage:
|
||||
"HTTPConnectionPool(host='payments', port=8080): Read timed out. (read timeout=2)",
|
||||
exceptionCount: 1834,
|
||||
serviceName: 'checkout',
|
||||
groupID: '6a1f0c2d8e4b7a935c10d4f6b8e2a771',
|
||||
},
|
||||
{
|
||||
exceptionType: 'java.net.SocketTimeoutException',
|
||||
exceptionMessage: 'Read timed out',
|
||||
exceptionCount: 1290,
|
||||
serviceName: 'payment-java',
|
||||
groupID: 'c93d2f81a0b64e7f95d31c8e7a4b0d26',
|
||||
},
|
||||
{
|
||||
exceptionType: 'TypeError',
|
||||
exceptionMessage: "Cannot read properties of undefined (reading 'id')",
|
||||
exceptionCount: 964,
|
||||
serviceName: 'frontend',
|
||||
groupID: '1d7e4b93c05f8a26e91b4d70c3f85a12',
|
||||
},
|
||||
{
|
||||
exceptionType: 'psycopg2.OperationalError',
|
||||
exceptionMessage: 'could not connect to server: Connection refused',
|
||||
exceptionCount: 742,
|
||||
serviceName: 'orders',
|
||||
groupID: 'ab3c5d7e9f10234567890bcdef123456',
|
||||
},
|
||||
{
|
||||
exceptionType: 'KeyError',
|
||||
exceptionMessage: "'customer_id'",
|
||||
exceptionCount: 611,
|
||||
serviceName: 'cart',
|
||||
groupID: '77e0a1b2c3d4e5f60718293a4b5c6d7e',
|
||||
},
|
||||
{
|
||||
exceptionType: '*net.OpError',
|
||||
exceptionMessage: 'dial tcp 10.0.4.11:9092: connect: connection refused',
|
||||
exceptionCount: 508,
|
||||
serviceName: 'kafka-producer',
|
||||
groupID: '2f4a6c8e0b1d3f5709a2b4c6d8e0f135',
|
||||
},
|
||||
{
|
||||
exceptionType: 'ValidationError',
|
||||
exceptionMessage:
|
||||
'1 validation error for Order\nquantity: value is not a valid integer',
|
||||
exceptionCount: 402,
|
||||
serviceName: 'orders',
|
||||
groupID: '9b8a7c6d5e4f30211f2e3d4c5b6a7988',
|
||||
},
|
||||
{
|
||||
exceptionType: 'java.lang.NullPointerException',
|
||||
exceptionMessage: 'Cannot invoke "String.length()" because "sku" is null',
|
||||
exceptionCount: 355,
|
||||
serviceName: 'inventory-java',
|
||||
groupID: '3c1e5a79b2d4f68008a1c3e5b7d9f012',
|
||||
},
|
||||
{
|
||||
exceptionType: 'RuntimeError',
|
||||
exceptionMessage: 'Event loop is closed',
|
||||
exceptionCount: 287,
|
||||
serviceName: 'notifications',
|
||||
groupID: 'e5d4c3b2a1908f7e6d5c4b3a29180706',
|
||||
},
|
||||
{
|
||||
exceptionType: 'sqlalchemy.exc.IntegrityError',
|
||||
exceptionMessage:
|
||||
'duplicate key value violates unique constraint "orders_pkey"',
|
||||
exceptionCount: 213,
|
||||
serviceName: 'orders',
|
||||
groupID: '0a1b2c3d4e5f60718293a4b5c6d7e8f9',
|
||||
},
|
||||
{
|
||||
exceptionType: 'AxiosError',
|
||||
exceptionMessage: 'Request failed with status code 503',
|
||||
exceptionCount: 168,
|
||||
serviceName: 'frontend',
|
||||
groupID: '4d6f8a0c2e4b6d8f0a1c3e5b7d9f1113',
|
||||
},
|
||||
{
|
||||
exceptionType: '*fmt.wrapError',
|
||||
exceptionMessage: 'publish message: context deadline exceeded',
|
||||
exceptionCount: 96,
|
||||
serviceName: 'kafka-producer',
|
||||
groupID: 'bb0a99887766554433221100ffeeddcc',
|
||||
},
|
||||
{
|
||||
exceptionType: 'RedisTimeoutError',
|
||||
exceptionMessage: 'Command timed out after 1000ms',
|
||||
exceptionCount: 41,
|
||||
serviceName: 'session-store',
|
||||
groupID: '8e7d6c5b4a39281706f5e4d3c2b1a099',
|
||||
},
|
||||
];
|
||||
|
||||
export const EXCEPTION_CATALOGUE_SIZE = EXCEPTION_CATALOGUE.length;
|
||||
|
||||
export const EXCEPTION_SERVICE_NAMES = Array.from(
|
||||
new Set(EXCEPTION_CATALOGUE.map(({ serviceName }) => serviceName)),
|
||||
);
|
||||
|
||||
export const EXCEPTION_TYPES = EXCEPTION_CATALOGUE.map(
|
||||
({ exceptionType }) => exceptionType,
|
||||
);
|
||||
|
||||
/** The nanoseconds every row carries, so `firstSeen` stays the table's row key. */
|
||||
const SUBSECOND_NANOS = '797616374';
|
||||
|
||||
/**
|
||||
* `lastSeen` and `firstSeen` come back as RFC 3339 with nanoseconds, which is
|
||||
* what `getNanoSeconds` parses to build the link to the detail page.
|
||||
*/
|
||||
const seenAt = (atMs: number): string =>
|
||||
`${new Date(atMs).toISOString().slice(0, 19)}.${SUBSECOND_NANOS}Z`;
|
||||
|
||||
export interface ListErrorsBody {
|
||||
start: string;
|
||||
end: string;
|
||||
order?: Order;
|
||||
orderParam?: OrderBy;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
exceptionType?: string;
|
||||
serviceName?: string;
|
||||
}
|
||||
|
||||
const MINUTE_MS = 60 * 1000;
|
||||
|
||||
const contains = (value: string, search: string | undefined): boolean =>
|
||||
!search || value.toLowerCase().includes(search.toLowerCase());
|
||||
|
||||
const compare = (left: Exception, right: Exception, by: OrderBy): number => {
|
||||
if (by === 'exceptionCount') {
|
||||
return left.exceptionCount - right.exceptionCount;
|
||||
}
|
||||
|
||||
if (by === 'lastSeen' || by === 'firstSeen') {
|
||||
return Date.parse(left[by]) - Date.parse(right[by]);
|
||||
}
|
||||
|
||||
return left[by].localeCompare(right[by]);
|
||||
};
|
||||
|
||||
/**
|
||||
* The exception groups the endpoint holds for one request. The timestamps land
|
||||
* inside the window the time picker asked for, and the sorting and the column
|
||||
* searches are applied here: the table writes both into the query string and
|
||||
* sends them along, rather than sorting or filtering what it already has.
|
||||
*/
|
||||
const selectExceptions = (count: number, body: ListErrorsBody): Exception[] => {
|
||||
const endMs = Number(body.end) / 1e6;
|
||||
|
||||
const rows: Exception[] = EXCEPTION_CATALOGUE.slice(0, count).map(
|
||||
(exception, index) => ({
|
||||
...exception,
|
||||
lastSeen: seenAt(endMs - index * MINUTE_MS),
|
||||
firstSeen: seenAt(endMs - (index + 1) * 30 * MINUTE_MS),
|
||||
}),
|
||||
);
|
||||
|
||||
const filtered = rows.filter(
|
||||
(row) =>
|
||||
contains(row.exceptionType, body.exceptionType) &&
|
||||
contains(row.serviceName, body.serviceName),
|
||||
);
|
||||
|
||||
const orderParam = body.orderParam ?? 'serviceName';
|
||||
const direction = body.order === 'descending' ? -1 : 1;
|
||||
|
||||
return filtered.sort(
|
||||
(left, right) => direction * compare(left, right, orderParam),
|
||||
);
|
||||
};
|
||||
|
||||
export const exceptionRows = (
|
||||
count: number,
|
||||
body: ListErrorsBody,
|
||||
): Exception[] => {
|
||||
const offset = body.offset ?? 0;
|
||||
const limit = body.limit ?? 10;
|
||||
|
||||
return selectExceptions(count, body).slice(offset, offset + limit);
|
||||
};
|
||||
|
||||
/** `/countErrors` answers the bare total the table pages against. */
|
||||
export const exceptionTotal = (count: number, body: ListErrorsBody): number =>
|
||||
selectExceptions(count, body).length;
|
||||
|
||||
const { resource, attribute } = TelemetrytypesFieldContextDTO;
|
||||
const { string: stringType, bool } = TelemetrytypesFieldDataTypeDTO;
|
||||
|
||||
const QUICK_FILTERS: TelemetrytypesTelemetryFieldKeyDTO[] = [
|
||||
{ name: 'service.name', fieldDataType: stringType, fieldContext: resource },
|
||||
{ name: 'exceptionType', fieldDataType: stringType, fieldContext: attribute },
|
||||
{
|
||||
name: 'deployment.environment',
|
||||
fieldDataType: stringType,
|
||||
fieldContext: resource,
|
||||
},
|
||||
{
|
||||
name: 'telemetry.sdk.language',
|
||||
fieldDataType: stringType,
|
||||
fieldContext: resource,
|
||||
},
|
||||
{ name: 'host.name', fieldDataType: stringType, fieldContext: resource },
|
||||
{
|
||||
name: 'k8s.namespace.name',
|
||||
fieldDataType: stringType,
|
||||
fieldContext: resource,
|
||||
},
|
||||
{ name: 'os.type', fieldDataType: stringType, fieldContext: resource },
|
||||
{ name: 'hasError', fieldDataType: bool, fieldContext: attribute },
|
||||
];
|
||||
|
||||
export const EXCEPTION_QUICK_FILTER_CAP = QUICK_FILTERS.length;
|
||||
|
||||
export const exceptionQuickFiltersResponse = (
|
||||
count: number,
|
||||
): GetQuickFilters200 =>
|
||||
quickFiltersResponse(
|
||||
QuickfiltertypesSourceDTO.exceptions,
|
||||
QUICK_FILTERS.slice(0, count),
|
||||
);
|
||||
|
||||
const ATTRIBUTE_VALUES: Record<string, string[]> = {
|
||||
'service.name': EXCEPTION_SERVICE_NAMES,
|
||||
exceptionType: EXCEPTION_TYPES,
|
||||
'deployment.environment': ['production', 'staging', 'canary'],
|
||||
'telemetry.sdk.language': ['go', 'python', 'java', 'nodejs'],
|
||||
'host.name': ['ip-10-0-4-11', 'ip-10-0-4-12', 'ip-10-0-5-31'],
|
||||
'k8s.namespace.name': ['default', 'otel-demo', 'payments'],
|
||||
'os.type': ['linux', 'darwin'],
|
||||
};
|
||||
|
||||
export const exceptionAttributeValuesResponse = (
|
||||
attributeKey: string | null,
|
||||
searchText: string | null,
|
||||
): { status: string; data: IAttributeValuesResponse } => {
|
||||
const search = (searchText ?? '').toLowerCase();
|
||||
const values = (ATTRIBUTE_VALUES[attributeKey ?? ''] ?? []).filter((value) =>
|
||||
value.toLowerCase().includes(search),
|
||||
);
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
data: {
|
||||
stringAttributeValues: values,
|
||||
numberAttributeValues: null,
|
||||
boolAttributeValues: attributeKey === 'hasError' ? ['true', 'false'] : null,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const ATTRIBUTE_KEYS: BaseAutocompleteData[] = [
|
||||
...QUICK_FILTERS.map(({ name, fieldDataType, fieldContext }) => ({
|
||||
key: name,
|
||||
dataType: fieldDataType === bool ? DataTypes.bool : DataTypes.String,
|
||||
type: fieldContext === attribute ? 'tag' : String(fieldContext),
|
||||
})),
|
||||
{ key: 'service.namespace', dataType: DataTypes.String, type: 'resource' },
|
||||
{ key: 'k8s.pod.name', dataType: DataTypes.String, type: 'resource' },
|
||||
{ key: 'k8s.cluster.name', dataType: DataTypes.String, type: 'resource' },
|
||||
{ key: 'cloud.region', dataType: DataTypes.String, type: 'resource' },
|
||||
];
|
||||
|
||||
export const exceptionAttributeKeysResponse = (
|
||||
searchText: string | null,
|
||||
): { status: string; data: IQueryAutocompleteResponse } => {
|
||||
const search = (searchText ?? '').toLowerCase();
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
data: {
|
||||
attributeKeys: ATTRIBUTE_KEYS.filter(({ key }) =>
|
||||
key.toLowerCase().includes(search),
|
||||
),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** The quick filter settings panel lists its "other filters" from `/fields/keys`. */
|
||||
export const exceptionFieldKeysResponse = (
|
||||
searchText: string | null,
|
||||
): GetFieldsKeys200 => {
|
||||
const search = (searchText ?? '').toLowerCase();
|
||||
|
||||
return fieldKeysResponse(
|
||||
ATTRIBUTE_KEYS.map(({ key }) => key).filter((key) =>
|
||||
key.toLowerCase().includes(search),
|
||||
),
|
||||
{ signal: TelemetrytypesSignalDTO.logs, fieldContext: resource },
|
||||
);
|
||||
};
|
||||
@@ -1,406 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import type { TelemetrytypesTelemetryFieldKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
QuickfiltertypesSourceDTO,
|
||||
TelemetrytypesFieldContextDTO,
|
||||
TelemetrytypesFieldDataTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { VIEWS } from 'container/ApiMonitoring/Explorer/Domains/DomainDetails/constants';
|
||||
import { DEFAULT_PARAMS } from 'container/ApiMonitoring/queryParams';
|
||||
import type { Time } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { rest } from 'msw';
|
||||
import type { AppState } from 'store/reducers';
|
||||
import type { Props as ListOverviewRequest } from 'types/api/thirdPartyApis/listOverview';
|
||||
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
|
||||
|
||||
import {
|
||||
choiceControl,
|
||||
countControl,
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { fieldValuesResponse } from '@/storybook/msw/__story_mockdata__/fields';
|
||||
import { quickFiltersResponse } from '@/storybook/msw/__story_mockdata__/quickFilters';
|
||||
|
||||
import {
|
||||
allEndpointsResponse,
|
||||
DEPENDENT_SERVICE_MAX,
|
||||
dependentServicesResponse,
|
||||
DOMAIN_MAX,
|
||||
domainListResponse,
|
||||
domainMetricsResponse,
|
||||
type DrawerDomain,
|
||||
DRAWER_DOMAINS,
|
||||
drawerDomainName,
|
||||
endpointDropdownResponse,
|
||||
ENDPOINT_MAX,
|
||||
endpointMetricsResponse,
|
||||
endpointUrl,
|
||||
groupByAttributeKeys,
|
||||
overTimeChartResponse,
|
||||
STATUS_CODE_MAX,
|
||||
statusCodeChartResponse,
|
||||
statusCodeTableResponse,
|
||||
TOP_ERROR_MAX,
|
||||
topErrorsResponse,
|
||||
} from './__story_mockdata__/apiMonitoring';
|
||||
|
||||
const DOMAINS = 'External APIs · domains';
|
||||
const DRAWER = 'External APIs · drawer';
|
||||
const FILTERS = 'External APIs · filters';
|
||||
|
||||
const QUICK_FILTERS: TelemetrytypesTelemetryFieldKeyDTO[] = [
|
||||
{
|
||||
name: 'deployment.environment',
|
||||
fieldDataType: TelemetrytypesFieldDataTypeDTO.string,
|
||||
fieldContext: TelemetrytypesFieldContextDTO.resource,
|
||||
},
|
||||
{
|
||||
name: 'service.name',
|
||||
fieldDataType: TelemetrytypesFieldDataTypeDTO.string,
|
||||
fieldContext: TelemetrytypesFieldContextDTO.resource,
|
||||
},
|
||||
{
|
||||
name: 'rpc.method',
|
||||
fieldDataType: TelemetrytypesFieldDataTypeDTO.string,
|
||||
fieldContext: TelemetrytypesFieldContextDTO.attribute,
|
||||
},
|
||||
];
|
||||
|
||||
const QUICK_FILTER_VALUES: Record<string, string[]> = {
|
||||
'deployment.environment': ['production', 'staging'],
|
||||
'service.name': ['checkout', 'payments', 'cart'],
|
||||
'rpc.method': ['GET', 'POST'],
|
||||
};
|
||||
|
||||
const DRAWER_STATES = [
|
||||
'closed',
|
||||
'all-endpoints',
|
||||
'endpoint-stats',
|
||||
'top-errors',
|
||||
] as const;
|
||||
|
||||
type DrawerState = (typeof DRAWER_STATES)[number];
|
||||
|
||||
const VIEW_OF: Record<Exclude<DrawerState, 'closed'>, VIEWS> = {
|
||||
'all-endpoints': VIEWS.ALL_ENDPOINTS,
|
||||
'endpoint-stats': VIEWS.ENDPOINT_STATS,
|
||||
'top-errors': VIEWS.TOP_ERRORS,
|
||||
};
|
||||
|
||||
const RELATIVE_TIME: Time = '30m';
|
||||
|
||||
const THIRTY_MINUTES_IN_MS = 30 * 60 * 1000;
|
||||
|
||||
const NANOSECONDS_IN_MS = 1_000_000;
|
||||
|
||||
/**
|
||||
* `globalTime` derives its window from `window.location.pathname`, which in a
|
||||
* story is the preview's rather than the page's, so without a seeded range the
|
||||
* time picker and the queries would disagree about the window.
|
||||
*/
|
||||
const timeRange = (): Partial<AppState> => {
|
||||
const now = Date.now();
|
||||
|
||||
return {
|
||||
globalTime: {
|
||||
minTime: (now - THIRTY_MINUTES_IN_MS) * NANOSECONDS_IN_MS,
|
||||
maxTime: now * NANOSECONDS_IN_MS,
|
||||
loading: false,
|
||||
selectedTime: RELATIVE_TIME,
|
||||
isAutoRefreshDisabled: false,
|
||||
selectedAutoRefreshInterval: '',
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const serviceFilterItems = {
|
||||
op: 'AND',
|
||||
items: [
|
||||
{
|
||||
id: 'storybook-service-filter',
|
||||
key: { key: 'service.name', dataType: 'string', type: 'resource' },
|
||||
op: '=',
|
||||
value: 'checkout',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
interface RouteValues {
|
||||
drawer: DrawerState;
|
||||
drawerDomain: DrawerDomain;
|
||||
domains: number;
|
||||
serviceFilter: boolean;
|
||||
}
|
||||
|
||||
const apiMonitoringRoute = ({
|
||||
drawer,
|
||||
drawerDomain,
|
||||
domains,
|
||||
serviceFilter,
|
||||
}: RouteValues): string => {
|
||||
const domainName = drawerDomainName(drawerDomain, domains);
|
||||
|
||||
if (drawer === 'closed' || !domainName) {
|
||||
return ROUTES.API_MONITORING;
|
||||
}
|
||||
|
||||
const params = {
|
||||
...DEFAULT_PARAMS,
|
||||
selectedDomain: domainName,
|
||||
selectedView: VIEW_OF[drawer],
|
||||
selectedEndPointName:
|
||||
drawer === 'endpoint-stats' ? endpointUrl(domainName, 0) : '',
|
||||
...(serviceFilter ? { endPointDetailsLocalFilters: serviceFilterItems } : {}),
|
||||
};
|
||||
|
||||
return `${ROUTES.API_MONITORING}?apiMonitoringParams=${encodeURIComponent(
|
||||
JSON.stringify(params),
|
||||
)}`;
|
||||
};
|
||||
|
||||
/** The parts of a query spec the handler tells the page's requests apart by. */
|
||||
interface RequestSpec {
|
||||
name?: string;
|
||||
aggregations?: Array<{ expression?: string }>;
|
||||
groupBy?: Array<{ name: string }>;
|
||||
filter?: { expression?: string };
|
||||
}
|
||||
|
||||
interface QueryShape {
|
||||
names: string[];
|
||||
expressions: string[];
|
||||
groupBy: string[];
|
||||
filters: string[];
|
||||
/** The endpoint the request pinned, when one is selected. */
|
||||
endPointName?: string;
|
||||
}
|
||||
|
||||
const shapeOf = (body: QueryRangeRequestV5): QueryShape => {
|
||||
const specs = (body.compositeQuery?.queries ?? []).map(
|
||||
({ spec }) => spec as RequestSpec,
|
||||
);
|
||||
const filters = specs.map((spec) => spec.filter?.expression ?? '');
|
||||
|
||||
return {
|
||||
names: specs
|
||||
.map((spec) => spec.name)
|
||||
.filter((name): name is string => Boolean(name)),
|
||||
expressions: specs.flatMap((spec) =>
|
||||
(spec.aggregations ?? []).map((aggregation) => aggregation.expression ?? ''),
|
||||
),
|
||||
// Every query in the request repeats the same group-by, so the columns the
|
||||
// response answers with are the distinct ones.
|
||||
groupBy: [
|
||||
...new Set(
|
||||
specs.flatMap((spec) => (spec.groupBy ?? []).map(({ name }) => name)),
|
||||
),
|
||||
],
|
||||
filters,
|
||||
endPointName: filters
|
||||
.map((expression) => /http_url\s*=\s*'([^']+)'/.exec(expression)?.[1])
|
||||
.find(Boolean),
|
||||
};
|
||||
};
|
||||
|
||||
export const apiMonitoringMocks = defineStoryMocks({
|
||||
controls: {
|
||||
domains: countControl('Domains', {
|
||||
group: DOMAINS,
|
||||
description:
|
||||
'External hosts the workspace called in the window. At 0 the page shows what to instrument instead of the table.',
|
||||
value: DOMAIN_MAX,
|
||||
max: DOMAIN_MAX,
|
||||
}),
|
||||
drawer: choiceControl<DrawerState>('Domain drawer', {
|
||||
group: DRAWER,
|
||||
description:
|
||||
'The drawer a domain row opens, and which of its three views is showing.',
|
||||
options: DRAWER_STATES,
|
||||
value: 'closed',
|
||||
}),
|
||||
drawerDomain: choiceControl<DrawerDomain>('Drawer domain', {
|
||||
group: DRAWER,
|
||||
description:
|
||||
'Which row the drawer opens: a healthy host, one failing most calls, or a bare address on a non-standard port.',
|
||||
options: DRAWER_DOMAINS,
|
||||
value: 'healthy',
|
||||
}),
|
||||
endpoints: countControl('Endpoints', {
|
||||
group: DRAWER,
|
||||
description:
|
||||
'Endpoints the domain has. Fills the Endpoint Overview table and the endpoint picker.',
|
||||
value: ENDPOINT_MAX,
|
||||
max: ENDPOINT_MAX,
|
||||
}),
|
||||
statusCodes: countControl('Status codes', {
|
||||
group: DRAWER,
|
||||
description:
|
||||
'Distinct response codes the endpoint answered with, in the table and in the call response chart.',
|
||||
value: STATUS_CODE_MAX,
|
||||
max: STATUS_CODE_MAX,
|
||||
}),
|
||||
dependentServices: countControl('Dependent services', {
|
||||
group: DRAWER,
|
||||
description:
|
||||
'Services calling the endpoint. Past five the list collapses behind Show more.',
|
||||
value: DEPENDENT_SERVICE_MAX,
|
||||
max: DEPENDENT_SERVICE_MAX,
|
||||
}),
|
||||
topErrors: countControl('Top errors', {
|
||||
group: DRAWER,
|
||||
description: 'Rows the Top 10 Errors table has for the domain.',
|
||||
value: TOP_ERROR_MAX,
|
||||
max: TOP_ERROR_MAX,
|
||||
}),
|
||||
serviceFilter: toggleControl('Service filter', {
|
||||
group: DRAWER,
|
||||
description:
|
||||
'Puts a service.name filter on the endpoint stats view, which drops the Dependent Services block.',
|
||||
value: false,
|
||||
}),
|
||||
quickFilters: countControl('Quick filters', {
|
||||
group: FILTERS,
|
||||
description:
|
||||
'Configured API Monitoring quick filters shown beside the domain list.',
|
||||
value: QUICK_FILTERS.length,
|
||||
max: QUICK_FILTERS.length,
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.get(
|
||||
'http://localhost/api/v2/quick_filters/:source',
|
||||
response.json(() =>
|
||||
quickFiltersResponse(
|
||||
QuickfiltertypesSourceDTO.api_monitoring,
|
||||
QUICK_FILTERS.slice(0, values.quickFilters),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v1/third-party-apis/overview/list',
|
||||
response.json(async (req) => {
|
||||
const { show_ip: showIp } = (await req.json()) as ListOverviewRequest;
|
||||
|
||||
return domainListResponse(values.domains, showIp, Date.now());
|
||||
}),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v3/autocomplete/attribute_keys',
|
||||
response.json((req) => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
attributeKeys: groupByAttributeKeys(
|
||||
req.url.searchParams.get('searchText') ?? '',
|
||||
),
|
||||
},
|
||||
})),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v3/autocomplete/attribute_values',
|
||||
response.json((req) => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
boolAttributeValues: null,
|
||||
numberAttributeValues: null,
|
||||
stringAttributeValues:
|
||||
QUICK_FILTER_VALUES[req.url.searchParams.get('attributeKey') ?? ''] ?? [],
|
||||
},
|
||||
})),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/fields/values',
|
||||
response.json((req) =>
|
||||
fieldValuesResponse(
|
||||
QUICK_FILTER_VALUES[req.url.searchParams.get('name') ?? ''] ?? [],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Every widget in the drawer asks the same endpoint, so what a request is
|
||||
// for is only in its shape: the panel type, what it groups by, and which
|
||||
// aggregations it names.
|
||||
rest.post(
|
||||
'http://localhost/api/v5/query_range',
|
||||
response.json(async (req) => {
|
||||
const body = (await req.json()) as QueryRangeRequestV5;
|
||||
const shape = shapeOf(body);
|
||||
const domainName =
|
||||
drawerDomainName(values.drawerDomain, values.domains) ?? '';
|
||||
const window = { start: body.start, end: body.end };
|
||||
|
||||
if (body.requestType === 'time_series') {
|
||||
if (shape.groupBy.includes('response_status_code')) {
|
||||
return statusCodeChartResponse(
|
||||
domainName,
|
||||
values.statusCodes,
|
||||
window,
|
||||
shape.expressions.includes('count()') ? 'calls' : 'latency',
|
||||
);
|
||||
}
|
||||
|
||||
return overTimeChartResponse(
|
||||
domainName,
|
||||
window,
|
||||
shape.expressions.includes('rate()') ? 'rate' : 'latency',
|
||||
);
|
||||
}
|
||||
|
||||
if (shape.groupBy.includes('status_message')) {
|
||||
return topErrorsResponse(
|
||||
domainName,
|
||||
values.topErrors,
|
||||
shape.filters.some((expression) =>
|
||||
expression.includes('status_message EXISTS'),
|
||||
),
|
||||
shape.endPointName,
|
||||
);
|
||||
}
|
||||
|
||||
if (shape.groupBy.includes('response_status_code')) {
|
||||
return statusCodeTableResponse(domainName, values.statusCodes);
|
||||
}
|
||||
|
||||
if (shape.groupBy.includes('http_url')) {
|
||||
if (shape.names.length === 1) {
|
||||
return endpointDropdownResponse(domainName, values.endpoints);
|
||||
}
|
||||
|
||||
return allEndpointsResponse(
|
||||
domainName,
|
||||
values.endpoints,
|
||||
shape.groupBy,
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
|
||||
if (shape.groupBy.includes('service.name')) {
|
||||
return dependentServicesResponse(domainName, values.dependentServices);
|
||||
}
|
||||
|
||||
if (shape.expressions.includes('rate()')) {
|
||||
return endpointMetricsResponse(
|
||||
domainName,
|
||||
shape.endPointName ?? endpointUrl(domainName, 0),
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
|
||||
return domainMetricsResponse(domainName, Date.now());
|
||||
}),
|
||||
),
|
||||
],
|
||||
config: (values) => ({
|
||||
route: apiMonitoringRoute(values),
|
||||
reduxState: timeRange(),
|
||||
}),
|
||||
});
|
||||
@@ -1,145 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { apiMonitoringMocks } from './ApiMonitoringPage.stories.mocks';
|
||||
import ApiMonitoringPage from '../ApiMonitoringPage';
|
||||
|
||||
type ApiMonitoringArgs = PageStoryArgs<typeof apiMonitoringMocks>;
|
||||
|
||||
const pageStory = storyMocks(apiMonitoringMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* Third party domains instrumented services call, their endpoints, status codes
|
||||
* and the services depending on them. The domain drawer is part of the route, so
|
||||
* it is a control rather than a play.
|
||||
*
|
||||
* Route: `/api-monitoring/explorer`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/External APIs',
|
||||
tags: ['play'],
|
||||
component: ApiMonitoringPage,
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<ApiMonitoringArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<ApiMonitoringArgs>;
|
||||
|
||||
/**
|
||||
* Every external host the workspace called in the window, with the endpoints it
|
||||
* uses, how often, how slow and how much of it failed. Clicking a row opens the
|
||||
* domain drawer.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/**
|
||||
* The domain drawer on All Endpoints: the host's own rate, latency and error
|
||||
* share above a table of every endpoint under it, groupable by any span
|
||||
* attribute.
|
||||
*/
|
||||
export const DomainEndpoints: Story = {
|
||||
args: { drawer: 'all-endpoints' },
|
||||
};
|
||||
|
||||
/** The endpoint drawer scoped to checkout, which hides dependent services. */
|
||||
export const ServiceFiltered: Story = {
|
||||
args: { drawer: 'endpoint-stats', serviceFilter: true },
|
||||
};
|
||||
|
||||
/** A non-standard-port destination, preserving the endpoint metadata pill. */
|
||||
export const PortDomain: Story = {
|
||||
args: { drawer: 'endpoint-stats', drawerDomain: 'ip-address' },
|
||||
};
|
||||
|
||||
/** The page fetches before it renders a filter, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
/**
|
||||
* The quick-filter panel has no test id of its own, and it only mounts once the
|
||||
* workspace's filters have answered.
|
||||
*/
|
||||
const selectFirstQuickFilterValue = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<void> => {
|
||||
const panel = await waitFor(() => {
|
||||
const found = canvasElement.querySelector<HTMLElement>('.quick-filters');
|
||||
|
||||
if (!found) {
|
||||
throw new Error('Quick filters did not render');
|
||||
}
|
||||
|
||||
return found;
|
||||
}, untilLoaded);
|
||||
|
||||
// The V2 checkbox panel starts with every value selected, so its checkbox only
|
||||
// toggles an exclusion. The value's own label is what selects it on its own
|
||||
// ("Only"), which is the state this story shows.
|
||||
const [row] = await within(panel).findAllByTestId(
|
||||
/^checkbox-value-row-/,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.click(within(row).getAllByRole('button')[0]);
|
||||
|
||||
// The panel re-renders around the new query, so the checkbox is looked up
|
||||
// again on every attempt rather than held from before the click.
|
||||
await waitFor(
|
||||
() => expect(within(panel).getAllByRole('checkbox')[0]).toBeChecked(),
|
||||
untilLoaded,
|
||||
);
|
||||
};
|
||||
|
||||
/** The domain drawer's real empty endpoint-table branch. */
|
||||
export const EmptyEndpointDrawer: Story = {
|
||||
args: { drawer: 'all-endpoints', endpoints: 0 },
|
||||
};
|
||||
|
||||
/** A selected API Monitoring quick-filter value. */
|
||||
export const QuickFilterSelected: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await selectFirstQuickFilterValue(canvasElement);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* One endpoint's stats: the services calling it, the codes it answered with as
|
||||
* a chart and a table, and its rate and latency over the window.
|
||||
*/
|
||||
export const EndpointStats: Story = {
|
||||
args: { drawer: 'endpoint-stats' },
|
||||
};
|
||||
|
||||
/**
|
||||
* The ten errors the domain returned most, by endpoint, status code and the
|
||||
* message that came back. A row opens the traces behind it.
|
||||
*/
|
||||
export const TopErrors: Story = {
|
||||
args: { drawer: 'top-errors' },
|
||||
};
|
||||
|
||||
/**
|
||||
* A domain answering almost every call with an error, which is what the drawer
|
||||
* looks like when the host is the problem.
|
||||
*/
|
||||
export const FailingDomain: Story = {
|
||||
args: { drawer: 'endpoint-stats', drawerDomain: 'failing' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Nothing instrumented yet: no client spans carrying a URL, so the page explains
|
||||
* what to send instead of listing hosts.
|
||||
*/
|
||||
export const NoExternalCalls: Story = {
|
||||
args: { domains: 0 },
|
||||
};
|
||||
|
||||
/** The domain list mid-query, with the cancel action the toolbar offers. */
|
||||
export const Loading: Story = {
|
||||
args: { dataState: 'loading' },
|
||||
};
|
||||
@@ -1,556 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { domainNameKey } from 'container/ApiMonitoring/constants';
|
||||
import { SPAN_ATTRIBUTES } from 'container/ApiMonitoring/Explorer/Domains/DomainDetails/constants';
|
||||
import type { APIMonitoringResponseColumn } from 'container/ApiMonitoring/types';
|
||||
import type { PayloadProps as ListOverviewResponse } from 'types/api/thirdPartyApis/listOverview';
|
||||
import type { MetricRangePayloadV5 } from 'types/api/v5/queryRange';
|
||||
|
||||
import {
|
||||
queryRangeV5ScalarTableResponse,
|
||||
queryRangeV5TimeSeriesResponse,
|
||||
timeSeriesPoints,
|
||||
} from '@/storybook/msw/__story_mockdata__/queryRange';
|
||||
|
||||
interface Domain {
|
||||
name: string;
|
||||
/** Endpoints in use, which is also how many the drawer can list. */
|
||||
endpoints: number;
|
||||
rate: number;
|
||||
errorRate: number;
|
||||
latencyMs: number;
|
||||
lastSeenMinutesAgo: number;
|
||||
/** A bare address, which the Show IP addresses filter drops. */
|
||||
isIp?: boolean;
|
||||
/** Prefix its endpoint URLs carry, which is where the Port pill reads from. */
|
||||
origin?: string;
|
||||
}
|
||||
|
||||
const DOMAINS: Domain[] = [
|
||||
{
|
||||
name: 'api.stripe.com',
|
||||
endpoints: 10,
|
||||
rate: 8.42,
|
||||
errorRate: 1.24,
|
||||
latencyMs: 241,
|
||||
lastSeenMinutesAgo: 2,
|
||||
},
|
||||
{
|
||||
name: 'api.github.com',
|
||||
endpoints: 7,
|
||||
rate: 3.16,
|
||||
errorRate: 0.42,
|
||||
latencyMs: 187,
|
||||
lastSeenMinutesAgo: 5,
|
||||
},
|
||||
{
|
||||
name: 's3.us-east-1.amazonaws.com',
|
||||
endpoints: 5,
|
||||
rate: 21.68,
|
||||
errorRate: 0.08,
|
||||
latencyMs: 96,
|
||||
lastSeenMinutesAgo: 1,
|
||||
},
|
||||
{
|
||||
name: 'api.segment.io',
|
||||
endpoints: 4,
|
||||
rate: 12.94,
|
||||
errorRate: 4.71,
|
||||
latencyMs: 318,
|
||||
lastSeenMinutesAgo: 11,
|
||||
},
|
||||
{
|
||||
name: 'hooks.slack.com',
|
||||
endpoints: 3,
|
||||
rate: 0.82,
|
||||
errorRate: 12.5,
|
||||
latencyMs: 642,
|
||||
lastSeenMinutesAgo: 46,
|
||||
},
|
||||
{
|
||||
name: 'api.twilio.com',
|
||||
endpoints: 4,
|
||||
rate: 1.64,
|
||||
errorRate: 61.9,
|
||||
latencyMs: 1184,
|
||||
lastSeenMinutesAgo: 184,
|
||||
},
|
||||
{
|
||||
name: '34.120.155.12',
|
||||
endpoints: 2,
|
||||
rate: 0.41,
|
||||
errorRate: 91.3,
|
||||
latencyMs: 2410,
|
||||
lastSeenMinutesAgo: 1620,
|
||||
isIp: true,
|
||||
origin: 'http://34.120.155.12:8080',
|
||||
},
|
||||
{
|
||||
name: 'api.sendgrid.com',
|
||||
endpoints: 3,
|
||||
rate: 2.27,
|
||||
errorRate: 0,
|
||||
latencyMs: 152,
|
||||
lastSeenMinutesAgo: 8,
|
||||
},
|
||||
];
|
||||
|
||||
export const DOMAIN_MAX = DOMAINS.length;
|
||||
|
||||
const MS_IN_MINUTE = 60 * 1000;
|
||||
const NS_IN_MS = 1_000_000;
|
||||
|
||||
const lastSeenIso = (minutesAgo: number, now: number): string =>
|
||||
new Date(now - minutesAgo * MS_IN_MINUTE).toISOString();
|
||||
|
||||
const listOverviewColumns: APIMonitoringResponseColumn[] = [
|
||||
{
|
||||
name: domainNameKey,
|
||||
signal: 'traces',
|
||||
fieldContext: '',
|
||||
fieldDataType: 'string',
|
||||
queryName: '',
|
||||
aggregationIndex: 0,
|
||||
meta: {},
|
||||
columnType: 'attribute',
|
||||
},
|
||||
...['endpoints', 'rps', 'error_rate', 'p99', 'lastseen'].map((name) => ({
|
||||
name,
|
||||
signal: 'traces',
|
||||
fieldContext: '',
|
||||
fieldDataType: 'number',
|
||||
queryName: name,
|
||||
aggregationIndex: 0,
|
||||
meta: {},
|
||||
columnType: 'metric',
|
||||
})),
|
||||
];
|
||||
|
||||
const domainsIn = (count: number, showIp: boolean): Domain[] =>
|
||||
DOMAINS.filter((domain) => showIp || !domain.isIp).slice(0, count);
|
||||
|
||||
export const domainNames = (count: number, showIp = true): string[] =>
|
||||
domainsIn(count, showIp).map((domain) => domain.name);
|
||||
|
||||
export const DRAWER_DOMAINS = ['healthy', 'failing', 'ip-address'] as const;
|
||||
|
||||
export type DrawerDomain = (typeof DRAWER_DOMAINS)[number];
|
||||
|
||||
const DRAWER_DOMAIN_OF: Record<DrawerDomain, string> = {
|
||||
healthy: 'api.stripe.com',
|
||||
failing: 'api.twilio.com',
|
||||
'ip-address': '34.120.155.12',
|
||||
};
|
||||
|
||||
/** Falls back to the first row when the chosen domain is past the list's count. */
|
||||
export const drawerDomainName = (
|
||||
kind: DrawerDomain,
|
||||
count: number,
|
||||
): string | undefined => {
|
||||
const available = domainNames(count);
|
||||
const target = DRAWER_DOMAIN_OF[kind];
|
||||
|
||||
return available.includes(target) ? target : available[0];
|
||||
};
|
||||
|
||||
export const domainListResponse = (
|
||||
count: number,
|
||||
showIp: boolean,
|
||||
now: number,
|
||||
): ListOverviewResponse => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
type: 'scalar',
|
||||
meta: { rowsScanned: count, bytesScanned: 0, durationMs: 0 },
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
columns: listOverviewColumns,
|
||||
// Typed as strings, but the error column calls `toFixed` on the cell
|
||||
// and the last-used column parses it with `new Date`, so the metrics
|
||||
// go out as numbers and the timestamp as a date string.
|
||||
data: domainsIn(count, showIp).map((domain) => [
|
||||
domain.name,
|
||||
domain.endpoints,
|
||||
domain.rate,
|
||||
domain.errorRate,
|
||||
domain.latencyMs * NS_IN_MS,
|
||||
lastSeenIso(domain.lastSeenMinutesAgo, now),
|
||||
]) as unknown as string[][],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const domainOf = (name: string): Domain =>
|
||||
DOMAINS.find((domain) => domain.name === name) ?? DOMAINS[0];
|
||||
|
||||
const ENDPOINT_PATHS = [
|
||||
'/v1/charges',
|
||||
'/v1/customers',
|
||||
'/v1/payment_intents',
|
||||
'/v1/refunds',
|
||||
'/v1/invoices',
|
||||
'/v1/subscriptions',
|
||||
'/v1/events',
|
||||
'/v1/payouts',
|
||||
'/v1/balance',
|
||||
'/v1/tokens',
|
||||
];
|
||||
|
||||
export const ENDPOINT_MAX = ENDPOINT_PATHS.length;
|
||||
|
||||
/** Full URLs, port included, which is what the drawer splits into endpoint and port. */
|
||||
export const endpointUrls = (domainName: string, count: number): string[] => {
|
||||
const { origin = `https://${domainName}` } = domainOf(domainName);
|
||||
|
||||
return ENDPOINT_PATHS.slice(0, count).map((path) => `${origin}${path}`);
|
||||
};
|
||||
|
||||
export const endpointUrl = (domainName: string, index = 0): string =>
|
||||
endpointUrls(domainName, ENDPOINT_MAX)[index];
|
||||
|
||||
const endpointScale = (domain: Domain, index: number): number =>
|
||||
1 + ((index * 7) % 5) / 4;
|
||||
|
||||
export const domainMetricsResponse = (
|
||||
domainName: string,
|
||||
now: number,
|
||||
): MetricRangePayloadV5 => {
|
||||
const domain = domainOf(domainName);
|
||||
|
||||
return queryRangeV5ScalarTableResponse({
|
||||
aggregations: ['A', 'B', 'D', 'F1'],
|
||||
rows: [
|
||||
[
|
||||
domain.endpoints,
|
||||
domain.latencyMs * NS_IN_MS,
|
||||
lastSeenIso(domain.lastSeenMinutesAgo, now),
|
||||
domain.errorRate,
|
||||
],
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
export const endpointMetricsResponse = (
|
||||
domainName: string,
|
||||
endPointName: string,
|
||||
now: number,
|
||||
): MetricRangePayloadV5 => {
|
||||
const domain = domainOf(domainName);
|
||||
const index = Math.max(
|
||||
endpointUrls(domainName, ENDPOINT_MAX).indexOf(endPointName),
|
||||
0,
|
||||
);
|
||||
const scale = endpointScale(domain, index);
|
||||
|
||||
return queryRangeV5ScalarTableResponse({
|
||||
aggregations: ['A', 'B', 'D', 'F1'],
|
||||
rows: [
|
||||
[
|
||||
Number((domain.rate * scale).toFixed(2)),
|
||||
Math.round(domain.latencyMs * scale) * NS_IN_MS,
|
||||
lastSeenIso(domain.lastSeenMinutesAgo, now),
|
||||
Number((domain.errorRate * scale).toFixed(2)),
|
||||
],
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* The Endpoint Overview table. Extra group-by columns come from the request, so
|
||||
* a group-by picked in the panel widens the table instead of dropping its rows.
|
||||
*/
|
||||
export const allEndpointsResponse = (
|
||||
domainName: string,
|
||||
count: number,
|
||||
groupBy: string[],
|
||||
now: number,
|
||||
): MetricRangePayloadV5 => {
|
||||
const domain = domainOf(domainName);
|
||||
const extraGroupBy = groupBy.filter(
|
||||
(name) => name !== SPAN_ATTRIBUTES.HTTP_URL,
|
||||
);
|
||||
|
||||
return queryRangeV5ScalarTableResponse({
|
||||
groupBy: [SPAN_ATTRIBUTES.HTTP_URL, ...extraGroupBy],
|
||||
aggregations: ['A', 'B', 'C', 'F1'],
|
||||
rows: endpointUrls(domainName, count).map((url, index) => {
|
||||
const scale = endpointScale(domain, index);
|
||||
|
||||
return [
|
||||
url,
|
||||
...extraGroupBy.map((name) => groupByValue(name, index)),
|
||||
Math.round(domain.rate * scale * 600),
|
||||
Math.round(domain.latencyMs * scale) * NS_IN_MS,
|
||||
lastSeenIso(domain.lastSeenMinutesAgo + index, now),
|
||||
Number((domain.errorRate * scale).toFixed(2)),
|
||||
];
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const GROUP_BY_VALUES: Record<string, string[]> = {
|
||||
'service.name': ['checkout', 'payments', 'cart'],
|
||||
'deployment.environment': ['production', 'staging'],
|
||||
'rpc.method': ['POST', 'GET'],
|
||||
};
|
||||
|
||||
function groupByValue(name: string, index: number): string {
|
||||
const values = GROUP_BY_VALUES[name] ?? ['value-a', 'value-b'];
|
||||
|
||||
return values[index % values.length];
|
||||
}
|
||||
|
||||
export const endpointDropdownResponse = (
|
||||
domainName: string,
|
||||
count: number,
|
||||
): MetricRangePayloadV5 =>
|
||||
queryRangeV5ScalarTableResponse({
|
||||
groupBy: [SPAN_ATTRIBUTES.HTTP_URL],
|
||||
aggregations: ['A'],
|
||||
rows: endpointUrls(domainName, count).map((url, index) => [
|
||||
url,
|
||||
1200 - index * 90,
|
||||
]),
|
||||
});
|
||||
|
||||
const STATUS_CODES = ['200', '201', '304', '400', '404', '500'];
|
||||
|
||||
export const STATUS_CODE_MAX = STATUS_CODES.length;
|
||||
|
||||
const statusCodeCalls = (index: number): number =>
|
||||
[4820, 1960, 640, 210, 96, 41][index];
|
||||
|
||||
export const statusCodeTableResponse = (
|
||||
domainName: string,
|
||||
count: number,
|
||||
): MetricRangePayloadV5 => {
|
||||
const domain = domainOf(domainName);
|
||||
|
||||
return queryRangeV5ScalarTableResponse({
|
||||
groupBy: [SPAN_ATTRIBUTES.RESPONSE_STATUS_CODE],
|
||||
aggregations: ['A', 'B', 'C'],
|
||||
rows: STATUS_CODES.slice(0, count).map((statusCode, index) => [
|
||||
statusCode,
|
||||
statusCodeCalls(index),
|
||||
Math.round(domain.latencyMs * (1 + index / 3)) * NS_IN_MS,
|
||||
Number((domain.rate / (index + 1)).toFixed(2)),
|
||||
]),
|
||||
});
|
||||
};
|
||||
|
||||
const DEPENDENT_SERVICES = [
|
||||
'checkout',
|
||||
'payments',
|
||||
'cart',
|
||||
'auth',
|
||||
'notifications',
|
||||
'search',
|
||||
'orders',
|
||||
'shipping',
|
||||
];
|
||||
|
||||
export const DEPENDENT_SERVICE_MAX = DEPENDENT_SERVICES.length;
|
||||
|
||||
export const dependentServicesResponse = (
|
||||
domainName: string,
|
||||
count: number,
|
||||
): MetricRangePayloadV5 => {
|
||||
const domain = domainOf(domainName);
|
||||
|
||||
return queryRangeV5ScalarTableResponse({
|
||||
groupBy: ['service.name'],
|
||||
aggregations: ['A', 'B', 'C', 'F1'],
|
||||
rows: DEPENDENT_SERVICES.slice(0, count).map((service, index) => {
|
||||
const calls = Math.round(3800 / (index + 1));
|
||||
|
||||
return [
|
||||
service,
|
||||
calls,
|
||||
Math.round(domain.latencyMs * (1 + index / 5)) * NS_IN_MS,
|
||||
Number((domain.rate / (index + 1)).toFixed(2)),
|
||||
Number((domain.errorRate * (1 + index / 4)).toFixed(2)),
|
||||
];
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
interface TopError {
|
||||
statusCode: string;
|
||||
message: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
const TOP_ERRORS: TopError[] = [
|
||||
{ statusCode: '500', message: 'upstream connect error', count: 412 },
|
||||
{ statusCode: '429', message: 'rate limit exceeded', count: 318 },
|
||||
{ statusCode: '503', message: 'upstream timeout', count: 244 },
|
||||
{ statusCode: '502', message: 'connection reset by peer', count: 187 },
|
||||
{ statusCode: '400', message: 'invalid request payload', count: 143 },
|
||||
{ statusCode: '401', message: 'expired api key', count: 118 },
|
||||
{ statusCode: '404', message: 'no such customer', count: 96 },
|
||||
{ statusCode: '409', message: 'idempotency key reused', count: 71 },
|
||||
{ statusCode: '422', message: 'card declined', count: 54 },
|
||||
{ statusCode: '500', message: 'internal server error', count: 32 },
|
||||
];
|
||||
|
||||
export const TOP_ERROR_MAX = TOP_ERRORS.length;
|
||||
|
||||
/**
|
||||
* The Top 10 Errors table, which reads the scalar result itself rather than the
|
||||
* legacy conversion, so its cells are keyed by column name.
|
||||
*/
|
||||
export const topErrorsResponse = (
|
||||
domainName: string,
|
||||
count: number,
|
||||
withStatusMessage: boolean,
|
||||
endPointName?: string,
|
||||
): MetricRangePayloadV5 => {
|
||||
const urls = endpointUrls(domainName, ENDPOINT_MAX);
|
||||
|
||||
return {
|
||||
data: {
|
||||
type: 'scalar',
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
columns: [
|
||||
{
|
||||
name: SPAN_ATTRIBUTES.HTTP_URL,
|
||||
queryName: '',
|
||||
aggregationIndex: 0,
|
||||
columnType: 'group',
|
||||
},
|
||||
{
|
||||
name: SPAN_ATTRIBUTES.RESPONSE_STATUS_CODE,
|
||||
queryName: '',
|
||||
aggregationIndex: 0,
|
||||
columnType: 'group',
|
||||
},
|
||||
{
|
||||
name: 'status_message',
|
||||
queryName: '',
|
||||
aggregationIndex: 0,
|
||||
columnType: 'group',
|
||||
},
|
||||
{
|
||||
name: '__result_0',
|
||||
queryName: 'A',
|
||||
aggregationIndex: 0,
|
||||
columnType: 'aggregation',
|
||||
},
|
||||
],
|
||||
data: TOP_ERRORS.slice(0, count).map((error, index) => [
|
||||
endPointName ?? urls[index % urls.length],
|
||||
error.statusCode,
|
||||
withStatusMessage ? error.message : 'n/a',
|
||||
error.count,
|
||||
]),
|
||||
},
|
||||
],
|
||||
},
|
||||
meta: {
|
||||
rowsScanned: count,
|
||||
bytesScanned: 0,
|
||||
durationMs: 0,
|
||||
stepIntervals: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
interface Window {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call response status, both the count and the latency the card switches to.
|
||||
* The chart buckets the codes into 2xx–5xx, so the per-code weights are the
|
||||
* ones the status code table shows and the buckets keep their relative size.
|
||||
*/
|
||||
export const statusCodeChartResponse = (
|
||||
domainName: string,
|
||||
count: number,
|
||||
window: Window,
|
||||
metric: 'calls' | 'latency',
|
||||
): MetricRangePayloadV5 => {
|
||||
const domain = domainOf(domainName);
|
||||
|
||||
return queryRangeV5TimeSeriesResponse([
|
||||
{
|
||||
queryName: 'A',
|
||||
series: STATUS_CODES.slice(0, count).map((statusCode, index) => {
|
||||
const base =
|
||||
metric === 'calls'
|
||||
? statusCodeCalls(index) / 12
|
||||
: Math.round(domain.latencyMs * (1 + index / 3)) * NS_IN_MS;
|
||||
|
||||
return {
|
||||
labels: [
|
||||
{
|
||||
key: { name: SPAN_ATTRIBUTES.RESPONSE_STATUS_CODE },
|
||||
value: statusCode,
|
||||
},
|
||||
],
|
||||
values: timeSeriesPoints({
|
||||
...window,
|
||||
seed: index * 3,
|
||||
base,
|
||||
amplitude: base / 5,
|
||||
}),
|
||||
};
|
||||
}),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
/** The rate and latency charts at the bottom of the endpoint stats view. */
|
||||
export const overTimeChartResponse = (
|
||||
domainName: string,
|
||||
window: Window,
|
||||
metric: 'rate' | 'latency',
|
||||
): MetricRangePayloadV5 => {
|
||||
const domain = domainOf(domainName);
|
||||
const base = metric === 'rate' ? domain.rate : domain.latencyMs * NS_IN_MS;
|
||||
|
||||
return queryRangeV5TimeSeriesResponse([
|
||||
{
|
||||
queryName: 'A',
|
||||
series: [
|
||||
{
|
||||
values: timeSeriesPoints({
|
||||
...window,
|
||||
base,
|
||||
amplitude: base / 5,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const GROUP_BY_KEYS = [
|
||||
'service.name',
|
||||
'deployment.environment',
|
||||
'rpc.method',
|
||||
'http.request.method',
|
||||
'net.peer.name',
|
||||
];
|
||||
|
||||
export const groupByAttributeKeys = (
|
||||
searchText: string,
|
||||
): Array<{ key: string; dataType: string; type: string; isColumn: boolean }> =>
|
||||
GROUP_BY_KEYS.filter((key) =>
|
||||
key.toLowerCase().includes(searchText.toLowerCase()),
|
||||
).map((key) => ({
|
||||
key,
|
||||
dataType: 'string',
|
||||
type: 'tag',
|
||||
isColumn: false,
|
||||
}));
|
||||
@@ -1,157 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { AlertDetectionTypes } from 'container/FormAlertRules';
|
||||
import { rest } from 'msw';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import { choiceControl, countControl } from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
|
||||
import {
|
||||
alertFieldKeysResponse,
|
||||
alertFieldValuesResponse,
|
||||
alertMetricMetadataResponse,
|
||||
alertMetricsResponse,
|
||||
alertPreviewSeries,
|
||||
} from '../../AlertList/stories/__story_mockdata__/alertQuery';
|
||||
import {
|
||||
channelsResponse,
|
||||
CHANNEL_MAX,
|
||||
} from '../../AlertList/stories/__story_mockdata__/alerts';
|
||||
|
||||
/**
|
||||
* Which alert the page is building. The page reads this off the URL, so the
|
||||
* control is a route rather than a response: with no type at all it stays on
|
||||
* the picker, anomaly detection routes to the classic form, and everything else
|
||||
* opens the current one.
|
||||
*/
|
||||
const ALERT_MODES = [
|
||||
'select-type',
|
||||
'metrics',
|
||||
'logs',
|
||||
'traces',
|
||||
'exceptions',
|
||||
'anomaly',
|
||||
'classic-form',
|
||||
] as const;
|
||||
|
||||
type AlertMode = (typeof ALERT_MODES)[number];
|
||||
|
||||
const ALERT_TYPE_BY_MODE: Partial<Record<AlertMode, AlertTypes>> = {
|
||||
metrics: AlertTypes.METRICS_BASED_ALERT,
|
||||
logs: AlertTypes.LOGS_BASED_ALERT,
|
||||
traces: AlertTypes.TRACES_BASED_ALERT,
|
||||
exceptions: AlertTypes.EXCEPTIONS_BASED_ALERT,
|
||||
anomaly: AlertTypes.METRICS_BASED_ALERT,
|
||||
'classic-form': AlertTypes.METRICS_BASED_ALERT,
|
||||
};
|
||||
|
||||
const routeFor = (mode: AlertMode): string => {
|
||||
const alertType = ALERT_TYPE_BY_MODE[mode];
|
||||
|
||||
if (!alertType) {
|
||||
return ROUTES.ALERTS_NEW;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
[QueryParams.alertType]: alertType,
|
||||
[QueryParams.ruleType]:
|
||||
mode === 'anomaly'
|
||||
? AlertDetectionTypes.ANOMALY_DETECTION_ALERT
|
||||
: AlertDetectionTypes.THRESHOLD_ALERT,
|
||||
[QueryParams.relativeTime]: '6h',
|
||||
});
|
||||
|
||||
if (mode === 'classic-form') {
|
||||
params.set(QueryParams.showClassicCreateAlertsPage, 'true');
|
||||
}
|
||||
|
||||
return `${ROUTES.ALERTS_NEW}?${params.toString()}`;
|
||||
};
|
||||
|
||||
const FORM = 'Create alert · form';
|
||||
|
||||
export const createAlertMocks = defineStoryMocks({
|
||||
controls: {
|
||||
alertMode: choiceControl<AlertMode>('Alert being created', {
|
||||
group: FORM,
|
||||
options: ALERT_MODES,
|
||||
value: 'metrics',
|
||||
}),
|
||||
channels: countControl('Notification channels', {
|
||||
group: FORM,
|
||||
description: 'What a threshold can be routed to.',
|
||||
value: 5,
|
||||
max: CHANNEL_MAX,
|
||||
}),
|
||||
previewSeries: countControl('Preview series', {
|
||||
group: FORM,
|
||||
description:
|
||||
'Lines the chart above the condition draws once the query has something to run. A new metric alert has no metric picked yet, so it draws nothing until one is.',
|
||||
value: 3,
|
||||
max: 6,
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.post('http://localhost/api/v2/rules', (_req, res, ctx) =>
|
||||
res(ctx.status(201), ctx.json({ status: 'success', data: null })),
|
||||
),
|
||||
|
||||
rest.post('http://localhost/api/v2/rules/test', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: { alertCount: 2, message: 'Rule tested against the last 6 hours' },
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/channels',
|
||||
response.json(() => channelsResponse(values.channels)),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v5/query_range',
|
||||
response.json(async (req) => alertPreviewSeries(values.previewSeries, req)),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/metrics',
|
||||
response.json((req) =>
|
||||
alertMetricsResponse(req.url.searchParams.get('searchText') ?? ''),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/metrics/metadata',
|
||||
response.json((req) =>
|
||||
alertMetricMetadataResponse(req.url.searchParams.get('metricName') ?? ''),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/fields/keys',
|
||||
response.json((req) =>
|
||||
alertFieldKeysResponse(req.url.searchParams.get('searchText') ?? ''),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/fields/values',
|
||||
response.json((req) =>
|
||||
alertFieldValuesResponse(
|
||||
req.url.searchParams.get('name') ?? '',
|
||||
req.url.searchParams.get('searchText') ?? '',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
config: (values) => ({ route: routeFor(values.alertMode) }),
|
||||
});
|
||||
@@ -1,89 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { createAlertMocks } from './CreateAlert.stories.mocks';
|
||||
|
||||
import CreateAlertPage from '../index';
|
||||
|
||||
type CreateAlertArgs = PageStoryArgs<typeof createAlertMocks>;
|
||||
|
||||
const pageStory = storyMocks(createAlertMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* The new rule builder: the query, the condition, the evaluation preview against
|
||||
* `query_range`, and the channels to notify. The mode control picks the alert
|
||||
* type.
|
||||
*
|
||||
* Route: `/alerts/new`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Alerts/Create',
|
||||
tags: ['play'],
|
||||
component: CreateAlertPage,
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<CreateAlertArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<CreateAlertArgs>;
|
||||
|
||||
/**
|
||||
* A new metric alert being written: the query it watches, the threshold it
|
||||
* fires on, and where the notification goes.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Where a new alert starts: the signal the rule is going to watch. */
|
||||
export const SelectAlertType: Story = {
|
||||
args: { alertMode: 'select-type' },
|
||||
};
|
||||
|
||||
/** A log-based alert, whose query section searches logs rather than metrics. */
|
||||
export const LogsAlert: Story = {
|
||||
args: { alertMode: 'logs' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Anomaly detection, which is still written in the classic form: the seasonality
|
||||
* and the deviation take the place of a fixed threshold.
|
||||
*/
|
||||
export const AnomalyAlert: Story = {
|
||||
args: { alertMode: 'anomaly' },
|
||||
};
|
||||
|
||||
/** The classic form, which `showClassicCreateAlertsPage` opts back into. */
|
||||
export const ClassicForm: Story = {
|
||||
args: { alertMode: 'classic-form' },
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
* link. There is one per match type, and they are antd tooltips rather than
|
||||
* `@signozhq/ui` ones, so the Tooltips control leaves them alone and only the
|
||||
* option under the pointer shows one. This opens the match-type list and holds
|
||||
* the tallest of the five, "all the time", whose example runs to two lines.
|
||||
*/
|
||||
export const Tooltips: Story = {
|
||||
args: { tooltipsOpen: true },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const select = await within(canvasElement).findByTestId(
|
||||
'alert-threshold-match-type-select',
|
||||
undefined,
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
|
||||
// The select opens off a mousedown on its inner selector, so a click on
|
||||
// the wrapper the test id sits on reaches nothing.
|
||||
await userEvent.click(within(select).getByRole('combobox'));
|
||||
|
||||
await userEvent.hover(
|
||||
await screen.findByText('ALL THE TIME', undefined, { timeout: 15_000 }),
|
||||
);
|
||||
await screen.findByText('Example:', undefined, { timeout: 15_000 });
|
||||
},
|
||||
};
|
||||
@@ -1,205 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { rest } from 'msw';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
|
||||
|
||||
import { choiceControl, toggleControl } from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import {
|
||||
fieldKeysResponse,
|
||||
fieldValuesResponse,
|
||||
} from '@/storybook/msw/__story_mockdata__/fields';
|
||||
import {
|
||||
listMetricsResponse,
|
||||
metricMetadataResponse,
|
||||
} from '@/storybook/msw/__story_mockdata__/metrics';
|
||||
|
||||
import {
|
||||
NEW_PANEL_ID,
|
||||
newPanelSearch,
|
||||
} from '../../DashboardContainer/PanelEditor/newPanelRoute';
|
||||
import {
|
||||
currentDashboardDocument,
|
||||
PANEL_IDS,
|
||||
patchDashboardDocument,
|
||||
seedDashboardDocument,
|
||||
STORY_DASHBOARD_ID,
|
||||
VARIABLE_KINDS,
|
||||
type DashboardArgs,
|
||||
} from '../../stories/__story_mockdata__/dashboard';
|
||||
import {
|
||||
emptyPanelResponse,
|
||||
NAMESPACE_VALUES,
|
||||
panelResponse,
|
||||
serviceVariableValues,
|
||||
} from '../../stories/__story_mockdata__/panelData';
|
||||
import {
|
||||
EDITOR_FIELD_KEYS,
|
||||
EDITOR_FIELD_VALUES,
|
||||
EDITOR_METRICS,
|
||||
NEW_PANEL_KINDS,
|
||||
newPanelKindOf,
|
||||
type NewPanelKind,
|
||||
} from './__story_mockdata__/panelEditor';
|
||||
|
||||
const PANEL = 'Panel editor · panel';
|
||||
const DATA = 'Panel editor · data';
|
||||
|
||||
const PANEL_OPTIONS = [...PANEL_IDS, NEW_PANEL_ID] as const;
|
||||
|
||||
type PanelOption = (typeof PANEL_OPTIONS)[number];
|
||||
|
||||
const editorRoute = (panel: PanelOption, kind: NewPanelKind): string => {
|
||||
const path = generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, {
|
||||
dashboardId: STORY_DASHBOARD_ID,
|
||||
panelId: panel,
|
||||
});
|
||||
|
||||
return panel === NEW_PANEL_ID
|
||||
? `${path}${newPanelSearch(newPanelKindOf(kind))}`
|
||||
: path;
|
||||
};
|
||||
|
||||
export const panelEditorMocks = defineStoryMocks({
|
||||
controls: {
|
||||
panel: choiceControl<PanelOption>('Panel', {
|
||||
group: PANEL,
|
||||
description:
|
||||
'The panel the editor opens on. `new` is the create route, which seeds an unsaved panel of the kind below instead of loading one.',
|
||||
options: PANEL_OPTIONS,
|
||||
value: 'request-rate',
|
||||
}),
|
||||
newPanelKind: choiceControl<NewPanelKind>('New panel kind', {
|
||||
group: PANEL,
|
||||
description: 'Which kind the create route seeds. Ignored on a saved panel.',
|
||||
options: NEW_PANEL_KINDS,
|
||||
value: 'time-series',
|
||||
}),
|
||||
locked: toggleControl('Dashboard locked', {
|
||||
group: PANEL,
|
||||
description:
|
||||
'A locked dashboard is read-only, so the editor loads but Save is refused with the reason.',
|
||||
value: false,
|
||||
}),
|
||||
noData: toggleControl('Preview returns nothing', {
|
||||
group: DATA,
|
||||
description: 'The preview query answers with an empty result.',
|
||||
value: false,
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => {
|
||||
const document: DashboardArgs = {
|
||||
panels: PANEL_IDS.length,
|
||||
sectioned: true,
|
||||
variables: VARIABLE_KINDS,
|
||||
locked: values.locked,
|
||||
};
|
||||
|
||||
return [
|
||||
// The document the editor resolves its panel from, so it answers on its own
|
||||
// rather than through the Data control.
|
||||
rest.get('http://localhost/api/v2/dashboards/:id', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(currentDashboardDocument(document))),
|
||||
),
|
||||
|
||||
// Saving the panel is a JSON Patch whose response replaces the cache, so
|
||||
// the ops are applied to the story's document and the edit stays.
|
||||
rest.patch(
|
||||
'http://localhost/api/v2/dashboards/:id',
|
||||
async (req, res, ctx) => {
|
||||
const ops = (await req.json()) as Parameters<
|
||||
typeof patchDashboardDocument
|
||||
>[1];
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(patchDashboardDocument(document, ops)),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v5/query_range',
|
||||
response.json(async (req) => {
|
||||
if (values.noData) {
|
||||
return emptyPanelResponse();
|
||||
}
|
||||
|
||||
const body = (await req.json()) as QueryRangeRequestV5;
|
||||
const spec = body.compositeQuery?.queries?.[0]?.spec as
|
||||
| {
|
||||
aggregations?: { metricName?: string }[];
|
||||
groupBy?: { name?: string }[];
|
||||
}
|
||||
| undefined;
|
||||
|
||||
return panelResponse({
|
||||
requestType: body.requestType,
|
||||
window: { start: body.start, end: body.end },
|
||||
metricName: spec?.aggregations?.[0]?.metricName,
|
||||
groupBy: spec?.groupBy?.[0]?.name,
|
||||
});
|
||||
}),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v2/variables/query',
|
||||
response.json(() => ({
|
||||
status: 'success',
|
||||
data: { variableValues: serviceVariableValues(4) },
|
||||
})),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/metrics',
|
||||
response.json((req) =>
|
||||
listMetricsResponse(
|
||||
EDITOR_METRICS,
|
||||
req.url.searchParams.get('searchText') ?? '',
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/metrics/metadata',
|
||||
response.json((req) =>
|
||||
metricMetadataResponse(
|
||||
EDITOR_METRICS,
|
||||
req.url.searchParams.get('metricName') ?? '',
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/fields/keys',
|
||||
response.json(() => fieldKeysResponse(EDITOR_FIELD_KEYS)),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/fields/values',
|
||||
response.json((req) =>
|
||||
fieldValuesResponse(
|
||||
EDITOR_FIELD_VALUES[req.url.searchParams.get('name') ?? ''] ??
|
||||
NAMESPACE_VALUES,
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
config: (values) => ({
|
||||
route: editorRoute(values.panel, values.newPanelKind),
|
||||
}),
|
||||
effect: (values) => {
|
||||
seedDashboardDocument({
|
||||
panels: PANEL_IDS.length,
|
||||
sectioned: true,
|
||||
variables: VARIABLE_KINDS,
|
||||
locked: values.locked,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Route } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { panelEditorMocks } from './PanelEditorPage.stories.mocks';
|
||||
|
||||
import PanelEditorPage from '../PanelEditorPage';
|
||||
|
||||
type PanelEditorArgs = PageStoryArgs<typeof panelEditorMocks>;
|
||||
|
||||
const pageStory = storyMocks(panelEditorMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* The panel editor: the query builder on one side, the panel it renders on the
|
||||
* other, for a panel that exists or a new one of the chosen kind.
|
||||
*
|
||||
* Route: `/dashboard/:dashboardId/panel/:panelId`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Dashboards/Panel Editor',
|
||||
// The page is wrapped in `withAuthZPage`, which types its props as an index
|
||||
// signature; the story's args are what the controls resolve to.
|
||||
component: PanelEditorPage as ComponentType<PanelEditorArgs>,
|
||||
// The dashboard and panel ids come out of the pathname, so the editor renders
|
||||
// under its own route rather than being mounted on its own.
|
||||
render: (): JSX.Element => (
|
||||
<Route path={ROUTES.DASHBOARD_PANEL_EDITOR} component={PanelEditorPage} />
|
||||
),
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<PanelEditorArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<PanelEditorArgs>;
|
||||
|
||||
/**
|
||||
* Editing a saved time series panel: the live preview over the query builder on
|
||||
* the left, the panel's formatting, legend, axes and thresholds on the right.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/** The create route, seeding an unsaved panel of the chosen kind. */
|
||||
export const NewPanel: Story = {
|
||||
args: { panel: 'new' },
|
||||
};
|
||||
|
||||
/** A list panel, where the config pane is the column editor. */
|
||||
export const ListPanel: Story = {
|
||||
args: { panel: 'recent-logs' },
|
||||
};
|
||||
|
||||
/** A table panel, with its column units and thresholds. */
|
||||
export const TablePanel: Story = {
|
||||
args: { panel: 'top-endpoints' },
|
||||
};
|
||||
|
||||
/** The editor and query configuration remain visible when its preview has no rows. */
|
||||
export const NoPreviewData: Story = {
|
||||
args: { noData: true },
|
||||
};
|
||||
|
||||
/** The editor remains usable while the independently fetched preview has failed. */
|
||||
export const PreviewQueryError: Story = {
|
||||
args: { dataState: 'error' },
|
||||
};
|
||||
|
||||
/** A locked dashboard: the editor still opens, but it cannot save. */
|
||||
export const ReadOnly: Story = {
|
||||
args: { locked: true },
|
||||
// The deliberate 500s on the metrics queries are the state under test.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
/**
|
||||
* Every tooltip the editor carries, held open: the Quick Add beside the
|
||||
* Thresholds and Context links section headers, and the copy button on each of
|
||||
* the preview legend's series.
|
||||
*/
|
||||
export const Tooltips: Story = {
|
||||
args: { tooltipsOpen: true },
|
||||
};
|
||||
@@ -1,98 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import {
|
||||
MetrictypesTemporalityDTO,
|
||||
MetrictypesTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
metricSeed,
|
||||
type MetricSeed,
|
||||
} from '@/storybook/msw/__story_mockdata__/metrics';
|
||||
|
||||
import type { PanelKind } from '../../../DashboardContainer/Panels/types/panelKind';
|
||||
|
||||
/**
|
||||
* The panel kinds the create route can seed, spelled without the `signoz/`
|
||||
* prefix: a control value carrying a slash does not survive the story URL.
|
||||
*/
|
||||
export const NEW_PANEL_KINDS = [
|
||||
'time-series',
|
||||
'bar-chart',
|
||||
'number',
|
||||
'pie-chart',
|
||||
'table',
|
||||
'histogram',
|
||||
'list',
|
||||
] as const;
|
||||
|
||||
export type NewPanelKind = (typeof NEW_PANEL_KINDS)[number];
|
||||
|
||||
const KIND_BY_OPTION: Record<NewPanelKind, PanelKind> = {
|
||||
'time-series': 'signoz/TimeSeriesPanel',
|
||||
'bar-chart': 'signoz/BarChartPanel',
|
||||
number: 'signoz/NumberPanel',
|
||||
'pie-chart': 'signoz/PieChartPanel',
|
||||
table: 'signoz/TablePanel',
|
||||
histogram: 'signoz/HistogramPanel',
|
||||
list: 'signoz/ListPanel',
|
||||
};
|
||||
|
||||
export const newPanelKindOf = (option: NewPanelKind): PanelKind =>
|
||||
KIND_BY_OPTION[option];
|
||||
|
||||
/** Attributes the editor's query builder offers while filtering and grouping. */
|
||||
export const EDITOR_FIELD_KEYS = [
|
||||
'service.name',
|
||||
'http.route',
|
||||
'http.status_code',
|
||||
'deployment.environment',
|
||||
'k8s.namespace.name',
|
||||
'host.name',
|
||||
] as const;
|
||||
|
||||
export const EDITOR_FIELD_VALUES: Record<string, readonly string[]> = {
|
||||
'service.name': ['checkout', 'payments', 'inventory', 'notifications'],
|
||||
'http.route': ['/v1/checkout', '/v1/cart', '/v1/payment/authorize'],
|
||||
'http.status_code': ['200', '404', '500', '503'],
|
||||
'deployment.environment': ['production', 'staging', 'development'],
|
||||
'k8s.namespace.name': ['checkout-prod', 'payments-prod', 'platform-prod'],
|
||||
'host.name': ['ip-10-0-1-14', 'ip-10-0-2-31', 'ip-10-0-3-77'],
|
||||
};
|
||||
|
||||
/** The metrics the editor's aggregation field offers, the panels' own included. */
|
||||
export const EDITOR_METRICS: MetricSeed[] = [
|
||||
metricSeed('signoz_calls_total', 'Total spans received', 'count'),
|
||||
metricSeed('signoz_errors_total', 'Spans with an error status', 'count'),
|
||||
metricSeed(
|
||||
'signoz_latency_bucket',
|
||||
'Span duration histogram',
|
||||
'ms',
|
||||
MetrictypesTypeDTO.histogram,
|
||||
MetrictypesTemporalityDTO.delta,
|
||||
),
|
||||
metricSeed(
|
||||
'signoz_apdex',
|
||||
'Apdex score per service',
|
||||
'',
|
||||
MetrictypesTypeDTO.gauge,
|
||||
MetrictypesTemporalityDTO.unspecified,
|
||||
),
|
||||
metricSeed(
|
||||
'system_cpu_usage',
|
||||
'CPU used per host',
|
||||
'percent',
|
||||
MetrictypesTypeDTO.gauge,
|
||||
MetrictypesTemporalityDTO.unspecified,
|
||||
),
|
||||
metricSeed(
|
||||
'system_memory_usage',
|
||||
'Memory used per host',
|
||||
'bytes',
|
||||
MetrictypesTypeDTO.gauge,
|
||||
MetrictypesTemporalityDTO.unspecified,
|
||||
),
|
||||
];
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user