mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-22 11:20:43 +01:00
Compare commits
143 Commits
qf-scroll-
...
chore/impr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6cd221cf1 | ||
|
|
bf1443f52a | ||
|
|
b442844f0b | ||
|
|
7dda74db53 | ||
|
|
8e71a96244 | ||
|
|
1d42399d8e | ||
|
|
235496bda2 | ||
|
|
31337bb3b3 | ||
|
|
2b28da96de | ||
|
|
c31b845a1d | ||
|
|
baafb7a723 | ||
|
|
558c4d7cbe | ||
|
|
c9be3c98e6 | ||
|
|
113c744395 | ||
|
|
c1daa3ec63 | ||
|
|
72b30684b4 | ||
|
|
b2f992ef5f | ||
|
|
3fc63da52c | ||
|
|
d0a91261a3 | ||
|
|
81afa641a2 | ||
|
|
e3db2d824b | ||
|
|
314bacfc7c | ||
|
|
ea698bb952 | ||
|
|
e756dec84d | ||
|
|
50a3927c87 | ||
|
|
4279d60f70 | ||
|
|
1a57cb4948 | ||
|
|
d3f38fc961 | ||
|
|
191a029cab | ||
|
|
030146b201 | ||
|
|
e243b00561 | ||
|
|
562e21e036 | ||
|
|
58e62342fe | ||
|
|
077a9e14f7 | ||
|
|
dd71f54b1a | ||
|
|
81dd301273 | ||
|
|
6ebfb44708 | ||
|
|
16a947a9e0 | ||
|
|
9edf2a19c7 | ||
|
|
40227aa010 | ||
|
|
793b1d24a5 | ||
|
|
16f70a0dba | ||
|
|
abe3e5b833 | ||
|
|
3efe81571e | ||
|
|
dfcf85d499 | ||
|
|
1de77074b1 | ||
|
|
8ae0ee45f8 | ||
|
|
80dce376d1 | ||
|
|
b6af98a0a8 | ||
|
|
191e039890 | ||
|
|
9134d0a854 | ||
|
|
5ff24cbc67 | ||
|
|
2814ec8988 | ||
|
|
f542b1de05 | ||
|
|
9f6c6af4c2 | ||
|
|
9d15f22ca6 | ||
|
|
2bbda851dd | ||
|
|
e3a2998412 | ||
|
|
b8bebadda9 | ||
|
|
f2e2327664 | ||
|
|
d12d3cb62f | ||
|
|
bfa7482548 | ||
|
|
6e4e2c8ec4 | ||
|
|
c79511637d | ||
|
|
ef72bf1b36 | ||
|
|
864539cac7 | ||
|
|
1051051dc8 | ||
|
|
eff954b49d | ||
|
|
c64e898b32 | ||
|
|
b977149cfd | ||
|
|
57c9011e39 | ||
|
|
72333a75a9 | ||
|
|
849c77d06b | ||
|
|
8ae9914143 | ||
|
|
3e2326b475 | ||
|
|
f4b1889558 | ||
|
|
2b1bb1a3c7 | ||
|
|
cbea5a528c | ||
|
|
5b13ae2ca3 | ||
|
|
c23f0a0a28 | ||
|
|
ff244f53c6 | ||
|
|
138b14d14a | ||
|
|
51fd759c71 | ||
|
|
c6155d313f | ||
|
|
8166da8158 | ||
|
|
991ca7573c | ||
|
|
09d07ca3a6 | ||
|
|
11cb979f27 | ||
|
|
8a8fe5371c | ||
|
|
9a3c1ad181 | ||
|
|
36a452d790 | ||
|
|
b053f8b255 | ||
|
|
6759d3d188 | ||
|
|
9613130cc0 | ||
|
|
9d9be599eb | ||
|
|
ca27cb3494 | ||
|
|
97f6b99707 | ||
|
|
dcf68caf6c | ||
|
|
364d6b52f1 | ||
|
|
e1ac3f7b1b | ||
|
|
a205710d7f | ||
|
|
bcf32dec1f | ||
|
|
f1f63b7fa7 | ||
|
|
81e8bff6fa | ||
|
|
e5699b1461 | ||
|
|
8882260445 | ||
|
|
3569ba7a61 | ||
|
|
f80c2b4fc4 | ||
|
|
8290a1d9fd | ||
|
|
67c650c62d | ||
|
|
255061b53f | ||
|
|
94befc2a1d | ||
|
|
19926f9e20 | ||
|
|
5e86f134d7 | ||
|
|
05a33d82ae | ||
|
|
9630622f28 | ||
|
|
b8853a0556 | ||
|
|
62aac2ac7b | ||
|
|
3167bf13ea | ||
|
|
8501d6bc5f | ||
|
|
50acd473e6 | ||
|
|
811c66af36 | ||
|
|
d023e48635 | ||
|
|
3fa5a754f3 | ||
|
|
c306fb1a6d | ||
|
|
d4affb3452 | ||
|
|
afbaad9511 | ||
|
|
43e79a249a | ||
|
|
fcea1b86ce | ||
|
|
aa93c94e0f | ||
|
|
30eb205623 | ||
|
|
9284e40cfc | ||
|
|
a3220483ff | ||
|
|
4461c07231 | ||
|
|
44bf33669f | ||
|
|
755cb5ae0f | ||
|
|
c7f65e8cb4 | ||
|
|
c2dc2e7f46 | ||
|
|
42aa496f54 | ||
|
|
a7af3761dd | ||
|
|
704a9a18b9 | ||
|
|
d86a910e24 | ||
|
|
892ef131d7 |
@@ -26,6 +26,135 @@ 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,
|
||||
@@ -50,7 +179,29 @@ process on top of it.
|
||||
- **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/`.
|
||||
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).
|
||||
- **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:
|
||||
|
||||
@@ -74,6 +225,13 @@ process on top of it.
|
||||
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
|
||||
@@ -85,6 +243,16 @@ process on top of it.
|
||||
## 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,20 +128,83 @@ 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',
|
||||
title: 'Pages/Services/List',
|
||||
component: Services,
|
||||
...storyMocks(servicesMocks, { route: ROUTES.APPLICATION, layout: 'app' }),
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} 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, permissions, check state.
|
||||
access preset, granted permissions, revoked 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`,
|
||||
@@ -155,9 +218,12 @@ const meta = {
|
||||
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,11 +12,12 @@ 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` → `pages-services`, plus the
|
||||
story export in kebab-case. Render one story on its own:
|
||||
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:
|
||||
|
||||
```
|
||||
http://localhost:6006/iframe.html?id=pages-services--default&viewMode=story
|
||||
http://localhost:6006/iframe.html?id=pages-services-list--default&viewMode=story
|
||||
```
|
||||
|
||||
## Flip controls from the URL
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: storybook-visual-diff
|
||||
description: Screenshot a set of SigNoz Storybook stories, then pixel-diff two runs to see what a CSS or component change did, with the changes tinted over the new shot. Use when asked to take story screenshots, capture a visual baseline, compare before/after of a style change, or find which pages a change affects.
|
||||
description: Screenshot a set of SigNoz Storybook stories, then pixel-diff two runs to see what a CSS or component change did, with the changes tinted over the new shot. Also surveys where a component is used across the UI, ringing each instance in red and collecting every one into a single contact sheet. Use when asked to take story screenshots, capture a visual baseline, compare before/after of a style change, find which pages a change affects, or show every place a component appears.
|
||||
---
|
||||
|
||||
# Storybook visual diff
|
||||
@@ -22,7 +22,7 @@ says, take it and do not ask again; ask only for what is genuinely missing, in
|
||||
|
||||
| To settle | Ask | Options |
|
||||
| --- | --- | --- |
|
||||
| Job | "What should this run produce?" | shoot only · baseline for a change you are about to make · compare against a change already in the working tree · compare this branch against another (`main` by default, or one the user names) · compare two configurations of the same story (`--args`, clock, width) · noise floor (same tree twice) |
|
||||
| Job | "What should this run produce?" | shoot only · survey where a component is used (§4) · baseline for a change you are about to make · compare against a change already in the working tree · compare this branch against another (`main` by default, or one the user names) · compare two configurations of the same story (`--args`, clock, width) · noise floor (same tree twice) |
|
||||
| Scope | "Which stories?" | offer 2-3 concrete selections read off `index.json` (a page, a `--title` prefix, everything), never open-ended |
|
||||
| Themes | "Which themes?" | dark · dark + light |
|
||||
| Read-out | "How should the diff read?" | `green` (changed pixels over the after shot) · `green-parallel` (before \| after \| diff, side by side) · `red` · `red-parallel` · `none` (keep both runs, do not diff) |
|
||||
@@ -39,6 +39,7 @@ The job decides which loop below to run:
|
||||
| Job | Loop |
|
||||
| --- | --- |
|
||||
| **shoot only** | §1, §2, stop. Report the paths. No diff, no second run. |
|
||||
| **usage survey** | §1, §4, stop. One run, no diff: the question is where a component appears, not what moved. |
|
||||
| **baseline first** | the full loop, stopping after step 2 to hand the change back. The user makes it, then continue at step 4. |
|
||||
| **change already in the tree** | the tree *is* the after state. `git stash` (or check out the base commit) to shoot the before, restore, shoot the after. Confirm the working tree is clean enough to stash before touching it, and restore it even if a capture fails. |
|
||||
| **branch vs branch** | shoot the current branch, then `git switch <base>` in place (stash first if the tree is dirty), restart the dev server, shoot again, switch back and unstash. Restart matters: HMR does not survive a whole-branch swap cleanly. Get the tree back to where it started even if a capture fails. |
|
||||
@@ -122,6 +123,8 @@ node scripts/story-shots.mjs .story-shots/baseline \
|
||||
| `--clock <iso\|live>` | wall clock the page reads, passed to the preview as `?storyClock`; `live` unfreezes it |
|
||||
| `--motion` | keep animations and transitions running (sets the `motion` global to `live`) |
|
||||
| `--ignore <selector>` | hide matching elements, on top of `[data-shot-ignore]` and `[data-chromatic="ignore"]` |
|
||||
| `--highlight <selector>` | also write `<id>--highlight.png`, every match ringed in red with 6px of padding |
|
||||
| `--crop <selector>` | also write one `crops/<id>--<n>.png` per match, and montage the theme's crops into `crops.png` |
|
||||
| `--flat` | write `<out>/<id>.png`, no theme directory |
|
||||
| `--no-caption` | leave the caption band off the shots |
|
||||
| `--list` | print the matched stories and exit |
|
||||
@@ -129,8 +132,9 @@ node scripts/story-shots.mjs .story-shots/baseline \
|
||||
Files land at `<out>/<theme>/<story-id>.png`, next to a `shots.json` recording
|
||||
what each shot is (id, title, name, theme, `ok`/`busy`, the caption's height in
|
||||
rows) and how the run was configured (args, clock, width, height, grow, motion,
|
||||
settle, ignore). Keep the flags identical between the two runs or the diff pairs
|
||||
nothing.
|
||||
settle, ignore, highlight, crop). Keep the flags identical between the two runs
|
||||
or the diff pairs nothing. `--highlight` and `--crop` write extra files beside
|
||||
the shots; §4 is what they are for.
|
||||
|
||||
Every shot carries the caption band described below, so a single screenshot says
|
||||
what it is on its own. `--no-caption` leaves it off, and so does a machine
|
||||
@@ -208,10 +212,75 @@ pixelmatch's `includeAA: false`. So the script implements that comparison:
|
||||
A pair whose shots are different sizes is compared over the overlap, and every
|
||||
row and column that exists in only one of them counts as changed.
|
||||
|
||||
Pairing is by `<theme>/<story-id>.png`, so a story that exists on only one side
|
||||
(new on the feature branch, renamed, retitled) has nothing to pair with and is
|
||||
skipped silently. On a branch-vs-branch run, compare the two runs' file lists
|
||||
before reading the numbers.
|
||||
Pairing is by `<theme>/<story-id>.png`. A file that exists on one side only (a
|
||||
story added on the feature branch, renamed, retitled, or one whose capture
|
||||
failed) has nothing to compare against. It is not skipped: the side that has the
|
||||
shot is written out, captioned `missing previous` or `missing current`, and every
|
||||
one of its pixels counts as changed, so it sorts to the top of the report and is
|
||||
printed with that note. In the parallel modes the run that does not have it gets
|
||||
a placeholder tile saying so, in the theme's own colours, so the montage keeps
|
||||
its three tiles. Without this a whole component going missing reads as a clean
|
||||
run.
|
||||
|
||||
## 4. Surveying where a component is used
|
||||
|
||||
A different question from a diff: not *what moved*, but *where does this
|
||||
component appear and what does each instance look like*. One run answers it.
|
||||
|
||||
```bash
|
||||
node scripts/story-shots.mjs .story-shots/button-group --port 6007 --theme dark,light \
|
||||
--highlight '.ant-btn-group, div[role="group"][class*="button-group"]' \
|
||||
--crop '.ant-btn-group, div[role="group"][class*="button-group"]' \
|
||||
--stories pages-home--default,pages-alerts-history--default,...
|
||||
```
|
||||
|
||||
Four things come out, per theme:
|
||||
|
||||
- `<theme>/<id>.png` — the page as it is.
|
||||
- `<theme>/<id>--highlight.png` — the same page with every instance ringed in
|
||||
red. This is what says *where on the page*, which a crop cannot.
|
||||
- `<theme>/crops/<id>--<n>.png` — each instance on its own.
|
||||
- `<theme>/crops.png` — every crop of that theme in one labelled contact sheet.
|
||||
|
||||
The sheet is the useful artifact. Twelve instances across nine pages is one
|
||||
image to read, not twelve files to open in turn, and the label under each says
|
||||
which story it came from.
|
||||
|
||||
### Finding the selector and the stories
|
||||
|
||||
1. **Grep the source for the import, not the tag.** `Button.Group` and
|
||||
`ButtonGroup` are two different components in this repo: antd's, and
|
||||
`@signozhq/ui/button`'s. A survey that greps one misses the other.
|
||||
2. **Read the rendered markup, not the JSX.** `--crop` takes a CSS selector
|
||||
against the DOM. antd's group is `.ant-btn-group`; the design-system one is a
|
||||
`div[role="group"]` whose class is a hashed CSS module, hence
|
||||
`[class*="button-group"]`. Open the built component under
|
||||
`node_modules/@signozhq/ui/dist/` when the class is not obvious.
|
||||
3. **Map each source file to the story that renders it.** Follow the consumers:
|
||||
a container renders inside a page, and the page's story is the one to shoot.
|
||||
A component behind a drawer or a tab needs the story whose `args` open it
|
||||
(`--args drawer:endpoint-stats`), not the page default.
|
||||
4. **Let the run itself confirm the mapping.** Each story logs `N cropped`. A
|
||||
`0 cropped` line means that story never reaches the state, so swap the story
|
||||
rather than the selector.
|
||||
|
||||
### What a zero means
|
||||
|
||||
- **`0 cropped` on a story** — the component is not on that page in that state.
|
||||
Wrong story, or the state is behind an interaction the story has no `play`
|
||||
for. A modal nobody opens cannot be surveyed; say so instead of shooting the
|
||||
page it sits behind.
|
||||
- **An instance in the source with no crop** — a container whose children are
|
||||
all conditional renders as a 0x0 box. Both `--crop` and `--highlight` skip
|
||||
anything under 1px, since there is nothing on screen to ring. That is a
|
||||
finding about the component, not a failure of the run: it is in the tree and
|
||||
invisible.
|
||||
|
||||
A story logged `viewport-sized content, stopped chasing Npx` is shot back at
|
||||
`--height` with its own scrollbar, and what is below the fold there is laid out
|
||||
but never painted. The crops of such a story are taken at the chased height
|
||||
instead, so they are not the black rectangles the page shot would give; the
|
||||
`--highlight` shot still shows only what fits the viewport.
|
||||
|
||||
## What makes a shot reproducible
|
||||
|
||||
@@ -292,3 +361,6 @@ for `--ignore` when a region cannot be settled.
|
||||
- **Stories behind a hover, drawer or modal** only render what their `play`
|
||||
reaches. If a state is missing from the shot, the story needs the `play`, not
|
||||
the script.
|
||||
- **The caption's temporary file is written beside the shot**, not in the system
|
||||
temp directory. `/tmp` is often a different filesystem, and the rename back
|
||||
over the shot then fails with `EXDEV: cross-device link not permitted`.
|
||||
|
||||
@@ -3454,6 +3454,29 @@ components:
|
||||
required:
|
||||
- customValue
|
||||
type: object
|
||||
DashboardtypesDashboard:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
createdBy:
|
||||
type: string
|
||||
data:
|
||||
$ref: '#/components/schemas/DashboardtypesStorableDashboardData'
|
||||
id:
|
||||
type: string
|
||||
locked:
|
||||
type: boolean
|
||||
org_id:
|
||||
type: string
|
||||
source:
|
||||
$ref: '#/components/schemas/DashboardtypesSource'
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
updatedBy:
|
||||
type: string
|
||||
type: object
|
||||
DashboardtypesDashboardPanelRef:
|
||||
properties:
|
||||
dashboardId:
|
||||
@@ -3633,6 +3656,13 @@ components:
|
||||
timeRangeEnabled:
|
||||
type: boolean
|
||||
type: object
|
||||
DashboardtypesGettablePublicDashboardData:
|
||||
properties:
|
||||
dashboard:
|
||||
$ref: '#/components/schemas/DashboardtypesDashboard'
|
||||
publicDashboard:
|
||||
$ref: '#/components/schemas/DashboardtypesGettablePublicDasbhboard'
|
||||
type: object
|
||||
DashboardtypesGettablePublicDashboardDataV2:
|
||||
properties:
|
||||
dashboard:
|
||||
@@ -4417,6 +4447,9 @@ components:
|
||||
- normal
|
||||
- percent
|
||||
type: string
|
||||
DashboardtypesStorableDashboardData:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
DashboardtypesTableFormatting:
|
||||
properties:
|
||||
columnUnits:
|
||||
@@ -7192,6 +7225,22 @@ components:
|
||||
- attributes
|
||||
- totalKeys
|
||||
type: object
|
||||
MetricsexplorertypesMetricDashboard:
|
||||
properties:
|
||||
dashboardId:
|
||||
type: string
|
||||
dashboardName:
|
||||
type: string
|
||||
widgetId:
|
||||
type: string
|
||||
widgetName:
|
||||
type: string
|
||||
required:
|
||||
- dashboardName
|
||||
- dashboardId
|
||||
- widgetId
|
||||
- widgetName
|
||||
type: object
|
||||
MetricsexplorertypesMetricDashboardPanelsResponse:
|
||||
properties:
|
||||
dashboards:
|
||||
@@ -7202,6 +7251,16 @@ components:
|
||||
required:
|
||||
- dashboards
|
||||
type: object
|
||||
MetricsexplorertypesMetricDashboardsResponse:
|
||||
properties:
|
||||
dashboards:
|
||||
items:
|
||||
$ref: '#/components/schemas/MetricsexplorertypesMetricDashboard'
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- dashboards
|
||||
type: object
|
||||
MetricsexplorertypesMetricHighlightsResponse:
|
||||
properties:
|
||||
activeTimeSeries:
|
||||
@@ -13244,6 +13303,112 @@ paths:
|
||||
summary: Update org preference
|
||||
tags:
|
||||
- preferences
|
||||
/api/v1/public/dashboards/{id}:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns the sanitized dashboard data for public access
|
||||
operationId: GetPublicDashboardData
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/DashboardtypesGettablePublicDashboardData'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- anonymous:
|
||||
- public-dashboard:read
|
||||
summary: Get public dashboard data
|
||||
tags:
|
||||
- dashboard
|
||||
/api/v1/public/dashboards/{id}/widgets/{idx}/query_range:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint return query range results for a widget of public
|
||||
dashboard
|
||||
operationId: GetPublicDashboardWidgetQueryRange
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- in: path
|
||||
name: idx
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryRangeResponse'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- anonymous:
|
||||
- public-dashboard:read
|
||||
summary: Get query range result
|
||||
tags:
|
||||
- dashboard
|
||||
/api/v1/roles:
|
||||
get:
|
||||
deprecated: false
|
||||
@@ -19495,6 +19660,74 @@ paths:
|
||||
summary: Get metric attributes
|
||||
tags:
|
||||
- metrics
|
||||
/api/v2/metrics/dashboards:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns associated dashboards for a specified metric
|
||||
operationId: GetMetricDashboards
|
||||
parameters:
|
||||
- description: The name of the metric. May contain slashes (e.g. cloud-provider
|
||||
metrics like run.googleapis.com/request_latencies).
|
||||
in: query
|
||||
name: metricName
|
||||
required: true
|
||||
schema:
|
||||
description: The name of the metric. May contain slashes (e.g. cloud-provider
|
||||
metrics like run.googleapis.com/request_latencies).
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/MetricsexplorertypesMetricDashboardsResponse'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Get metric dashboards
|
||||
tags:
|
||||
- metrics
|
||||
/api/v2/metrics/highlights:
|
||||
get:
|
||||
deprecated: false
|
||||
|
||||
@@ -52,7 +52,7 @@ func (module *module) CreatePublic(ctx context.Context, orgID valuer.UUID, publi
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
dashboard, err := module.GetV2(ctx, orgID, publicDashboard.DashboardID)
|
||||
dashboard, err := module.Get(ctx, orgID, publicDashboard.DashboardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -90,6 +90,15 @@ func (module *module) GetPublic(ctx context.Context, orgID valuer.UUID, dashboar
|
||||
return dashboardtypes.NewPublicDashboardFromStorablePublicDashboard(storablePublicDashboard), nil
|
||||
}
|
||||
|
||||
func (module *module) GetDashboardByPublicID(ctx context.Context, id valuer.UUID) (*dashboardtypes.Dashboard, error) {
|
||||
storableDashboard, err := module.store.GetDashboardByPublicID(ctx, id.StringValue())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dashboardtypes.NewDashboardFromStorableDashboard(storableDashboard), nil
|
||||
}
|
||||
|
||||
func (module *module) GetPublicDashboardSelectorsAndOrg(ctx context.Context, id valuer.UUID, orgs []*types.Organization) ([]coretypes.Selector, valuer.UUID, error) {
|
||||
orgIDs := make([]string, len(orgs))
|
||||
for idx, org := range orgs {
|
||||
@@ -107,6 +116,24 @@ func (module *module) GetPublicDashboardSelectorsAndOrg(ctx context.Context, id
|
||||
}, storableDashboard.OrgID, nil
|
||||
}
|
||||
|
||||
func (module *module) GetPublicWidgetQueryRange(ctx context.Context, id valuer.UUID, widgetIdx, startTime, endTime uint64) (*querybuildertypesv5.QueryRangeResponse, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "dashboard",
|
||||
instrumentationtypes.CodeFunctionName: "GetPublicWidgetQueryRange",
|
||||
})
|
||||
dashboard, err := module.GetDashboardByPublicID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query, err := dashboard.GetWidgetQuery(startTime, endTime, widgetIdx, module.settings.Logger())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return module.querier.QueryRange(ctx, dashboard.OrgID, query)
|
||||
}
|
||||
|
||||
func (module *module) GetDashboardByPublicIDV2(ctx context.Context, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
|
||||
storableDashboard, err := module.store.GetDashboardByPublicID(ctx, id.StringValue())
|
||||
if err != nil {
|
||||
@@ -162,7 +189,7 @@ func (module *module) UpdatePublic(ctx context.Context, orgID valuer.UUID, publi
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
dashboard, err := module.GetV2(ctx, orgID, publicDashboard.DashboardID)
|
||||
dashboard, err := module.Get(ctx, orgID, publicDashboard.DashboardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -173,13 +200,34 @@ func (module *module) UpdatePublic(ctx context.Context, orgID valuer.UUID, publi
|
||||
return module.store.UpdatePublic(ctx, dashboardtypes.NewStorablePublicDashboardFromPublicDashboard(publicDashboard))
|
||||
}
|
||||
|
||||
func (module *module) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
|
||||
dashboard, err := module.Get(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := dashboard.ErrIfNotDeletable(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if dashboard.Locked {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "dashboard is locked, please unlock the dashboard to be delete it")
|
||||
}
|
||||
|
||||
return module.delete(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (module *module) DeleteUnsafe(ctx context.Context, orgID, id valuer.UUID) error {
|
||||
return module.delete(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (module *module) DeletePublic(ctx context.Context, orgID valuer.UUID, dashboardID valuer.UUID) error {
|
||||
_, err := module.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
dashboard, err := module.GetV2(ctx, orgID, dashboardID)
|
||||
dashboard, err := module.Get(ctx, orgID, dashboardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -212,6 +260,10 @@ func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[strin
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (module *module) Create(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, source dashboardtypes.Source, data dashboardtypes.PostableDashboard) (*dashboardtypes.Dashboard, error) {
|
||||
return module.pkgDashboardModule.Create(ctx, orgID, createdBy, creator, source, data)
|
||||
}
|
||||
|
||||
func (module *module) CreateV2(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, source dashboardtypes.Source, postable dashboardtypes.PostableDashboardV2) (*dashboardtypes.DashboardV2, error) {
|
||||
return module.pkgDashboardModule.CreateV2(ctx, orgID, createdBy, creator, source, postable)
|
||||
}
|
||||
@@ -294,10 +346,30 @@ func (module *module) DeleteView(ctx context.Context, orgID valuer.UUID, id valu
|
||||
return module.pkgDashboardModule.DeleteView(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.Dashboard, error) {
|
||||
return module.pkgDashboardModule.Get(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (module *module) GetByMetricNames(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string][]dashboardtypes.DashboardPanelRef, error) {
|
||||
return module.pkgDashboardModule.GetByMetricNames(ctx, orgID, metricNames)
|
||||
}
|
||||
|
||||
func (module *module) GetByMetricNamesV2(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string][]dashboardtypes.DashboardPanelRef, error) {
|
||||
return module.pkgDashboardModule.GetByMetricNamesV2(ctx, orgID, metricNames)
|
||||
}
|
||||
|
||||
func (module *module) List(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.Dashboard, error) {
|
||||
return module.pkgDashboardModule.List(ctx, orgID)
|
||||
}
|
||||
|
||||
func (module *module) Update(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, data dashboardtypes.UpdatableDashboard, diff int) (*dashboardtypes.Dashboard, error) {
|
||||
return module.pkgDashboardModule.Update(ctx, orgID, id, updatedBy, data, diff)
|
||||
}
|
||||
|
||||
func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
|
||||
return module.pkgDashboardModule.LockUnlock(ctx, orgID, id, updatedBy, isAdmin, lock)
|
||||
}
|
||||
|
||||
func (module *module) ReconcileSystemDashboards(ctx context.Context, orgID valuer.UUID) error {
|
||||
return module.pkgDashboardModule.ReconcileSystemDashboards(ctx, orgID)
|
||||
}
|
||||
@@ -305,3 +377,12 @@ func (module *module) ReconcileSystemDashboards(ctx context.Context, orgID value
|
||||
func (module *module) GetSystemDashboard(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error) {
|
||||
return module.pkgDashboardModule.GetSystemDashboard(ctx, orgID, name)
|
||||
}
|
||||
|
||||
func (module *module) delete(ctx context.Context, orgID, id valuer.UUID) error {
|
||||
return module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
if err := module.store.DeletePublic(ctx, id.String()); err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return err
|
||||
}
|
||||
return module.store.Delete(ctx, orgID, id)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -462,7 +462,7 @@ func (m *module) relatedAssetImpact(ctx context.Context, orgID valuer.UUID, metr
|
||||
droppedSet[label] = struct{}{}
|
||||
}
|
||||
|
||||
if dashboards, err := m.dashboard.GetByMetricNamesV2(ctx, orgID, []string{metricName}); err != nil {
|
||||
if dashboards, err := m.dashboard.GetByMetricNames(ctx, orgID, []string{metricName}); err != nil {
|
||||
m.logger.WarnContext(ctx, "failed to fetch related dashboards for reduction preview", slog.String("metric_name", metricName), errors.Attr(err))
|
||||
} else {
|
||||
for _, item := range dashboards[metricName] {
|
||||
|
||||
@@ -295,6 +295,8 @@
|
||||
// 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,12 +27,22 @@ 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`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -55,12 +65,12 @@ const isExcluded = (plugin: PluginOption): boolean =>
|
||||
|
||||
const config: StorybookConfig = {
|
||||
framework: '@storybook/react-vite',
|
||||
stories: ['../src/**/*.stories.@(ts|tsx)'],
|
||||
stories: ['../src/storybook/docs/**/*.mdx', '../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'],
|
||||
addons: ['@storybook/addon-a11y', '@storybook/addon-docs'],
|
||||
core: { disableTelemetry: true },
|
||||
viteFinal: async (viteConfig) => {
|
||||
const plugins = (viteConfig.plugins ?? [])
|
||||
@@ -77,6 +87,14 @@ 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,6 +6,17 @@
|
||||
-->
|
||||
<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>
|
||||
@@ -24,3 +35,38 @@
|
||||
},
|
||||
};
|
||||
</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,6 +3,8 @@ 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';
|
||||
@@ -13,7 +15,12 @@ import {
|
||||
} from '../src/storybook/runtime/resolveStory';
|
||||
import { allModes } from './modes';
|
||||
|
||||
import '../src/ReactI18';
|
||||
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/styles.scss';
|
||||
|
||||
@@ -63,10 +70,127 @@ 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'],
|
||||
'Traces',
|
||||
['Explorer', 'Trace Details', 'Funnel Details'],
|
||||
'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
|
||||
@@ -74,6 +198,9 @@ 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',
|
||||
@@ -119,12 +246,21 @@ const preview: Preview = {
|
||||
world.apply();
|
||||
world.install(worker);
|
||||
|
||||
await ready;
|
||||
await Promise.all([ready, translationsReady]);
|
||||
},
|
||||
],
|
||||
beforeEach: () => {
|
||||
clearBlockedNavigations();
|
||||
resetStoryHistory();
|
||||
|
||||
// The runner clears its console/network buffer before it navigates, so
|
||||
// anything the outgoing story still has in flight would be reported
|
||||
// against this one. Stamping the moment this story starts gives the runner
|
||||
// a line to discard those by. `Date.now()` is faked for the stories, so
|
||||
// this reads the one clock the runner's own timestamps share.
|
||||
document.body.dataset.signozStoryStartedAt = String(
|
||||
performance.timeOrigin + performance.now(),
|
||||
);
|
||||
},
|
||||
// After `play`, which is the moment both capture stacks shoot at.
|
||||
afterEach: settleForCapture,
|
||||
|
||||
@@ -88,10 +88,16 @@ self.addEventListener('fetch', function (event) {
|
||||
const { request } = event
|
||||
const accept = request.headers.get('accept') || ''
|
||||
|
||||
// Bypass server-sent events.
|
||||
if (accept.includes('text/event-stream')) {
|
||||
return
|
||||
}
|
||||
// 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 navigation requests.
|
||||
if (request.mode === 'navigate') {
|
||||
|
||||
@@ -25,7 +25,23 @@ const IGNORED_MESSAGES = [
|
||||
/violates the following Content Security Policy directive/,
|
||||
];
|
||||
|
||||
const messagesByPage = new WeakMap<Page, string[]>();
|
||||
interface CapturedMessage {
|
||||
at: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const messagesByPage = new WeakMap<Page, CapturedMessage[]>();
|
||||
|
||||
/**
|
||||
* When the story under test started rendering, stamped by the preview's
|
||||
* `beforeEach`. Messages captured before it belong to the previous story: the
|
||||
* runner clears this buffer ahead of the navigation, so whatever that story
|
||||
* still had in flight lands here.
|
||||
*/
|
||||
const storyStartedAt = (page: Page): Promise<number> =>
|
||||
page
|
||||
.evaluate(() => Number(document.body.dataset.signozStoryStartedAt ?? 0))
|
||||
.catch(() => 0);
|
||||
|
||||
/**
|
||||
* Only `console.error` fails a story. `console.warn` is dev-time advice from
|
||||
@@ -43,14 +59,14 @@ const config: TestRunnerConfig = {
|
||||
return;
|
||||
}
|
||||
|
||||
const messages: string[] = [];
|
||||
const messages: CapturedMessage[] = [];
|
||||
messagesByPage.set(page, messages);
|
||||
page.on('console', (message) => {
|
||||
if (
|
||||
message.type() === 'error' &&
|
||||
!IGNORED_MESSAGES.some((pattern) => pattern.test(message.text()))
|
||||
) {
|
||||
messages.push(`[error] ${message.text()}`);
|
||||
messages.push({ at: Date.now(), text: `[error] ${message.text()}` });
|
||||
}
|
||||
});
|
||||
// The console message alone ("Failed to load resource") doesn't name the
|
||||
@@ -58,12 +74,23 @@ const config: TestRunnerConfig = {
|
||||
// actionable instead of just a status code.
|
||||
page.on('response', (response) => {
|
||||
if (response.status() >= 400) {
|
||||
messages.push(`[response] ${response.status()} ${response.url()}`);
|
||||
messages.push({
|
||||
at: Date.now(),
|
||||
text: `[response] ${response.status()} ${response.url()}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
async postVisit(page, context): Promise<void> {
|
||||
const messages = messagesByPage.get(page) ?? [];
|
||||
const captured = messagesByPage.get(page) ?? [];
|
||||
if (captured.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startedAt = await storyStartedAt(page);
|
||||
const messages = captured
|
||||
.filter((message) => message.at >= startedAt)
|
||||
.map((message) => message.text);
|
||||
if (messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -162,6 +162,7 @@
|
||||
"@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",
|
||||
|
||||
41
frontend/plugins/rules/no-msw-in-story-file.mjs
Normal file
41
frontend/plugins/rules/no-msw-in-story-file.mjs
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 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,6 +15,7 @@ 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: {
|
||||
@@ -31,5 +32,6 @@ 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
@@ -363,6 +363,9 @@ 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.9.1))(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.9.1))(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))(typescript@5.9.3)
|
||||
@@ -2184,6 +2187,12 @@ 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==}
|
||||
|
||||
@@ -3766,6 +3775,15 @@ 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:
|
||||
@@ -4189,6 +4207,9 @@ 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==}
|
||||
|
||||
@@ -12199,6 +12220,12 @@ 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
|
||||
@@ -13619,6 +13646,25 @@ 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.9.1))(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.9.1))(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.9.1))(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.9.1))(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
|
||||
@@ -14064,6 +14110,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.2
|
||||
|
||||
'@types/mdx@2.0.14': {}
|
||||
|
||||
'@types/ms@0.7.31': {}
|
||||
|
||||
'@types/node@16.18.25': {}
|
||||
|
||||
@@ -61,7 +61,6 @@
|
||||
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
|
||||
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
|
||||
"AI_OBSERVABILITY_OVERVIEW": "SigNoz | AI Observability Overview",
|
||||
"AI_OBSERVABILITY_EXPLORER": "SigNoz | AI Observability Explorer",
|
||||
"AI_OBSERVABILITY_CONFIGURATION": "SigNoz | AI Observability Configuration",
|
||||
"AI_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | AI Observability Attribute Mapping"
|
||||
}
|
||||
@@ -42,6 +42,7 @@
|
||||
"NOT_FOUND": "SigNoz | Page Not Found",
|
||||
"LOGS": "SigNoz | Logs",
|
||||
"LOGS_EXPLORER": "SigNoz | Logs Explorer",
|
||||
"OLD_LOGS_EXPLORER": "SigNoz | Old Logs Explorer",
|
||||
"LIVE_LOGS": "SigNoz | Live Logs",
|
||||
"LOGS_PIPELINES": "SigNoz | Logs Pipelines",
|
||||
"HOME_PAGE": "Open source Observability Platform | SigNoz",
|
||||
@@ -85,7 +86,6 @@
|
||||
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
|
||||
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
|
||||
"AI_OBSERVABILITY_OVERVIEW": "SigNoz | AI Observability Overview",
|
||||
"AI_OBSERVABILITY_EXPLORER": "SigNoz | AI Observability Explorer",
|
||||
"AI_OBSERVABILITY_CONFIGURATION": "SigNoz | AI Observability Configuration",
|
||||
"AI_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | AI Observability Attribute Mapping"
|
||||
}
|
||||
@@ -15,6 +15,8 @@ export const CONFIG_KEYS = [
|
||||
'motion',
|
||||
'settle',
|
||||
'ignore',
|
||||
'highlight',
|
||||
'crop',
|
||||
];
|
||||
|
||||
let tools;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { parseArgs } from 'node:util';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
import {
|
||||
bodyFont,
|
||||
@@ -18,7 +17,8 @@ import {
|
||||
|
||||
/**
|
||||
* Pairs the PNGs of two story-shots.mjs runs by relative path and reports what
|
||||
* moved, per pair, largest first.
|
||||
* moved, per pair, largest first. A shot only one run has is reported too,
|
||||
* labelled with the side it is missing from and counted as changed in full.
|
||||
*
|
||||
* The comparison is Chromatic's: a pixel counts as changed when its YIQ
|
||||
* distance from the baseline pixel is over `threshold` of the largest distance
|
||||
@@ -55,7 +55,8 @@ if (opts.help || !baseDir || !afterDir || !MODES.has(opts.mode)) {
|
||||
--tint <#rrggbb> override the mode's highlight colour
|
||||
--no-caption do not stamp the story and the run settings on top
|
||||
|
||||
Prints "<changed pixels> <relative path>", largest first. Needs ImageMagick.`);
|
||||
Prints "<changed pixels> <relative path>", largest first; a shot only one run
|
||||
has is printed as "(missing previous)" or "(missing current)". Needs ImageMagick.`);
|
||||
process.exit(opts.help ? 0 : 1);
|
||||
}
|
||||
|
||||
@@ -325,6 +326,11 @@ const shotOf = (run, rel) =>
|
||||
|
||||
const captionOf = (run, rel) => shotOf(run, rel)?.caption ?? 0;
|
||||
|
||||
/** What the shot was shot in, falling back to the directory it sits in. */
|
||||
const themeOf = (rel) =>
|
||||
(shotOf(afterRun, rel) ?? shotOf(baseRun, rel))?.theme ??
|
||||
rel.split(path.sep)[0];
|
||||
|
||||
/** ImageMagick's inline crop, so a tile shows the shot without its caption. */
|
||||
const withoutCaption = (file, { width, height }, top) =>
|
||||
top > 0 ? `${file}[${width}x${height}+0+${top}]` : file;
|
||||
@@ -369,20 +375,156 @@ const pngs = async (dir, prefix = '') => {
|
||||
const results = [];
|
||||
await mkdir(outDir, { recursive: true });
|
||||
|
||||
for (const rel of (await pngs(baseDir)).sort()) {
|
||||
/** The half-built tiles, under the output directory so nothing is left elsewhere. */
|
||||
const scratch = Object.fromEntries(
|
||||
['body', 'diff', 'shot', 'missing'].map((name) => [
|
||||
name,
|
||||
path.join(outDir, `.story-shots-${process.pid}-${name}.png`),
|
||||
]),
|
||||
);
|
||||
|
||||
/** One labelled tile of a parallel montage. */
|
||||
const tile = (label, file, background) => [
|
||||
'(',
|
||||
`label:${literal(label)}`,
|
||||
file,
|
||||
'-gravity',
|
||||
'center',
|
||||
'-append',
|
||||
'-bordercolor',
|
||||
background,
|
||||
'-border',
|
||||
'12',
|
||||
')',
|
||||
];
|
||||
|
||||
/** The tiles side by side under one caption. */
|
||||
const montage = ({
|
||||
tiles,
|
||||
width,
|
||||
background,
|
||||
foreground,
|
||||
caption,
|
||||
target,
|
||||
theme,
|
||||
}) => {
|
||||
magick([
|
||||
'-background',
|
||||
background,
|
||||
'-fill',
|
||||
foreground,
|
||||
...bodyFont(),
|
||||
'-pointsize',
|
||||
// The tiles end up side by side, so they are read at the montage's width.
|
||||
String(Math.round(pointsize(width * 3) * 0.62)),
|
||||
...tiles.flat(),
|
||||
'-gravity',
|
||||
'north',
|
||||
'+append',
|
||||
caption.length ? scratch.body : target,
|
||||
]);
|
||||
if (caption.length) {
|
||||
stamp({ lines: caption, from: scratch.body, to: target, theme });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* A tile standing in for a shot the run does not have, sized like the one it
|
||||
* does. The gutter's colours are the theme's own inverted, so they go back the
|
||||
* other way here and the tile reads as a shot rather than as a hole.
|
||||
*/
|
||||
const placeholder = (
|
||||
file,
|
||||
{ width, height },
|
||||
text,
|
||||
{ background, foreground },
|
||||
) =>
|
||||
magick([
|
||||
'-size',
|
||||
`${width}x${height}`,
|
||||
'-background',
|
||||
foreground,
|
||||
'-fill',
|
||||
background,
|
||||
'-gravity',
|
||||
'center',
|
||||
...bodyFont(),
|
||||
'-pointsize',
|
||||
String(pointsize(width * 3)),
|
||||
`label:${literal(text)}`,
|
||||
file,
|
||||
]);
|
||||
|
||||
const [baseFiles, afterFiles] = await Promise.all([
|
||||
pngs(baseDir),
|
||||
pngs(afterDir),
|
||||
]);
|
||||
const inBase = new Set(baseFiles);
|
||||
const inAfter = new Set(afterFiles);
|
||||
|
||||
for (const rel of [...new Set([...baseFiles, ...afterFiles])].sort((a, b) =>
|
||||
a.localeCompare(b),
|
||||
)) {
|
||||
const afterFile = path.join(afterDir, rel);
|
||||
const base = readRgba(path.join(baseDir, rel), captionOf(baseRun, rel));
|
||||
let after;
|
||||
try {
|
||||
after = readRgba(afterFile, captionOf(afterRun, rel));
|
||||
} catch {
|
||||
console.error(`missing in after: ${rel}`);
|
||||
const target = path.join(outDir, rel);
|
||||
await mkdir(path.join(outDir, path.dirname(rel)), { recursive: true });
|
||||
|
||||
// A story added, removed or renamed since the baseline has nothing to
|
||||
// compare against, so the side that does have it is written out under the
|
||||
// label of the side that does not, and every one of its pixels counts.
|
||||
if (!inBase.has(rel) || !inAfter.has(rel)) {
|
||||
const gone = inAfter.has(rel) ? 'previous' : 'current';
|
||||
const held = gone === 'previous' ? 'current' : 'previous';
|
||||
const run = gone === 'previous' ? afterRun : baseRun;
|
||||
const image = readRgba(
|
||||
gone === 'previous' ? afterFile : path.join(baseDir, rel),
|
||||
captionOf(run, rel),
|
||||
);
|
||||
const theme = themeOf(rel);
|
||||
const colors = palette(theme);
|
||||
const caption = captionLines(rel, [`missing ${gone}`]);
|
||||
|
||||
if (opts.mode.endsWith('-parallel')) {
|
||||
// The montage keeps its three tiles: the run that has the shot shows it,
|
||||
// and the run that does not, like the diff, says so in its place. There
|
||||
// is nothing to compare, so nothing is tinted.
|
||||
await writeRgba(image, scratch.shot);
|
||||
placeholder(scratch.missing, image, `missing ${gone}`, colors);
|
||||
|
||||
const sides = {
|
||||
[held]: tile(sideLabel(held, run), scratch.shot, colors.background),
|
||||
[gone]: tile(
|
||||
sideLabel(gone, gone === 'previous' ? baseRun : afterRun),
|
||||
scratch.missing,
|
||||
colors.background,
|
||||
),
|
||||
};
|
||||
montage({
|
||||
tiles: [
|
||||
sides.previous,
|
||||
sides.current,
|
||||
tile('diff', scratch.missing, colors.background),
|
||||
],
|
||||
width: image.width,
|
||||
...colors,
|
||||
caption,
|
||||
target,
|
||||
theme,
|
||||
});
|
||||
} else {
|
||||
await writeRgba(image, caption.length ? scratch.body : target);
|
||||
if (caption.length) {
|
||||
stamp({ lines: caption, from: scratch.body, to: target, theme });
|
||||
}
|
||||
}
|
||||
|
||||
results.push([image.width * image.height, rel, `missing ${gone}`]);
|
||||
continue;
|
||||
}
|
||||
|
||||
await mkdir(path.join(outDir, path.dirname(rel)), { recursive: true });
|
||||
const base = readRgba(path.join(baseDir, rel), captionOf(baseRun, rel));
|
||||
const after = readRgba(afterFile, captionOf(afterRun, rel));
|
||||
const diff = diffPair(base, after, opts.mode);
|
||||
const target = path.join(outDir, rel);
|
||||
const parallel = opts.mode.endsWith('-parallel');
|
||||
// With no tiles to label, a run's own settings go in the caption instead.
|
||||
const caption = captionLines(
|
||||
@@ -391,70 +533,54 @@ for (const rel of (await pngs(baseDir)).sort()) {
|
||||
? []
|
||||
: [sideLabel('previous', baseRun), sideLabel('current', afterRun)],
|
||||
);
|
||||
const diffFile = path.join(os.tmpdir(), `story-shots-${process.pid}.png`);
|
||||
const body = path.join(os.tmpdir(), `story-shots-${process.pid}-body.png`);
|
||||
|
||||
// The gutter is the opposite of the theme's own background, so the tiles and
|
||||
// the caption keep an edge instead of bleeding into it.
|
||||
const shot = shotOf(afterRun, rel) ?? shotOf(baseRun, rel);
|
||||
const theme = shot?.theme ?? rel.split(path.sep)[0];
|
||||
const theme = themeOf(rel);
|
||||
const { background, foreground } = palette(theme);
|
||||
|
||||
if (parallel) {
|
||||
await writeRgba(diff, diffFile);
|
||||
const tile = (label, file) => [
|
||||
'(',
|
||||
`label:${literal(label)}`,
|
||||
file,
|
||||
'-gravity',
|
||||
'center',
|
||||
'-append',
|
||||
'-bordercolor',
|
||||
await writeRgba(diff, scratch.diff);
|
||||
montage({
|
||||
tiles: [
|
||||
tile(
|
||||
sideLabel('previous', baseRun),
|
||||
withoutCaption(path.join(baseDir, rel), base, captionOf(baseRun, rel)),
|
||||
background,
|
||||
),
|
||||
tile(
|
||||
sideLabel('current', afterRun),
|
||||
withoutCaption(afterFile, after, captionOf(afterRun, rel)),
|
||||
background,
|
||||
),
|
||||
tile('diff', scratch.diff, background),
|
||||
],
|
||||
width: after.width,
|
||||
background,
|
||||
'-border',
|
||||
'12',
|
||||
')',
|
||||
];
|
||||
magick([
|
||||
'-background',
|
||||
background,
|
||||
'-fill',
|
||||
foreground,
|
||||
...bodyFont(),
|
||||
'-pointsize',
|
||||
// The tiles end up side by side, so they are read at the montage's width.
|
||||
String(Math.round(pointsize(after.width * 3) * 0.62)),
|
||||
...tile(
|
||||
sideLabel('previous', baseRun),
|
||||
withoutCaption(path.join(baseDir, rel), base, captionOf(baseRun, rel)),
|
||||
),
|
||||
...tile(
|
||||
sideLabel('current', afterRun),
|
||||
withoutCaption(afterFile, after, captionOf(afterRun, rel)),
|
||||
),
|
||||
...tile('diff', diffFile),
|
||||
'-gravity',
|
||||
'north',
|
||||
'+append',
|
||||
caption.length ? body : target,
|
||||
]);
|
||||
if (caption.length) {
|
||||
stamp({ lines: caption, from: body, to: target, theme });
|
||||
}
|
||||
caption,
|
||||
target,
|
||||
theme,
|
||||
});
|
||||
} else {
|
||||
await writeRgba(diff, caption.length ? body : target);
|
||||
await writeRgba(diff, caption.length ? scratch.body : target);
|
||||
if (caption.length) {
|
||||
stamp({ lines: caption, from: body, to: target, theme });
|
||||
stamp({ lines: caption, from: scratch.body, to: target, theme });
|
||||
}
|
||||
}
|
||||
|
||||
results.push([diff.changed, rel]);
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
Object.values(scratch).map((file) => rm(file, { force: true })),
|
||||
);
|
||||
|
||||
results
|
||||
.sort((a, b) => b[0] - a[0])
|
||||
.forEach(([changed, rel]) =>
|
||||
console.log(`${String(changed).padStart(10)} ${rel}`),
|
||||
);
|
||||
.forEach(([changed, rel, note]) => {
|
||||
const suffix = note ? ` (${note})` : '';
|
||||
console.log(`${String(changed).padStart(10)} ${rel}${suffix}`);
|
||||
});
|
||||
|
||||
console.error(`diffs in ${outDir}`);
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdir, rename, writeFile } from 'node:fs/promises';
|
||||
import { renameSync, rmSync } from 'node:fs';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import { parseArgs } from 'node:util';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
import {
|
||||
bodyFont,
|
||||
CONFIG_KEYS,
|
||||
hasMagick,
|
||||
literal,
|
||||
magick,
|
||||
palette,
|
||||
settingsLine,
|
||||
stamp,
|
||||
} from './story-shots-caption.mjs';
|
||||
@@ -39,6 +43,8 @@ const { values: opts, positionals } = parseArgs({
|
||||
clock: { type: 'string', default: FROZEN_CLOCK },
|
||||
motion: { type: 'boolean', default: false },
|
||||
ignore: { type: 'string', multiple: true, default: [] },
|
||||
highlight: { type: 'string', multiple: true, default: [] },
|
||||
crop: { type: 'string', multiple: true, default: [] },
|
||||
flat: { type: 'boolean', default: false },
|
||||
'no-caption': { type: 'boolean', default: false },
|
||||
list: { type: 'boolean', default: false },
|
||||
@@ -72,6 +78,10 @@ if (opts.help || (!outDir && !opts.list)) {
|
||||
--clock <iso|live> wall clock the page reads (default ${FROZEN_CLOCK})
|
||||
--motion keep animations and transitions running
|
||||
--ignore <selector> hide matching elements, on top of [data-shot-ignore]
|
||||
--highlight <selector>
|
||||
also shoot <id>--highlight.png, every match ringed in red
|
||||
--crop <selector> also write one <id>--<n>.png per match under <theme>/crops,
|
||||
and montage them into <theme>/crops.png
|
||||
--flat write <out>/<id>.png instead of <out>/<theme>/<id>.png
|
||||
--no-caption do not stamp the story and the run settings on the shot
|
||||
--list print the matched stories and exit
|
||||
@@ -135,10 +145,17 @@ if (!stories.length) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const ignoreSelectors = opts.ignore
|
||||
.flatMap((value) => value.split(','))
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
/** A repeatable, comma-separated flag read as one CSS selector list. */
|
||||
const selectorList = (values) =>
|
||||
values
|
||||
.flatMap((value) => value.split(','))
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
|
||||
const ignoreSelectors = selectorList(opts.ignore);
|
||||
const highlightSelector = selectorList(opts.highlight);
|
||||
const cropSelector = selectorList(opts.crop);
|
||||
|
||||
if (opts.clock !== 'live' && Number.isNaN(Date.parse(opts.clock))) {
|
||||
console.error(`--clock: not a date: ${opts.clock}`);
|
||||
@@ -152,13 +169,12 @@ if (opts.clock !== 'live' && Number.isNaN(Date.parse(opts.clock))) {
|
||||
* their bottom - is done by the preview itself, so a Chromatic build and a shot
|
||||
* from here see the same page.
|
||||
*/
|
||||
const ignoreCss = (
|
||||
ignore,
|
||||
) => `[data-shot-ignore], [data-chromatic='ignore']${ignore
|
||||
.map((selector) => `, ${selector}`)
|
||||
.join('')} {
|
||||
const ignoreCss = (ignore) => {
|
||||
const extra = ignore ? `, ${ignore}` : '';
|
||||
return `[data-shot-ignore], [data-chromatic='ignore']${extra} {
|
||||
visibility: hidden !important;
|
||||
}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Playwright is not a frontend dependency: it lives in `tests/e2e`, or globally,
|
||||
@@ -248,7 +264,9 @@ const runConfig = {
|
||||
grow: opts.grow,
|
||||
motion: opts.motion ? 'live' : 'still',
|
||||
settle: opts.settle,
|
||||
ignore: ignoreSelectors.join(', '),
|
||||
ignore: ignoreSelectors,
|
||||
highlight: highlightSelector,
|
||||
crop: cropSelector,
|
||||
};
|
||||
|
||||
const captioning = !opts['no-caption'] && hasMagick();
|
||||
@@ -261,6 +279,8 @@ const configLine = settingsLine(runConfig, CONFIG_KEYS);
|
||||
|
||||
for (const theme of themes.length ? themes : [null]) {
|
||||
const dir = opts.flat ? outDir : path.join(outDir, theme ?? 'default');
|
||||
const cropDir = path.join(dir, 'crops');
|
||||
const crops = [];
|
||||
await mkdir(dir, { recursive: true });
|
||||
if (theme) {
|
||||
console.log(`\n[${theme}]`);
|
||||
@@ -295,6 +315,9 @@ for (const theme of themes.length ? themes : [null]) {
|
||||
// the viewport, kept only to flag the story in the log.
|
||||
let chasing = 0;
|
||||
|
||||
// What the story's line in the log says beyond ok/busy.
|
||||
const notes = [];
|
||||
|
||||
const url = new URL(`${base}/iframe.html`);
|
||||
url.searchParams.set('viewMode', 'story');
|
||||
url.searchParams.set('id', story.id);
|
||||
@@ -447,47 +470,160 @@ for (const theme of themes.length ? themes : [null]) {
|
||||
shot = next;
|
||||
}
|
||||
|
||||
const file = path.join(dir, `${story.id}.png`);
|
||||
await writeFile(file, shot);
|
||||
/**
|
||||
* The band goes on the shot itself so a single screenshot says what it
|
||||
* is, and its height is returned so a diff can take it back off. The
|
||||
* temporary is written beside the shot rather than in the system temp
|
||||
* directory: those are often separate filesystems, and a rename across
|
||||
* one fails with EXDEV.
|
||||
*/
|
||||
const caption = (target, lines) => {
|
||||
if (!captioning) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// The band goes on the shot itself so a single screenshot says what it
|
||||
// is, and its height is recorded so a diff can take it back off.
|
||||
let caption = 0;
|
||||
if (captioning) {
|
||||
const temporary = path.join(
|
||||
os.tmpdir(),
|
||||
`story-shots-caption-${process.pid}.png`,
|
||||
path.dirname(target),
|
||||
`.caption-${process.pid}.png`,
|
||||
);
|
||||
caption = stamp({
|
||||
const rows = stamp({
|
||||
lines: [
|
||||
`${story.title}/${story.name}`,
|
||||
[story.id, theme ?? 'default', stable ? '' : '(busy)']
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
configLine,
|
||||
...lines,
|
||||
].filter(Boolean),
|
||||
from: file,
|
||||
from: target,
|
||||
to: temporary,
|
||||
theme: theme ?? 'dark',
|
||||
});
|
||||
await rename(temporary, file);
|
||||
renameSync(temporary, target);
|
||||
return rows;
|
||||
};
|
||||
|
||||
const file = path.join(dir, `${story.id}.png`);
|
||||
await writeFile(file, shot);
|
||||
|
||||
const record = (relative, caption) =>
|
||||
shots.push({
|
||||
file: path.posix.join(opts.flat ? '' : (theme ?? 'default'), relative),
|
||||
id: story.id,
|
||||
title: story.title,
|
||||
name: story.name,
|
||||
theme: theme ?? 'default',
|
||||
status: stable ? 'ok' : 'busy',
|
||||
caption,
|
||||
});
|
||||
|
||||
record(`${story.id}.png`, caption(file, [configLine]));
|
||||
|
||||
// The crops are taken before anything is drawn over the page, so a
|
||||
// component's own shot carries no ring and no label: the montage at the
|
||||
// end of the theme is what names them.
|
||||
if (cropSelector) {
|
||||
await mkdir(cropDir, { recursive: true });
|
||||
|
||||
// A page that sizes itself in `vh` was shot back at `--height` with
|
||||
// its own scrollbar, and what is below the fold there is laid out but
|
||||
// never painted: cropping it gives a black rectangle. The crops alone
|
||||
// are taken at the height the rounds had reached, which is where the
|
||||
// page does paint.
|
||||
if (chasing) {
|
||||
await page.setViewportSize({
|
||||
width: Number(opts.width),
|
||||
height: chasing,
|
||||
});
|
||||
await page.waitForTimeout(Number(opts.settle));
|
||||
}
|
||||
|
||||
const matches = page.locator(cropSelector);
|
||||
let kept = 0;
|
||||
for (let index = 0; index < (await matches.count()); index += 1) {
|
||||
const element = matches.nth(index);
|
||||
// A group whose children are all conditional renders as a 0x0 box.
|
||||
// It has no counterpart on screen, so there is nothing to crop.
|
||||
const box = await element.boundingBox();
|
||||
if (!box || box.width < 1 || box.height < 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
kept += 1;
|
||||
const relative = `${story.id}--${kept}.png`;
|
||||
// The scroll that brings an element into view needs a frame before
|
||||
// the crop, or the region comes back unpainted.
|
||||
await element.scrollIntoViewIfNeeded({ timeout: 15_000 });
|
||||
await page.waitForTimeout(250);
|
||||
await element.screenshot({
|
||||
path: path.join(cropDir, relative),
|
||||
timeout: 15_000,
|
||||
});
|
||||
crops.push({
|
||||
file: path.join(cropDir, relative),
|
||||
label: `${story.title}/${story.name} #${kept}`,
|
||||
});
|
||||
record(path.posix.join('crops', relative), 0);
|
||||
}
|
||||
notes.push(`${kept} cropped`);
|
||||
|
||||
if (chasing) {
|
||||
await page.setViewportSize({
|
||||
width: Number(opts.width),
|
||||
height: Number(opts.height),
|
||||
});
|
||||
await page.waitForTimeout(Number(opts.settle));
|
||||
}
|
||||
}
|
||||
|
||||
shots.push({
|
||||
file: path.posix.join(
|
||||
opts.flat ? '' : (theme ?? 'default'),
|
||||
`${story.id}.png`,
|
||||
),
|
||||
id: story.id,
|
||||
title: story.title,
|
||||
name: story.name,
|
||||
theme: theme ?? 'default',
|
||||
status: stable ? 'ok' : 'busy',
|
||||
caption,
|
||||
});
|
||||
if (highlightSelector) {
|
||||
const ringed = await page.evaluate(
|
||||
([selector, padding]) => {
|
||||
const layer = document.createElement('div');
|
||||
// The shot is viewport-sized, so the rings are placed in viewport
|
||||
// coordinates and survive a page that stayed scrollable.
|
||||
layer.style.cssText =
|
||||
'position:fixed;inset:0;pointer-events:none;z-index:2147483647';
|
||||
let drawn = 0;
|
||||
for (const element of document.querySelectorAll(selector)) {
|
||||
const box = element.getBoundingClientRect();
|
||||
if (box.width < 1 || box.height < 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
drawn += 1;
|
||||
const ring = document.createElement('div');
|
||||
ring.style.cssText = `position:fixed;box-sizing:border-box;border:3px solid #ff003a;border-radius:4px;left:${
|
||||
box.left - padding
|
||||
}px;top:${box.top - padding}px;width:${
|
||||
box.width + padding * 2
|
||||
}px;height:${box.height + padding * 2}px`;
|
||||
layer.append(ring);
|
||||
}
|
||||
document.documentElement.append(layer);
|
||||
window.__storyShotsHighlight = layer;
|
||||
return drawn;
|
||||
},
|
||||
[highlightSelector, 6],
|
||||
);
|
||||
|
||||
const highlighted = path.join(dir, `${story.id}--highlight.png`);
|
||||
await writeFile(highlighted, await page.screenshot());
|
||||
record(
|
||||
`${story.id}--highlight.png`,
|
||||
caption(highlighted, [`${ringed} highlighted`, configLine]),
|
||||
);
|
||||
notes.push(`${ringed} highlighted`);
|
||||
await page.evaluate(() => {
|
||||
window.__storyShotsHighlight?.remove();
|
||||
delete window.__storyShotsHighlight;
|
||||
});
|
||||
}
|
||||
if (chasing) {
|
||||
notes.push(`viewport-sized content, stopped chasing ${chasing}px`);
|
||||
}
|
||||
console.log(
|
||||
` ${stable ? 'ok ' : 'busy'} ${story.id}${
|
||||
chasing ? ` (viewport-sized content, stopped chasing ${chasing}px)` : ''
|
||||
notes.length ? ` (${notes.join(', ')})` : ''
|
||||
}`,
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -497,6 +633,52 @@ for (const theme of themes.length ? themes : [null]) {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
|
||||
// One image of every crop the theme produced, labelled with the story it came
|
||||
// from. A component that appears on eight pages is a survey rather than eight
|
||||
// screenshots to open one after another.
|
||||
if (crops.length && captioning) {
|
||||
const sheet = path.join(dir, 'crops.png');
|
||||
const { background, foreground } = palette(theme ?? 'dark');
|
||||
|
||||
// `montage -label` sizes every tile to the widest *image*, so a label
|
||||
// longer than its crop runs under the next one. Each crop is composed with
|
||||
// its own label first, which sizes the tile to whichever of the two is
|
||||
// wider, and the sheet is then a montage of finished tiles.
|
||||
const tiles = crops.map(({ file, label }, index) => {
|
||||
const tile = path.join(cropDir, `.tile-${index}.png`);
|
||||
magick([
|
||||
'-background',
|
||||
background,
|
||||
'-fill',
|
||||
foreground,
|
||||
...bodyFont(),
|
||||
'-pointsize',
|
||||
'16',
|
||||
file,
|
||||
`label:${literal(label)}`,
|
||||
'-gravity',
|
||||
'center',
|
||||
'-append',
|
||||
tile,
|
||||
]);
|
||||
return tile;
|
||||
});
|
||||
|
||||
magick([
|
||||
'montage',
|
||||
'-background',
|
||||
background,
|
||||
'-tile',
|
||||
'2x',
|
||||
'-geometry',
|
||||
'+16+16',
|
||||
...tiles,
|
||||
sheet,
|
||||
]);
|
||||
tiles.forEach((tile) => rmSync(tile, { force: true }));
|
||||
console.log(` ${crops.length} crops -> ${sheet}`);
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
@@ -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 "$@"
|
||||
pnpm exec test-storybook --ci --maxWorkers=2 --testTimeout 30000 "$@"
|
||||
|
||||
@@ -1597,6 +1597,10 @@ describe('PrivateRoute', () => {
|
||||
LOGS: { path: ROUTES.LOGS, deniedRoles: DENIED_ROLES },
|
||||
LOGS_EXPLORER: { path: ROUTES.LOGS_EXPLORER, deniedRoles: DENIED_ROLES },
|
||||
LIVE_LOGS: { path: ROUTES.LIVE_LOGS, deniedRoles: DENIED_ROLES },
|
||||
OLD_LOGS_EXPLORER: {
|
||||
path: ROUTES.OLD_LOGS_EXPLORER,
|
||||
deniedRoles: DENIED_ROLES,
|
||||
},
|
||||
METRICS_EXPLORER: {
|
||||
path: ROUTES.METRICS_EXPLORER,
|
||||
deniedRoles: DENIED_ROLES,
|
||||
|
||||
@@ -154,6 +154,14 @@ export const Logs = Loadable(
|
||||
() => import(/* webpackChunkName: "Logs" */ 'pages/LogsModulePage'),
|
||||
);
|
||||
|
||||
export const LogsExplorer = Loadable(
|
||||
() => import(/* webpackChunkName: "Logs Explorer" */ 'pages/LogsModulePage'),
|
||||
);
|
||||
|
||||
export const OldLogsExplorer = Loadable(
|
||||
() => import(/* webpackChunkName: "Logs Explorer" */ 'pages/Logs'),
|
||||
);
|
||||
|
||||
export const LiveLogs = Loadable(
|
||||
() => import(/* webpackChunkName: "Live Logs" */ 'pages/LiveLogs'),
|
||||
);
|
||||
|
||||
@@ -26,11 +26,13 @@ import {
|
||||
LiveLogs,
|
||||
Login,
|
||||
Logs,
|
||||
LogsExplorer,
|
||||
LogsIndexToFields,
|
||||
LogsSaveViews,
|
||||
MessagingQueuesMainPage,
|
||||
MeterExplorerPage,
|
||||
MetricsExplorer,
|
||||
OldLogsExplorer,
|
||||
OnboardingV2,
|
||||
OrgOnboarding,
|
||||
PasswordReset,
|
||||
@@ -282,6 +284,20 @@ const routes: AppRoutes[] = [
|
||||
key: 'LOGS',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.LOGS_EXPLORER,
|
||||
exact: true,
|
||||
component: LogsExplorer,
|
||||
key: 'LOGS_EXPLORER',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.OLD_LOGS_EXPLORER,
|
||||
exact: true,
|
||||
component: OldLogsExplorer,
|
||||
key: 'OLD_LOGS_EXPLORER',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.LIVE_LOGS,
|
||||
exact: true,
|
||||
|
||||
@@ -36,12 +36,16 @@ import type {
|
||||
GetDashboardV2200,
|
||||
GetDashboardV2PathParameters,
|
||||
GetPublicDashboard200,
|
||||
GetPublicDashboardData200,
|
||||
GetPublicDashboardDataPathParameters,
|
||||
GetPublicDashboardDataV2200,
|
||||
GetPublicDashboardDataV2PathParameters,
|
||||
GetPublicDashboardPanelQueryRangeV2200,
|
||||
GetPublicDashboardPanelQueryRangeV2Params,
|
||||
GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
GetPublicDashboardPathParameters,
|
||||
GetPublicDashboardWidgetQueryRange200,
|
||||
GetPublicDashboardWidgetQueryRangePathParameters,
|
||||
GetSystemDashboard200,
|
||||
GetSystemDashboardPathParameters,
|
||||
ListDashboardViews200,
|
||||
@@ -470,6 +474,217 @@ export const useUpdatePublicDashboard = <
|
||||
> => {
|
||||
return useMutation(getUpdatePublicDashboardMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint returns the sanitized dashboard data for public access
|
||||
* @summary Get public dashboard data
|
||||
*/
|
||||
export const getPublicDashboardData = (
|
||||
{ id }: GetPublicDashboardDataPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetPublicDashboardData200>({
|
||||
url: `/api/v1/public/dashboards/${id}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetPublicDashboardDataQueryKey = ({
|
||||
id,
|
||||
}: GetPublicDashboardDataPathParameters) => {
|
||||
return [`/api/v1/public/dashboards/${id}`] as const;
|
||||
};
|
||||
|
||||
export const getGetPublicDashboardDataQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getPublicDashboardData>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetPublicDashboardDataPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardData>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetPublicDashboardDataQueryKey({ id });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getPublicDashboardData>>
|
||||
> = ({ signal }) => getPublicDashboardData({ id }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: id !== null && id !== undefined,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardData>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetPublicDashboardDataQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getPublicDashboardData>>
|
||||
>;
|
||||
export type GetPublicDashboardDataQueryError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get public dashboard data
|
||||
*/
|
||||
|
||||
export function useGetPublicDashboardData<
|
||||
TData = Awaited<ReturnType<typeof getPublicDashboardData>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetPublicDashboardDataPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardData>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetPublicDashboardDataQueryOptions({ id }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get public dashboard data
|
||||
*/
|
||||
export const invalidateGetPublicDashboardData = async (
|
||||
queryClient: QueryClient,
|
||||
{ id }: GetPublicDashboardDataPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetPublicDashboardDataQueryKey({ id }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint return query range results for a widget of public dashboard
|
||||
* @summary Get query range result
|
||||
*/
|
||||
export const getPublicDashboardWidgetQueryRange = (
|
||||
{ id, idx }: GetPublicDashboardWidgetQueryRangePathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetPublicDashboardWidgetQueryRange200>({
|
||||
url: `/api/v1/public/dashboards/${id}/widgets/${idx}/query_range`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetPublicDashboardWidgetQueryRangeQueryKey = ({
|
||||
id,
|
||||
idx,
|
||||
}: GetPublicDashboardWidgetQueryRangePathParameters) => {
|
||||
return [`/api/v1/public/dashboards/${id}/widgets/${idx}/query_range`] as const;
|
||||
};
|
||||
|
||||
export const getGetPublicDashboardWidgetQueryRangeQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getPublicDashboardWidgetQueryRange>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id, idx }: GetPublicDashboardWidgetQueryRangePathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardWidgetQueryRange>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ??
|
||||
getGetPublicDashboardWidgetQueryRangeQueryKey({ id, idx });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getPublicDashboardWidgetQueryRange>>
|
||||
> = ({ signal }) => getPublicDashboardWidgetQueryRange({ id, idx }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: id !== null && id !== undefined && idx !== null && idx !== undefined,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardWidgetQueryRange>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetPublicDashboardWidgetQueryRangeQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getPublicDashboardWidgetQueryRange>>
|
||||
>;
|
||||
export type GetPublicDashboardWidgetQueryRangeQueryError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get query range result
|
||||
*/
|
||||
|
||||
export function useGetPublicDashboardWidgetQueryRange<
|
||||
TData = Awaited<ReturnType<typeof getPublicDashboardWidgetQueryRange>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id, idx }: GetPublicDashboardWidgetQueryRangePathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardWidgetQueryRange>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetPublicDashboardWidgetQueryRangeQueryOptions(
|
||||
{ id, idx },
|
||||
options,
|
||||
);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get query range result
|
||||
*/
|
||||
export const invalidateGetPublicDashboardWidgetQueryRange = async (
|
||||
queryClient: QueryClient,
|
||||
{ id, idx }: GetPublicDashboardWidgetQueryRangePathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetPublicDashboardWidgetQueryRangeQueryKey({ id, idx }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns every saved view in the calling user's org. Saved views are shared org-wide.
|
||||
* @summary List dashboard saved views
|
||||
|
||||
@@ -24,6 +24,8 @@ import type {
|
||||
GetMetricAlertsParams,
|
||||
GetMetricAttributes200,
|
||||
GetMetricAttributesParams,
|
||||
GetMetricDashboards200,
|
||||
GetMetricDashboardsParams,
|
||||
GetMetricDashboardsV2200,
|
||||
GetMetricDashboardsV2Params,
|
||||
GetMetricHighlights200,
|
||||
@@ -1094,6 +1096,104 @@ export const invalidateGetMetricAttributes = async (
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint returns associated dashboards for a specified metric
|
||||
* @summary Get metric dashboards
|
||||
*/
|
||||
export const getMetricDashboards = (
|
||||
params: GetMetricDashboardsParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetMetricDashboards200>({
|
||||
url: `/api/v2/metrics/dashboards`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetMetricDashboardsQueryKey = (
|
||||
params?: GetMetricDashboardsParams,
|
||||
) => {
|
||||
return [`/api/v2/metrics/dashboards`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getGetMetricDashboardsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getMetricDashboards>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params: GetMetricDashboardsParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricDashboards>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetMetricDashboardsQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getMetricDashboards>>
|
||||
> = ({ signal }) => getMetricDashboards(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricDashboards>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetMetricDashboardsQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getMetricDashboards>>
|
||||
>;
|
||||
export type GetMetricDashboardsQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get metric dashboards
|
||||
*/
|
||||
|
||||
export function useGetMetricDashboards<
|
||||
TData = Awaited<ReturnType<typeof getMetricDashboards>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params: GetMetricDashboardsParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricDashboards>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetMetricDashboardsQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get metric dashboards
|
||||
*/
|
||||
export const invalidateGetMetricDashboards = async (
|
||||
queryClient: QueryClient,
|
||||
params: GetMetricDashboardsParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetMetricDashboardsQueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint returns highlights like number of datapoints, totaltimeseries, active time series, last received time for a specified metric
|
||||
* @summary Get metric highlights
|
||||
|
||||
@@ -4886,6 +4886,50 @@ export interface DashboardtypesCustomVariableSpecDTO {
|
||||
customValue: string;
|
||||
}
|
||||
|
||||
export interface DashboardtypesStorableDashboardDataDTO {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export enum DashboardtypesSourceDTO {
|
||||
user = 'user',
|
||||
system = 'system',
|
||||
integration = 'integration',
|
||||
}
|
||||
export interface DashboardtypesDashboardDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
createdBy?: string;
|
||||
data?: DashboardtypesStorableDashboardDataDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
locked?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
org_id?: string;
|
||||
source?: DashboardtypesSourceDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export interface DashboardtypesDashboardPanelRefDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -5824,11 +5868,6 @@ export interface DashboardtypesDashboardViewDTO {
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export enum DashboardtypesSourceDTO {
|
||||
user = 'user',
|
||||
system = 'system',
|
||||
integration = 'integration',
|
||||
}
|
||||
export interface TagtypesGettableTagDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -5906,6 +5945,11 @@ export interface DashboardtypesGettablePublicDasbhboardDTO {
|
||||
timeRangeEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface DashboardtypesGettablePublicDashboardDataDTO {
|
||||
dashboard?: DashboardtypesDashboardDTO;
|
||||
publicDashboard?: DashboardtypesGettablePublicDasbhboardDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesGettablePublicDashboardDataV2DTO {
|
||||
dashboard?: DashboardtypesGettableDashboardV2DTO;
|
||||
publicDashboard?: DashboardtypesGettablePublicDasbhboardDTO;
|
||||
@@ -8978,6 +9022,25 @@ export interface MetricsexplorertypesMetricAttributesResponseDTO {
|
||||
totalKeys: number;
|
||||
}
|
||||
|
||||
export interface MetricsexplorertypesMetricDashboardDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
dashboardId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
dashboardName: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
widgetId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
widgetName: string;
|
||||
}
|
||||
|
||||
export interface MetricsexplorertypesMetricDashboardPanelsResponseDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
@@ -8985,6 +9048,13 @@ export interface MetricsexplorertypesMetricDashboardPanelsResponseDTO {
|
||||
dashboards: DashboardtypesDashboardPanelRefDTO[] | null;
|
||||
}
|
||||
|
||||
export interface MetricsexplorertypesMetricDashboardsResponseDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
dashboards: MetricsexplorertypesMetricDashboardDTO[] | null;
|
||||
}
|
||||
|
||||
export interface MetricsexplorertypesMetricHighlightsResponseDTO {
|
||||
/**
|
||||
* @type integer
|
||||
@@ -12418,6 +12488,29 @@ export type GetOrgPreference200 = {
|
||||
export type UpdateOrgPreferencePathParameters = {
|
||||
name: string;
|
||||
};
|
||||
export type GetPublicDashboardDataPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetPublicDashboardData200 = {
|
||||
data: DashboardtypesGettablePublicDashboardDataDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetPublicDashboardWidgetQueryRangePathParameters = {
|
||||
id: string;
|
||||
idx: string;
|
||||
};
|
||||
export type GetPublicDashboardWidgetQueryRange200 = {
|
||||
data: Querybuildertypesv5QueryRangeResponseDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListRoles200 = {
|
||||
/**
|
||||
* @type array
|
||||
@@ -13375,6 +13468,22 @@ export type GetMetricAttributes200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetMetricDashboardsParams = {
|
||||
/**
|
||||
* @type string
|
||||
* @description The name of the metric. May contain slashes (e.g. cloud-provider metrics like run.googleapis.com/request_latencies).
|
||||
*/
|
||||
metricName: string;
|
||||
};
|
||||
|
||||
export type GetMetricDashboards200 = {
|
||||
data: MetricsexplorertypesMetricDashboardsResponseDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetMetricHighlightsParams = {
|
||||
/**
|
||||
* @type string
|
||||
|
||||
23
frontend/src/api/logs/AddToSelectedField.ts
Normal file
23
frontend/src/api/logs/AddToSelectedField.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/logs/addToSelectedFields';
|
||||
|
||||
const addToSelectedFields = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
|
||||
try {
|
||||
const data = await axios.post(`/logs/fields`, props);
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: '',
|
||||
payload: data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return Promise.reject(ErrorResponseHandler(error as AxiosError));
|
||||
}
|
||||
};
|
||||
|
||||
export default addToSelectedFields;
|
||||
26
frontend/src/api/logs/GetLogs.ts
Normal file
26
frontend/src/api/logs/GetLogs.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/logs/getLogs';
|
||||
|
||||
const GetLogs = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
|
||||
try {
|
||||
const data = await axios.get(`/logs`, {
|
||||
params: props,
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: '',
|
||||
payload: data.data.results,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
|
||||
export default GetLogs;
|
||||
26
frontend/src/api/logs/GetLogsAggregate.ts
Normal file
26
frontend/src/api/logs/GetLogsAggregate.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/logs/getLogsAggregate';
|
||||
|
||||
const GetLogsAggregate = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
|
||||
try {
|
||||
const data = await axios.get(`/logs/aggregate`, {
|
||||
params: props,
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: '',
|
||||
payload: data.data.items,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
|
||||
export default GetLogsAggregate;
|
||||
24
frontend/src/api/logs/GetSearchFields.ts
Normal file
24
frontend/src/api/logs/GetSearchFields.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { PayloadProps } from 'types/api/logs/getSearchFields';
|
||||
|
||||
const GetSearchFields = async (): Promise<
|
||||
SuccessResponse<PayloadProps> | ErrorResponse
|
||||
> => {
|
||||
try {
|
||||
const data = await axios.get(`/logs/fields`);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: '',
|
||||
payload: data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
|
||||
export default GetSearchFields;
|
||||
23
frontend/src/api/logs/RemoveFromSelectedField.ts
Normal file
23
frontend/src/api/logs/RemoveFromSelectedField.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/logs/addToSelectedFields';
|
||||
|
||||
const removeSelectedField = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
|
||||
try {
|
||||
const data = await axios.post(`/logs/fields`, props);
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: '',
|
||||
payload: data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return Promise.reject(ErrorResponseHandler(error as AxiosError));
|
||||
}
|
||||
};
|
||||
|
||||
export default removeSelectedField;
|
||||
22
frontend/src/api/logs/livetail.ts
Normal file
22
frontend/src/api/logs/livetail.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import apiV1 from 'api/apiV1';
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { EventSourcePolyfill } from 'event-source-polyfill';
|
||||
import { withBasePath } from 'utils/basePath';
|
||||
|
||||
// 10 min in ms
|
||||
const TIMEOUT_IN_MS = 10 * 60 * 1000;
|
||||
|
||||
export const LiveTail = (queryParams: string): EventSourcePolyfill =>
|
||||
new EventSourcePolyfill(
|
||||
ENVIRONMENT.baseURL
|
||||
? `${ENVIRONMENT.baseURL}${apiV1}logs/tail?${queryParams}`
|
||||
: withBasePath(`${apiV1}logs/tail?${queryParams}`),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${getLocalStorageKey(LOCALSTORAGE.AUTH_TOKEN)}`,
|
||||
},
|
||||
heartbeatTimeout: TIMEOUT_IN_MS,
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,69 @@
|
||||
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...');
|
||||
},
|
||||
};
|
||||
28
frontend/src/components/FieldsSelector/stories/FieldsSelector.stories.mocks.tsx
generated
Normal file
28
frontend/src/components/FieldsSelector/stories/FieldsSelector.stories.mocks.tsx
generated
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 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([]))),
|
||||
),
|
||||
];
|
||||
@@ -0,0 +1,102 @@
|
||||
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'],
|
||||
},
|
||||
};
|
||||
12
frontend/src/components/Logs/CategoryHeading/index.tsx
Normal file
12
frontend/src/components/Logs/CategoryHeading/index.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { CategoryHeadingText } from './styles';
|
||||
|
||||
interface ICategoryHeadingProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
function CategoryHeading({ children }: ICategoryHeadingProps): JSX.Element {
|
||||
return <CategoryHeadingText color="muted">{children}</CategoryHeadingText>;
|
||||
}
|
||||
|
||||
export default CategoryHeading;
|
||||
6
frontend/src/components/Logs/CategoryHeading/styles.ts
Normal file
6
frontend/src/components/Logs/CategoryHeading/styles.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const CategoryHeadingText = styled(Typography.Text)`
|
||||
font-size: 0.8rem;
|
||||
`;
|
||||
33
frontend/src/components/Logs/TableView/config.ts
Normal file
33
frontend/src/components/Logs/TableView/config.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { CSSProperties } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { TableProps } from 'antd';
|
||||
|
||||
export function getDefaultCellStyle(isDarkMode?: boolean): CSSProperties {
|
||||
return {
|
||||
paddingTop: 4,
|
||||
paddingBottom: 6,
|
||||
paddingRight: 8,
|
||||
paddingLeft: 8,
|
||||
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_400,
|
||||
fontSize: '14px',
|
||||
fontStyle: 'normal',
|
||||
fontWeight: 400,
|
||||
lineHeight: '18px',
|
||||
letterSpacing: '-0.07px',
|
||||
marginBottom: '0px',
|
||||
minWidth: '10rem',
|
||||
width: 'auto',
|
||||
};
|
||||
}
|
||||
|
||||
export const defaultTableStyle: CSSProperties = {
|
||||
minWidth: '40rem',
|
||||
};
|
||||
|
||||
export const defaultListViewPanelStyle: CSSProperties = {
|
||||
maxWidth: '40rem',
|
||||
};
|
||||
|
||||
export const tableScroll: TableProps<Record<string, unknown>>['scroll'] = {
|
||||
x: true,
|
||||
};
|
||||
24
frontend/src/components/Logs/TableView/index.tsx
Normal file
24
frontend/src/components/Logs/TableView/index.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Table } from 'antd';
|
||||
|
||||
// config
|
||||
import { tableScroll } from './config';
|
||||
import { LogsTableViewProps } from './types';
|
||||
import { useTableView } from './useTableView';
|
||||
|
||||
function LogsTableView(props: LogsTableViewProps): JSX.Element {
|
||||
const { dataSource, columns } = useTableView(props);
|
||||
|
||||
return (
|
||||
<Table
|
||||
size="small"
|
||||
columns={columns}
|
||||
dataSource={dataSource}
|
||||
pagination={false}
|
||||
rowKey="id"
|
||||
bordered
|
||||
scroll={tableScroll}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default LogsTableView;
|
||||
32
frontend/src/components/Logs/TableView/styles.ts
Normal file
32
frontend/src/components/Logs/TableView/styles.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import styled from 'styled-components';
|
||||
|
||||
interface TableBodyContentProps {
|
||||
linesPerRow: number;
|
||||
fontSize: FontSize;
|
||||
isDarkMode?: boolean;
|
||||
}
|
||||
|
||||
export const TableBodyContent = styled.div<TableBodyContentProps>`
|
||||
margin-bottom: 0;
|
||||
color: ${(props): string =>
|
||||
props.isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_400};
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 128.571% */
|
||||
letter-spacing: -0.07px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: ${(props): number => props.linesPerRow};
|
||||
line-clamp: ${(props): number => props.linesPerRow};
|
||||
-webkit-box-orient: vertical;
|
||||
${({ fontSize }): string =>
|
||||
fontSize === FontSize.SMALL
|
||||
? `font-size:11px; line-height:16px;`
|
||||
: fontSize === FontSize.MEDIUM
|
||||
? `font-size:13px; line-height:20px;`
|
||||
: `font-size:14px; line-height:24px;`}
|
||||
`;
|
||||
@@ -1,5 +1,40 @@
|
||||
import { TableColumnType as ColumnType } from 'antd';
|
||||
import {
|
||||
TableColumnsType as ColumnsType,
|
||||
TableColumnType as ColumnType,
|
||||
} from 'antd';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { IField } from 'types/api/logs/fields';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
export type ColumnTypeRender<T = unknown> = ReturnType<
|
||||
NonNullable<ColumnType<T>['render']>
|
||||
>;
|
||||
|
||||
export type LogsTableViewProps = {
|
||||
logs: ILog[];
|
||||
fields: IField[];
|
||||
linesPerRow: number;
|
||||
fontSize: FontSize;
|
||||
onClickExpand?: (log: ILog) => void;
|
||||
};
|
||||
|
||||
export type UseTableViewResult = {
|
||||
columns: ColumnsType<Record<string, unknown>>;
|
||||
dataSource: Record<string, string>[];
|
||||
};
|
||||
|
||||
export type UseTableViewProps = {
|
||||
appendTo?: 'center' | 'end';
|
||||
onOpenLogsContext?: (log: ILog) => void;
|
||||
onClickExpand?: (log: ILog) => void;
|
||||
activeLog?: ILog | null;
|
||||
activeLogIndex?: number;
|
||||
activeContextLog?: ILog | null;
|
||||
isListViewPanel?: boolean;
|
||||
} & LogsTableViewProps;
|
||||
|
||||
export type ActionsColumnProps = {
|
||||
logId: string;
|
||||
logs: ILog[];
|
||||
onOpenLogsContext?: (log: ILog) => void;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
.text {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 128.571% */
|
||||
letter-spacing: -0.07px;
|
||||
|
||||
&.small {
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
&.medium {
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
&.large {
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.state-indicator {
|
||||
width: 15px;
|
||||
.log-state-indicator {
|
||||
padding: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.table-timestamp {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.timestamp-text {
|
||||
color: var(--l1-foreground);
|
||||
margin: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.paragraph {
|
||||
margin: 0;
|
||||
padding: 0px !important;
|
||||
&.small {
|
||||
font-size: 11px !important;
|
||||
line-height: 16px !important;
|
||||
}
|
||||
|
||||
&.medium {
|
||||
font-size: 13px !important;
|
||||
line-height: 20px !important;
|
||||
}
|
||||
|
||||
&.large {
|
||||
font-size: 14px !important;
|
||||
line-height: 24px !important;
|
||||
}
|
||||
}
|
||||
180
frontend/src/components/Logs/TableView/useTableView.tsx
Normal file
180
frontend/src/components/Logs/TableView/useTableView.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { useMemo } from 'react';
|
||||
import { TableColumnsType as ColumnsType } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { FlatLogData } from 'lib/logs/flatLogData';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import LogStateIndicator from '../LogStateIndicator/LogStateIndicator';
|
||||
import {
|
||||
defaultListViewPanelStyle,
|
||||
defaultTableStyle,
|
||||
getDefaultCellStyle,
|
||||
} from './config';
|
||||
import { TableBodyContent } from './styles';
|
||||
import {
|
||||
ColumnTypeRender,
|
||||
UseTableViewProps,
|
||||
UseTableViewResult,
|
||||
} from './types';
|
||||
|
||||
import './useTableView.styles.scss';
|
||||
|
||||
export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
|
||||
const {
|
||||
logs,
|
||||
fields,
|
||||
linesPerRow,
|
||||
fontSize,
|
||||
appendTo = 'center',
|
||||
isListViewPanel,
|
||||
} = props;
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const flattenLogData = useMemo(
|
||||
() => logs.map((log) => FlatLogData(log)),
|
||||
[logs],
|
||||
);
|
||||
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const bodyColumnStyle = useMemo(
|
||||
() => ({
|
||||
...defaultTableStyle,
|
||||
...(fields.length > 2 ? { width: 'auto' } : {}),
|
||||
}),
|
||||
[fields.length],
|
||||
);
|
||||
|
||||
const columns: ColumnsType<Record<string, unknown>> = useMemo(() => {
|
||||
const fieldColumns: ColumnsType<Record<string, unknown>> = fields
|
||||
.filter((e) => !['id', 'body', 'timestamp'].includes(e.name))
|
||||
.map(({ name }) => ({
|
||||
title: name,
|
||||
dataIndex: name,
|
||||
accessorKey: name,
|
||||
id: name.toLowerCase().replace(/\./g, '_'),
|
||||
key: name,
|
||||
render: (field): ColumnTypeRender<Record<string, unknown>> => ({
|
||||
props: {
|
||||
style: {
|
||||
...(isListViewPanel
|
||||
? defaultListViewPanelStyle
|
||||
: getDefaultCellStyle(isDarkMode)),
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: linesPerRow,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
wordBreak: 'break-all',
|
||||
},
|
||||
},
|
||||
children: <p className={cx('paragraph', fontSize)}>{field}</p>,
|
||||
}),
|
||||
}));
|
||||
|
||||
if (isListViewPanel) {
|
||||
return [...fieldColumns];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
// We do not need any title and data index for the log state indicator
|
||||
title: '',
|
||||
dataIndex: '',
|
||||
key: 'state-indicator',
|
||||
accessorKey: 'state-indicator',
|
||||
id: 'state-indicator',
|
||||
render: (_, item): ColumnTypeRender<Record<string, unknown>> => ({
|
||||
children: (
|
||||
<div className={cx('state-indicator', fontSize)}>
|
||||
<LogStateIndicator
|
||||
fontSize={fontSize}
|
||||
severityText={item.severity_text as string}
|
||||
severityNumber={item.severity_number as number}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
},
|
||||
...(fields.some((field) => field.name === 'timestamp')
|
||||
? [
|
||||
{
|
||||
title: 'timestamp',
|
||||
dataIndex: 'timestamp',
|
||||
key: 'timestamp',
|
||||
accessorKey: 'timestamp',
|
||||
id: 'timestamp',
|
||||
// https://github.com/ant-design/ant-design/discussions/36886
|
||||
render: (
|
||||
field: string | number,
|
||||
): ColumnTypeRender<Record<string, unknown>> => {
|
||||
const date =
|
||||
typeof field === 'string'
|
||||
? formatTimezoneAdjustedTimestamp(
|
||||
field,
|
||||
DATE_TIME_FORMATS.ISO_DATETIME_MS,
|
||||
)
|
||||
: formatTimezoneAdjustedTimestamp(
|
||||
field / 1e6,
|
||||
DATE_TIME_FORMATS.ISO_DATETIME_MS,
|
||||
);
|
||||
return {
|
||||
children: (
|
||||
<div className="table-timestamp">
|
||||
<p className={cx('timestamp-text text', fontSize)}>{date}</p>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(appendTo === 'center' ? fieldColumns : []),
|
||||
...(fields.some((field) => field.name === 'body')
|
||||
? [
|
||||
{
|
||||
title: 'body',
|
||||
dataIndex: 'body',
|
||||
key: 'body',
|
||||
accessorKey: 'body',
|
||||
id: 'body',
|
||||
render: (
|
||||
field: string | number,
|
||||
): ColumnTypeRender<Record<string, unknown>> => ({
|
||||
props: {
|
||||
style: bodyColumnStyle,
|
||||
},
|
||||
children: (
|
||||
<TableBodyContent
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: getSanitizedLogBody(field as string, {
|
||||
shouldEscapeHtml: true,
|
||||
}),
|
||||
}}
|
||||
fontSize={fontSize}
|
||||
linesPerRow={linesPerRow}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(appendTo === 'end' ? fieldColumns : []),
|
||||
];
|
||||
}, [
|
||||
fields,
|
||||
isListViewPanel,
|
||||
appendTo,
|
||||
isDarkMode,
|
||||
linesPerRow,
|
||||
fontSize,
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
bodyColumnStyle,
|
||||
]);
|
||||
|
||||
return { columns, dataSource: flattenLogData };
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
import { Button, InputNumber, Popover, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import { LogViewMode } from 'container/OptionsMenu/types';
|
||||
import { LogViewMode } from 'container/LogsTable';
|
||||
import { FontSize, OptionsMenuConfig } from 'container/OptionsMenu/types';
|
||||
import {
|
||||
Check,
|
||||
|
||||
156
frontend/src/components/NewSelect/stories/NewSelect.stories.tsx
Normal file
156
frontend/src/components/NewSelect/stories/NewSelect.stories.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
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>
|
||||
),
|
||||
};
|
||||
15
frontend/src/components/NotFound/stories/NotFound.stories.mocks.tsx
generated
Normal file
15
frontend/src/components/NotFound/stories/NotFound.stories.mocks.tsx
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 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' }),
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
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 = {};
|
||||
@@ -2,8 +2,6 @@
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
.quick-filters-settings-container {
|
||||
flex: 0 0 0;
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
// The one `overflow: hidden` in the chain. Ancestors (RouteTab, AppLayout)
|
||||
// only hand height down; each pane below owns its own scroll.
|
||||
.layout {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Positioned so overlays (settings drawer) paint above the content pane
|
||||
// without changing this pane's layout width.
|
||||
.filters {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
// Bounded box for the OverlayScrollbar inside it (`.overlay-scrollbar` is
|
||||
// `height: 100%`), which owns the scrolling.
|
||||
.content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { ComponentProps, ReactNode } from 'react';
|
||||
import cx from 'classnames';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
|
||||
import QuickFilters from '../QuickFilters';
|
||||
|
||||
import styles from './QuickFiltersLayout.module.scss';
|
||||
|
||||
// Same optionality as `<QuickFilters />` in JSX (honours its defaultProps).
|
||||
type QuickFiltersElementProps = JSX.LibraryManagedAttributes<
|
||||
typeof QuickFilters,
|
||||
ComponentProps<typeof QuickFilters>
|
||||
>;
|
||||
|
||||
export interface QuickFiltersLayoutProps {
|
||||
quickFilterProps: QuickFiltersElementProps;
|
||||
showFilters: boolean;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
testId?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function QuickFiltersLayout({
|
||||
quickFilterProps,
|
||||
showFilters,
|
||||
className,
|
||||
contentClassName,
|
||||
testId,
|
||||
children,
|
||||
}: QuickFiltersLayoutProps): JSX.Element {
|
||||
return (
|
||||
<div className={cx(styles.layout, className)} data-testid={testId}>
|
||||
{showFilters && (
|
||||
<aside
|
||||
className={styles.filters}
|
||||
data-testid="quick-filters-layout-filters"
|
||||
>
|
||||
<QuickFilters {...quickFilterProps} />
|
||||
</aside>
|
||||
)}
|
||||
<section
|
||||
className={cx(styles.content, contentClassName)}
|
||||
data-testid="quick-filters-layout-content"
|
||||
>
|
||||
<OverlayScrollbar>
|
||||
<div>{children}</div>
|
||||
</OverlayScrollbar>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default QuickFiltersLayout;
|
||||
@@ -1,79 +0,0 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import { QuickFiltersSource } from '../../types';
|
||||
import QuickFiltersLayout from '../QuickFiltersLayout';
|
||||
|
||||
jest.mock('../QuickFiltersLayout.module.scss', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
layout: 'layout',
|
||||
filters: 'filters',
|
||||
content: 'content',
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../QuickFilters', () => ({
|
||||
__esModule: true,
|
||||
default: ({ source }: { source: string }): JSX.Element => (
|
||||
<div data-testid="quick-filters">{source}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const quickFilterProps = {
|
||||
source: QuickFiltersSource.TRACES_EXPLORER,
|
||||
handleFilterVisibilityChange: jest.fn(),
|
||||
};
|
||||
|
||||
describe('QuickFiltersLayout', () => {
|
||||
it('renders QuickFilters with the given props inside the filters pane', () => {
|
||||
render(
|
||||
<QuickFiltersLayout showFilters quickFilterProps={quickFilterProps}>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
|
||||
const filtersPane = screen.getByTestId('quick-filters-layout-filters');
|
||||
expect(filtersPane).toContainElement(screen.getByTestId('quick-filters'));
|
||||
expect(screen.getByTestId('quick-filters')).toHaveTextContent(
|
||||
QuickFiltersSource.TRACES_EXPLORER,
|
||||
);
|
||||
expect(screen.getByTestId('quick-filters-layout-content')).toHaveTextContent(
|
||||
'content',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not render the filters pane when showFilters is false', () => {
|
||||
render(
|
||||
<QuickFiltersLayout showFilters={false} quickFilterProps={quickFilterProps}>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('quick-filters-layout-filters'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('quick-filters')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('merges classNames onto the root and content panes', () => {
|
||||
render(
|
||||
<QuickFiltersLayout
|
||||
showFilters
|
||||
quickFilterProps={quickFilterProps}
|
||||
className="page-root"
|
||||
contentClassName="page-content"
|
||||
testId="page"
|
||||
>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
|
||||
const root = screen.getByTestId('page');
|
||||
expect(root).toHaveClass('layout', 'page-root');
|
||||
expect(screen.getByTestId('quick-filters-layout-content')).toHaveClass(
|
||||
'content',
|
||||
'page-content',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,12 +6,27 @@
|
||||
left: 0;
|
||||
z-index: 999;
|
||||
width: 342px;
|
||||
height: 100%;
|
||||
background: var(--l1-background);
|
||||
transition: width 0.05s ease-in-out;
|
||||
overflow: hidden;
|
||||
color: var(--l1-foreground);
|
||||
|
||||
&.qf-logs-explorer {
|
||||
height: calc(100vh - 45px);
|
||||
}
|
||||
|
||||
&.qf-exceptions {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
&.qf-api-monitoring {
|
||||
height: calc(100vh - 45px);
|
||||
}
|
||||
|
||||
&.qf-traces-explorer {
|
||||
height: calc(100vh - 45px);
|
||||
}
|
||||
|
||||
&.hidden {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
126
frontend/src/components/QuickFilters/stories/QuickFilters.stories.mocks.tsx
generated
Normal file
126
frontend/src/components/QuickFilters/stories/QuickFilters.stories.mocks.tsx
generated
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* 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',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import type { ComponentProps, ComponentType } from 'react';
|
||||
import { expect, screen, userEvent, waitFor, within } 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 },
|
||||
},
|
||||
} satisfies Meta<typeof QuickFilters>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
type TooltipsStory = StoryObj<
|
||||
ComponentProps<typeof QuickFilters> & GlobalMockArgs
|
||||
>;
|
||||
|
||||
/**
|
||||
* The settings control renders disabled while its permission check is in
|
||||
* flight and is swapped for the enabled one once the check answers, so it is
|
||||
* looked up again on every attempt; a click on the disabled one is dropped in
|
||||
* silence.
|
||||
*/
|
||||
const settingsControl = (canvasElement: HTMLElement): Promise<HTMLElement> =>
|
||||
waitFor(() => {
|
||||
const control = within(canvasElement).getByTestId('settings-icon-container');
|
||||
|
||||
expect(control).toBeEnabled();
|
||||
|
||||
return control;
|
||||
});
|
||||
|
||||
/** Interaction: the settings panel is opened through the admin settings control. */
|
||||
export const SettingsOpen: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(await settingsControl(canvasElement));
|
||||
await screen.findByText('Edit quick filters');
|
||||
},
|
||||
};
|
||||
|
||||
/** Mutation: changing the settings list reveals the fixed save and discard footer. */
|
||||
export const SettingsDirtyFooter: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(await settingsControl(canvasElement));
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Add' }));
|
||||
await screen.findByRole('button', { name: 'Save changes' });
|
||||
},
|
||||
};
|
||||
|
||||
/** 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,38 +0,0 @@
|
||||
// Hands the parent's height down to the active pane and lets the pane scroll
|
||||
// its own content, so TopNav and the tab bar stay put. Child combinators only
|
||||
// (nested Tabs must not be caught).
|
||||
.routeTab {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.routeTab > :global(.ant-tabs-content-holder) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.routeTab > :global(.ant-tabs-content-holder) > :global(.ant-tabs-content) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.routeTab
|
||||
> :global(.ant-tabs-content-holder)
|
||||
> :global(.ant-tabs-content)
|
||||
> :global(.ant-tabs-tabpane-active) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.routeTab
|
||||
> :global(.ant-tabs-content-holder)
|
||||
> :global(.ant-tabs-content)
|
||||
> :global(.ant-tabs-tabpane-active)
|
||||
> :global(.overlay-scrollbar) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -5,11 +5,6 @@ import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
import RouteTab from './index';
|
||||
import { RouteTabProps } from './types';
|
||||
|
||||
jest.mock('./RouteTab.module.scss', () => ({
|
||||
__esModule: true,
|
||||
default: { routeTab: 'routeTab' },
|
||||
}));
|
||||
|
||||
function DummyComponent1(): JSX.Element {
|
||||
return <div>Dummy Component 1</div>;
|
||||
}
|
||||
@@ -79,36 +74,6 @@ describe('RouteTab component', () => {
|
||||
expect(history.location.pathname).toBe('/tab2');
|
||||
});
|
||||
|
||||
it('applies the layout class alongside a custom className', () => {
|
||||
const history = createMemoryHistory();
|
||||
const { container } = render(
|
||||
<Router history={history}>
|
||||
<RouteTab
|
||||
history={history}
|
||||
routes={testRoutes}
|
||||
activeKey="Tab1"
|
||||
className="custom-tabs"
|
||||
/>
|
||||
</Router>,
|
||||
);
|
||||
expect(container.querySelector('.ant-tabs')).toHaveClass(
|
||||
'routeTab',
|
||||
'custom-tabs',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the active tab content inside an overlay scrollbar', () => {
|
||||
const history = createMemoryHistory();
|
||||
const { container } = render(
|
||||
<Router history={history}>
|
||||
<RouteTab history={history} routes={testRoutes} activeKey="Tab1" />
|
||||
</Router>,
|
||||
);
|
||||
expect(
|
||||
container.querySelector('.ant-tabs-tabpane-active > .overlay-scrollbar'),
|
||||
).toHaveTextContent('Dummy Component 1');
|
||||
});
|
||||
|
||||
it('calls onChangeHandler on tab change', () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const history = createMemoryHistory();
|
||||
|
||||
@@ -5,32 +5,20 @@ import {
|
||||
useParams,
|
||||
} from 'react-router-dom';
|
||||
import { Tabs, TabsProps } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
|
||||
import { RouteTabProps } from './types';
|
||||
|
||||
import styles from './RouteTab.module.scss';
|
||||
|
||||
interface Params {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Each pane scrolls its own content inside an OverlayScrollbar, so the tab bar
|
||||
* stays put. Mounted as the page root the pane is bounded to the viewport; inside
|
||||
* a plain block wrapper the scroller is inert and the page scrolls as usual.
|
||||
* Pane content that needs a bounded box must size itself with `height: 100%`
|
||||
* (the scroller's viewport is block flow, so `flex: 1` has no effect there).
|
||||
*/
|
||||
function RouteTab({
|
||||
routes,
|
||||
activeKey,
|
||||
onChangeHandler,
|
||||
history,
|
||||
showRightSection,
|
||||
className,
|
||||
...rest
|
||||
}: RouteTabProps & TabsProps): JSX.Element {
|
||||
const params = useParams<Params>();
|
||||
@@ -62,16 +50,11 @@ function RouteTab({
|
||||
label: name,
|
||||
key,
|
||||
tabKey: route,
|
||||
children: (
|
||||
<OverlayScrollbar>
|
||||
<Component />
|
||||
</OverlayScrollbar>
|
||||
),
|
||||
children: <Component />,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
className={cx(styles.routeTab, className)}
|
||||
onChange={onChange}
|
||||
destroyInactiveTabPane
|
||||
activeKey={currentRoute?.key || activeKey}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
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');
|
||||
},
|
||||
};
|
||||
@@ -71,7 +71,7 @@ interface ITableConfig {
|
||||
instance: Virtualizer<HTMLDivElement, Element>,
|
||||
) => void;
|
||||
}
|
||||
interface ITableV3Props<T> {
|
||||
export 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: null,
|
||||
virtualiserRef: undefined,
|
||||
};
|
||||
|
||||
54
frontend/src/components/TableV3/stories/TableV3.stories.tsx
Normal file
54
frontend/src/components/TableV3/stories/TableV3.stories.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
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: [] },
|
||||
};
|
||||
@@ -0,0 +1,239 @@
|
||||
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');
|
||||
},
|
||||
};
|
||||
@@ -37,6 +37,7 @@ const ROUTES = {
|
||||
NOT_FOUND: '/not-found',
|
||||
LOGS_BASE: '/logs',
|
||||
LOGS: '/logs/logs-explorer',
|
||||
OLD_LOGS_EXPLORER: '/logs/old-logs-explorer',
|
||||
LOGS_EXPLORER: '/logs/logs-explorer',
|
||||
LIVE_LOGS: '/logs/logs-explorer/live',
|
||||
LOGS_PIPELINES: '/logs/pipelines',
|
||||
|
||||
@@ -450,6 +450,12 @@ 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]);
|
||||
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
.api-monitoring-explorer {
|
||||
.api-quick-filters-header {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
border-right: 1px solid var(--l1-border);
|
||||
.api-monitoring-page {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
.api-quick-filter-left-section {
|
||||
width: 0%;
|
||||
flex-shrink: 0;
|
||||
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
.api-quick-filters-header {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
border-right: 1px solid var(--l1-border);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.api-module-right-section {
|
||||
@@ -153,6 +161,16 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.filter-visible {
|
||||
.api-quick-filter-left-section {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.api-module-right-section {
|
||||
width: calc(100% - 260px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-filtered-domains-message-container {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect } from 'react';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
|
||||
import cx from 'classnames';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
@@ -19,21 +20,20 @@ function Explorer(): JSX.Element {
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<QuickFiltersLayout
|
||||
className="api-monitoring-explorer"
|
||||
showFilters
|
||||
quickFilterProps={{
|
||||
className: 'qf-api-monitoring',
|
||||
source: QuickFiltersSource.API_MONITORING,
|
||||
signal: SignalType.API_MONITORING,
|
||||
showFilterCollapse: false,
|
||||
showQueryName: false,
|
||||
handleFilterVisibilityChange: (): void => {},
|
||||
useFieldApis: quickFilterFieldApis,
|
||||
}}
|
||||
>
|
||||
<div className={cx('api-monitoring-page', 'filter-visible')}>
|
||||
<section className="api-quick-filter-left-section">
|
||||
<QuickFilters
|
||||
className="qf-api-monitoring"
|
||||
source={QuickFiltersSource.API_MONITORING}
|
||||
signal={SignalType.API_MONITORING}
|
||||
showFilterCollapse={false}
|
||||
showQueryName={false}
|
||||
handleFilterVisibilityChange={(): void => {}}
|
||||
useFieldApis={quickFilterFieldApis}
|
||||
/>
|
||||
</section>
|
||||
<DomainList />
|
||||
</QuickFiltersLayout>
|
||||
</div>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -207,7 +207,12 @@ export default function CustomDomainSettings(): JSX.Element {
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="link" color="none" disabled={isFetchingHosts}>
|
||||
<Button
|
||||
variant="link"
|
||||
color="none"
|
||||
data-testid="custom-domain-menu-trigger"
|
||||
disabled={isFetchingHosts}
|
||||
>
|
||||
<Link2 size={12} />
|
||||
<span>{stripProtocol(activeHost?.url ?? '')}</span>
|
||||
<ChevronDown size={12} />
|
||||
|
||||
@@ -71,6 +71,7 @@ function Download({ data, isLoading, fileName }: DownloadProps): JSX.Element {
|
||||
<DropdownMenuSimple menu={menu}>
|
||||
<Button
|
||||
className="download-button"
|
||||
data-testid="download-menu-trigger"
|
||||
loading={isLoading || isDownloading}
|
||||
size="small"
|
||||
type="link"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-2) var(--spacing-8);
|
||||
padding: var(--spacing-2) var(--spacing-2);
|
||||
|
||||
--tabs-content-padding: 0;
|
||||
--tabs-text-color: var(--l1-foreground);
|
||||
|
||||
@@ -65,6 +65,8 @@
|
||||
}
|
||||
|
||||
.trace-explorer-page {
|
||||
display: flex;
|
||||
|
||||
// Meant to fix the query builder colors
|
||||
--input-background: var(--l2-background);
|
||||
--input-hover-background: var(--l2-background);
|
||||
@@ -73,8 +75,32 @@
|
||||
--input-hover-border-color: var(--internal-ant-border-color-hover);
|
||||
--input-focus-border-color: var(--internal-ant-border-color-hover);
|
||||
|
||||
.filter {
|
||||
width: 260px;
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
|
||||
border-right: 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background-color: var(--l1-background);
|
||||
|
||||
> .ant-card-body {
|
||||
padding: 0;
|
||||
width: 258px;
|
||||
}
|
||||
}
|
||||
|
||||
.trace-explorer {
|
||||
width: 100%;
|
||||
background: var(--l1-background);
|
||||
|
||||
> .ant-card-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
border-color: var(--l1-border);
|
||||
}
|
||||
.trace-explorer.filters-expanded {
|
||||
width: calc(100% - 260px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Card } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
@@ -186,21 +188,26 @@ function Explorer(): JSX.Element {
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<QuickFiltersLayout
|
||||
<div
|
||||
className="trace-explorer-page"
|
||||
testId="llm-observability-explorer"
|
||||
showFilters={isOpen}
|
||||
quickFilterProps={{
|
||||
className: 'qf-traces-explorer',
|
||||
source: QuickFiltersSource.AI_OBSERVABILITY,
|
||||
signal: SignalType.AI_OBSERVABILITY,
|
||||
useFieldApis: quickFiltersFieldApis,
|
||||
handleFilterVisibilityChange: (): void => {
|
||||
setOpen(!isOpen);
|
||||
},
|
||||
}}
|
||||
data-testid="llm-observability-explorer"
|
||||
>
|
||||
<div className="trace-explorer">
|
||||
<Card className="filter" hidden={!isOpen}>
|
||||
<QuickFilters
|
||||
className="qf-traces-explorer"
|
||||
source={QuickFiltersSource.AI_OBSERVABILITY}
|
||||
signal={SignalType.AI_OBSERVABILITY}
|
||||
useFieldApis={quickFiltersFieldApis}
|
||||
handleFilterVisibilityChange={(): void => {
|
||||
setOpen(!isOpen);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
<div
|
||||
className={cx('trace-explorer', {
|
||||
'filters-expanded': isOpen,
|
||||
})}
|
||||
>
|
||||
<div className="trace-explorer-header">
|
||||
<Toolbar
|
||||
showAutoRefresh
|
||||
@@ -284,7 +291,7 @@ function Explorer(): JSX.Element {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</QuickFiltersLayout>
|
||||
</div>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
.llmObservability {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
margin-top: var(--spacing-2);
|
||||
margin-left: var(--spacing-2);
|
||||
--tabs-text-color: var(--l1-foreground);
|
||||
--tabs-active-text-color: var(--l1-foreground);
|
||||
[role='tabpanel'] {
|
||||
margin: 0;
|
||||
padding: var(--spacing-0) var(--spacing-4);
|
||||
}
|
||||
}
|
||||
24
frontend/src/container/LLMObservability/LLMObservability.tsx
Normal file
24
frontend/src/container/LLMObservability/LLMObservability.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Tabs } from '@signozhq/ui/tabs';
|
||||
|
||||
import { useLLMObservabilityTabs } from './hooks/useLLMObservabilityTabs';
|
||||
import styles from './LLMObservability.module.scss';
|
||||
|
||||
// Shell for the LLM Observability page: renders the top-level tab bar
|
||||
// (Overview / Configuration) using the SigNoz design-system Tabs, with
|
||||
// route-driven active state from useLLMObservabilityTabs.
|
||||
function LLMObservability(): JSX.Element {
|
||||
const { items, activeTab, onTabChange } = useLLMObservabilityTabs();
|
||||
|
||||
return (
|
||||
<div className={styles.llmObservability} data-testid="llm-observability-page">
|
||||
<Tabs
|
||||
items={items}
|
||||
value={activeTab}
|
||||
onChange={onTabChange}
|
||||
testId="llm-observability-tabs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LLMObservability;
|
||||
@@ -6,13 +6,7 @@
|
||||
//TODO: remove this once we have a proper dashboard page.
|
||||
// The embedded V2 DashboardContainer renders its own page header (dashboard
|
||||
// title + share/feedback chrome) removed it for now
|
||||
:global([class*='dashboardPageHeader']),
|
||||
:global([class*='dashboardInfoWithActions']) {
|
||||
display: none;
|
||||
}
|
||||
// Embedded read-only: the add-variable and panel-action triggers are dead here.
|
||||
:global([class*='addSlot']),
|
||||
:global([data-testid^='panel-actions-']) {
|
||||
:global([class*='dashboardPageHeader']) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
gap: var(--spacing-8);
|
||||
--tabs-content-padding: 0;
|
||||
margin-top: var(--spacing-3);
|
||||
padding: var(--spacing-0) var(--spacing-8);
|
||||
--tabs-text-color: var(--l1-foreground);
|
||||
--tabs-active-text-color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
|
||||
.filtersBarSource {
|
||||
width: 160px;
|
||||
/* Trigger defaults to 2.25rem; the search input and Add button are both 2rem. */
|
||||
--select-trigger-height: 32px;
|
||||
}
|
||||
|
||||
.pageError {
|
||||
|
||||
@@ -50,6 +50,7 @@ function ModelCostActionsMenu({
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.actionButton}
|
||||
aria-label="Model cost actions"
|
||||
testId={`model-cost-actions-${rule.id}`}
|
||||
>
|
||||
<Ellipsis size={16} />
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { safeNavigateMock } from '__tests__/safeNavigateMock';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
LLM_PRICING_ENDPOINT,
|
||||
makeListResponse,
|
||||
mockRules,
|
||||
} from 'container/LLMObservability/Settings/ModelPricing/__tests__/fixtures';
|
||||
import history from 'lib/history';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import LLMObservabilityPage from '..';
|
||||
import LLMObservability from '../LLMObservability';
|
||||
|
||||
// The Overview tab renders the full V2 DashboardContainer (toolbar + date picker
|
||||
// call useNavigationType, which needs a data router this integration test doesn't
|
||||
@@ -24,16 +24,6 @@ jest.mock('container/LLMObservability/Explorer/Explorer', () => ({
|
||||
default: (): JSX.Element => <div data-testid="llm-observability-explorer" />,
|
||||
}));
|
||||
|
||||
jest.mock('lib/history', () => ({
|
||||
push: jest.fn(),
|
||||
listen: jest.fn(() => jest.fn()),
|
||||
location: { pathname: '/ai-observability/overview', search: '' },
|
||||
}));
|
||||
|
||||
const mockHistoryPush = history.push as jest.MockedFunction<
|
||||
typeof history.push
|
||||
>;
|
||||
|
||||
const SYSTEM_DASHBOARD_ENDPOINT = '*/api/v2/dashboards/system/ai-o11y-overview';
|
||||
|
||||
function setupSystemDashboard(): void {
|
||||
@@ -74,7 +64,6 @@ function setupList(items = mockRules): void {
|
||||
describe('LLMObservability (integration)', () => {
|
||||
beforeEach(() => {
|
||||
window.history.pushState(null, '', '/');
|
||||
mockHistoryPush.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -83,11 +72,11 @@ describe('LLMObservability (integration)', () => {
|
||||
|
||||
it('renders the overview panel and the tab bar on the overview route', async () => {
|
||||
setupSystemDashboard();
|
||||
render(<LLMObservabilityPage />, undefined, {
|
||||
render(<LLMObservability />, undefined, {
|
||||
initialRoute: ROUTES.AI_OBSERVABILITY_OVERVIEW,
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('llm-observability-page')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('llm-observability-tabs')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('llm-observability-overview')).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('llm-overview-dashboard')).toBeInTheDocument(),
|
||||
@@ -95,54 +84,54 @@ describe('LLMObservability (integration)', () => {
|
||||
expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'Explorer' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('tab', { name: /Model pricing/ }),
|
||||
screen.getByRole('tab', { name: 'Model pricing' }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('tab', { name: /Attribute Mapping/ }),
|
||||
screen.getByRole('tab', { name: 'Attribute Mapping' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates to the configuration route when the Model pricing tab is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<LLMObservabilityPage />, undefined, {
|
||||
render(<LLMObservability />, undefined, {
|
||||
initialRoute: ROUTES.AI_OBSERVABILITY_OVERVIEW,
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: /Model pricing/ }));
|
||||
await user.click(screen.getByRole('tab', { name: 'Model pricing' }));
|
||||
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith(
|
||||
expect(safeNavigateMock).toHaveBeenCalledWith(
|
||||
ROUTES.AI_OBSERVABILITY_CONFIGURATION,
|
||||
);
|
||||
});
|
||||
|
||||
it('navigates to the attribute mapping route when that tab is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<LLMObservabilityPage />, undefined, {
|
||||
render(<LLMObservability />, undefined, {
|
||||
initialRoute: ROUTES.AI_OBSERVABILITY_OVERVIEW,
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: /Attribute Mapping/ }));
|
||||
await user.click(screen.getByRole('tab', { name: 'Attribute Mapping' }));
|
||||
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith(
|
||||
expect(safeNavigateMock).toHaveBeenCalledWith(
|
||||
ROUTES.AI_OBSERVABILITY_ATTRIBUTE_MAPPING,
|
||||
);
|
||||
});
|
||||
|
||||
it('navigates to the explorer route when the Explorer tab is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<LLMObservabilityPage />, undefined, {
|
||||
render(<LLMObservability />, undefined, {
|
||||
initialRoute: ROUTES.AI_OBSERVABILITY_OVERVIEW,
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: /Explorer/ }));
|
||||
await user.click(screen.getByRole('tab', { name: 'Explorer' }));
|
||||
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith(
|
||||
expect(safeNavigateMock).toHaveBeenCalledWith(
|
||||
ROUTES.AI_OBSERVABILITY_EXPLORER,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the explorer panel on the explorer route', () => {
|
||||
render(<LLMObservabilityPage />, undefined, {
|
||||
render(<LLMObservability />, undefined, {
|
||||
initialRoute: ROUTES.AI_OBSERVABILITY_EXPLORER,
|
||||
});
|
||||
|
||||
@@ -150,7 +139,7 @@ describe('LLMObservability (integration)', () => {
|
||||
});
|
||||
|
||||
it('renders the attribute mapping page on the attribute mapping route', () => {
|
||||
render(<LLMObservabilityPage />, undefined, {
|
||||
render(<LLMObservability />, undefined, {
|
||||
initialRoute: ROUTES.AI_OBSERVABILITY_ATTRIBUTE_MAPPING,
|
||||
});
|
||||
|
||||
@@ -161,7 +150,7 @@ describe('LLMObservability (integration)', () => {
|
||||
|
||||
it('renders the model-pricing page on the configuration route', async () => {
|
||||
setupList();
|
||||
render(<LLMObservabilityPage />, undefined, {
|
||||
render(<LLMObservability />, undefined, {
|
||||
initialRoute: ROUTES.AI_OBSERVABILITY_CONFIGURATION,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { type TabItemProps } from '@signozhq/ui/tabs';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
|
||||
import LLMObservabilityAttributeMapping from '../AttributeMapping/LLMObservabilityAttributeMapping';
|
||||
import Explorer from '../Explorer/Explorer';
|
||||
import Overview from '../Overview/Overview';
|
||||
import LLMObservabilityModelPricing from '../Settings/ModelPricing/LLMObservabilityModelPricing';
|
||||
|
||||
const OVERVIEW_KEY = ROUTES.AI_OBSERVABILITY_OVERVIEW;
|
||||
const EXPLORER_KEY = ROUTES.AI_OBSERVABILITY_EXPLORER;
|
||||
const CONFIGURATION_KEY = ROUTES.AI_OBSERVABILITY_CONFIGURATION;
|
||||
const ATTRIBUTE_MAPPING_KEY = ROUTES.AI_OBSERVABILITY_ATTRIBUTE_MAPPING;
|
||||
|
||||
interface UseLLMObservabilityTabsResult {
|
||||
items: TabItemProps[];
|
||||
activeTab: string;
|
||||
onTabChange: (key: string) => void;
|
||||
}
|
||||
|
||||
// Drives the top-level LLM Observability tabs. Route-driven: the active tab is
|
||||
// derived from the pathname (each tab owns a URL) and changing tabs navigates,
|
||||
// so tabs stay shareable/back-button friendly while rendering with the SigNoz
|
||||
// design-system Tabs.
|
||||
export function useLLMObservabilityTabs(): UseLLMObservabilityTabsResult {
|
||||
const { pathname } = useLocation();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
let activeTab: string = OVERVIEW_KEY;
|
||||
if (pathname.startsWith(CONFIGURATION_KEY)) {
|
||||
activeTab = CONFIGURATION_KEY;
|
||||
} else if (pathname.startsWith(ATTRIBUTE_MAPPING_KEY)) {
|
||||
activeTab = ATTRIBUTE_MAPPING_KEY;
|
||||
} else if (pathname.startsWith(EXPLORER_KEY)) {
|
||||
activeTab = EXPLORER_KEY;
|
||||
}
|
||||
|
||||
const onTabChange = useCallback(
|
||||
(key: string): void => {
|
||||
safeNavigate(key);
|
||||
},
|
||||
[safeNavigate],
|
||||
);
|
||||
|
||||
const items: TabItemProps[] = [
|
||||
{
|
||||
key: OVERVIEW_KEY,
|
||||
label: 'Overview',
|
||||
children: <Overview />,
|
||||
},
|
||||
{
|
||||
key: EXPLORER_KEY,
|
||||
label: 'Explorer',
|
||||
children: <Explorer />,
|
||||
},
|
||||
{
|
||||
key: CONFIGURATION_KEY,
|
||||
label: 'Model pricing',
|
||||
children: <LLMObservabilityModelPricing />,
|
||||
},
|
||||
{
|
||||
key: ATTRIBUTE_MAPPING_KEY,
|
||||
label: 'Attribute Mapping',
|
||||
children: <LLMObservabilityAttributeMapping />,
|
||||
},
|
||||
];
|
||||
|
||||
return { items, activeTab, onTabChange };
|
||||
}
|
||||
136
frontend/src/container/LogControls/index.tsx
Normal file
136
frontend/src/container/LogControls/index.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Button, Flex } from 'antd';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import Controls from 'container/Controls';
|
||||
import Download from 'container/Download/Download';
|
||||
import { getGlobalTime } from 'container/LogsSearchFilter/utils';
|
||||
import dayjs from 'dayjs';
|
||||
import { Pagination } from 'hooks/queryPagination';
|
||||
import { getMinMaxForSelectedTime } from 'lib/getMinMax';
|
||||
import { FlatLogData } from 'lib/logs/flatLogData';
|
||||
import { OrderPreferenceItems } from 'pages/Logs/config';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Dispatch } from 'redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import AppActions from 'types/actions';
|
||||
import {
|
||||
GET_NEXT_LOG_LINES,
|
||||
GET_PREVIOUS_LOG_LINES,
|
||||
RESET_ID_START_AND_END,
|
||||
SET_LOG_LINES_PER_PAGE,
|
||||
} from 'types/actions/logs';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { ILogsReducer } from 'types/reducer/logs';
|
||||
|
||||
import { Container } from './styles';
|
||||
import { SkipBack } from '@signozhq/icons';
|
||||
|
||||
function LogControls(): JSX.Element | null {
|
||||
const {
|
||||
logLinesPerPage,
|
||||
liveTail,
|
||||
isLoading: isLogsLoading,
|
||||
isLoadingAggregate,
|
||||
logs,
|
||||
order,
|
||||
} = useSelector<AppState, ILogsReducer>((state) => state.logs);
|
||||
const globalTime = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const dispatch = useDispatch<Dispatch<AppActions>>();
|
||||
|
||||
const handleLogLinesPerPageChange = (e: Pagination['limit']): void => {
|
||||
dispatch({
|
||||
type: SET_LOG_LINES_PER_PAGE,
|
||||
payload: {
|
||||
logsLinesPerPage: e,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleGoToLatest = (): void => {
|
||||
const { maxTime, minTime } = getMinMaxForSelectedTime(
|
||||
globalTime.selectedTime,
|
||||
globalTime.minTime,
|
||||
globalTime.maxTime,
|
||||
);
|
||||
|
||||
const updatedGlobalTime = getGlobalTime(globalTime.selectedTime, {
|
||||
maxTime,
|
||||
minTime,
|
||||
});
|
||||
|
||||
if (updatedGlobalTime) {
|
||||
dispatch({
|
||||
type: RESET_ID_START_AND_END,
|
||||
payload: updatedGlobalTime,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleNavigatePrevious = (): void => {
|
||||
dispatch({
|
||||
type: GET_PREVIOUS_LOG_LINES,
|
||||
});
|
||||
};
|
||||
|
||||
const handleNavigateNext = (): void => {
|
||||
dispatch({
|
||||
type: GET_NEXT_LOG_LINES,
|
||||
});
|
||||
};
|
||||
|
||||
const flattenLogData = useMemo(
|
||||
() =>
|
||||
logs.map((log) => {
|
||||
const timestamp =
|
||||
typeof log.timestamp === 'string'
|
||||
? dayjs(log.timestamp).format(DATE_TIME_FORMATS.ISO_DATETIME_MS)
|
||||
: dayjs(log.timestamp / 1e6).format(DATE_TIME_FORMATS.ISO_DATETIME_MS);
|
||||
|
||||
return FlatLogData({
|
||||
...log,
|
||||
timestamp,
|
||||
});
|
||||
}),
|
||||
[logs],
|
||||
);
|
||||
|
||||
const isLoading = isLogsLoading || isLoadingAggregate;
|
||||
|
||||
if (liveTail !== 'STOPPED') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Download data={flattenLogData} isLoading={isLoading} fileName="log_data" />
|
||||
<Button
|
||||
loading={isLoading}
|
||||
size="small"
|
||||
type="link"
|
||||
disabled={order === OrderPreferenceItems.ASC}
|
||||
onClick={handleGoToLatest}
|
||||
>
|
||||
<Flex align="center" gap="4px">
|
||||
<SkipBack size="md" /> Go to latest
|
||||
</Flex>
|
||||
</Button>
|
||||
<Divider type="vertical" />
|
||||
<Controls
|
||||
isLoading={isLoading}
|
||||
totalCount={logs.length}
|
||||
countPerPage={logLinesPerPage}
|
||||
handleNavigatePrevious={handleNavigatePrevious}
|
||||
handleNavigateNext={handleNavigateNext}
|
||||
handleCountItemsPerPageChange={handleLogLinesPerPageChange}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(LogControls);
|
||||
14
frontend/src/container/LogControls/styles.ts
Normal file
14
frontend/src/container/LogControls/styles.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Button } from 'antd';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const Container = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
`;
|
||||
|
||||
export const DownloadLogButton = styled(Button)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { Link, Pin } from '@signozhq/icons';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
@@ -12,19 +14,23 @@ import { ResizeTable } from 'components/ResizeTable';
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
import { RESTRICTED_SELECTED_FIELDS } from 'container/LogsFilters/config';
|
||||
import { MetricsType } from 'container/MetricsApplication/constant';
|
||||
import { FontSize, OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import history from 'lib/history';
|
||||
import { fieldSearchFilter } from 'lib/logs/fieldSearch';
|
||||
import { removeJSONStringifyQuotes } from 'lib/removeJSONStringifyQuotes';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Dispatch } from 'redux';
|
||||
import AppActions from 'types/actions';
|
||||
import { SET_DETAILED_LOG_DATA } from 'types/actions/logs';
|
||||
import { IField } from 'types/api/logs/fields';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import { ActionItemProps } from './ActionItem';
|
||||
import { RESTRICTED_SELECTED_FIELDS } from './config';
|
||||
import FieldRenderer from './FieldRenderer';
|
||||
import TableViewActions from './TableView/TableViewActions';
|
||||
import {
|
||||
@@ -59,6 +65,7 @@ function TableView({
|
||||
listViewPanelSelectedFields,
|
||||
handleChangeSelectedView,
|
||||
}: Props): JSX.Element | null {
|
||||
const dispatch = useDispatch<Dispatch<AppActions>>();
|
||||
const [isfilterInLoading, setIsFilterInLoading] = useState<boolean>(false);
|
||||
const [isfilterOutLoading, setIsFilterOutLoading] = useState<boolean>(false);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
@@ -178,6 +185,11 @@ function TableView({
|
||||
const spanId = flattenLogData?.span_id;
|
||||
|
||||
if (traceId) {
|
||||
dispatch({
|
||||
type: SET_DETAILED_LOG_DATA,
|
||||
payload: null,
|
||||
});
|
||||
|
||||
const basePath = generatePath(ROUTES.TRACE_DETAIL, {
|
||||
id: traceId,
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
import { RESTRICTED_SELECTED_FIELDS } from 'container/LogsFilters/config';
|
||||
import { MetricsType } from 'container/MetricsApplication/constant';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { ICurrentQueryData } from 'hooks/useHandleExplorerTabChange';
|
||||
@@ -26,7 +27,6 @@ import {
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
import { RESTRICTED_SELECTED_FIELDS } from '../config';
|
||||
import { DataType } from '../TableView';
|
||||
import {
|
||||
filterKeyForField,
|
||||
@@ -141,9 +141,10 @@ export default function TableViewActions(
|
||||
const { stagedQuery, updateQueriesData } = useQueryBuilder();
|
||||
const { dataType, logType: fieldType } = getFieldAttributes(record.field);
|
||||
|
||||
// there is no option for where clause in live logs page or infra monitoring
|
||||
const isLiveLogsOrInfraPage = useMemo(
|
||||
// there is no option for where clause in old logs explorer and live logs page or infra monitoring
|
||||
const isOldLogsExplorerOrLiveLogsPage = useMemo(
|
||||
() =>
|
||||
pathname === ROUTES.OLD_LOGS_EXPLORER ||
|
||||
pathname === ROUTES.LIVE_LOGS ||
|
||||
pathname === ROUTES.INFRASTRUCTURE_MONITORING_HOSTS ||
|
||||
pathname === ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES,
|
||||
@@ -399,7 +400,7 @@ export default function TableViewActions(
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
{!isLiveLogsOrInfraPage && (
|
||||
{!isOldLogsExplorerOrLiveLogsPage && (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
@@ -486,7 +487,7 @@ export default function TableViewActions(
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
{!isLiveLogsOrInfraPage && (
|
||||
{!isOldLogsExplorerOrLiveLogsPage && (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { RESTRICTED_SELECTED_FIELDS } from 'container/LogsFilters/config';
|
||||
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
|
||||
import { RESTRICTED_SELECTED_FIELDS } from '../../config';
|
||||
import TableViewActions from '../TableViewActions';
|
||||
import useAsyncJSONProcessing from '../useAsyncJSONProcessing';
|
||||
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
export const RESTRICTED_SELECTED_FIELDS = ['timestamp', 'id'];
|
||||
|
||||
// Fields that can be filtered on but not grouped by in the log details view.
|
||||
export const RESTRICTED_GROUP_BY_FIELDS = ['body', 'trace_id'];
|
||||
|
||||
export const typeToArrayTypeMapper: { [key in DataTypes]: DataTypes } = {
|
||||
[DataTypes.String]: DataTypes.ArrayString,
|
||||
[DataTypes.Float64]: DataTypes.ArrayFloat64,
|
||||
|
||||
@@ -61,7 +61,8 @@ export function useLogAttributeActions({
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
|
||||
?.active || false;
|
||||
|
||||
const isLiveLogs = pathname === ROUTES.LIVE_LOGS;
|
||||
const isOldExplorerOrLive =
|
||||
pathname === ROUTES.OLD_LOGS_EXPLORER || pathname === ROUTES.LIVE_LOGS;
|
||||
|
||||
const filterFor = useCallback(
|
||||
(context: FieldContext, isFilterIn: boolean): void => {
|
||||
@@ -220,7 +221,7 @@ export function useLogAttributeActions({
|
||||
!handleChangeSelectedView ||
|
||||
!buildLogFilterTarget(fieldKeyPath, undefined, isBodyJsonQueryEnabled)
|
||||
.groupBySupported ||
|
||||
isLiveLogs,
|
||||
isOldExplorerOrLive,
|
||||
},
|
||||
{
|
||||
key: LogDetailsAction.REPLACE_FILTER,
|
||||
@@ -228,7 +229,9 @@ export function useLogAttributeActions({
|
||||
icon: <RefreshCw size={12} />,
|
||||
onClick: replaceFilter,
|
||||
shouldHide: (_key, fieldKeyPath): boolean =>
|
||||
!handleChangeSelectedView || isRestricted(fieldKeyPath) || isLiveLogs,
|
||||
!handleChangeSelectedView ||
|
||||
isRestricted(fieldKeyPath) ||
|
||||
isOldExplorerOrLive,
|
||||
},
|
||||
];
|
||||
}, [
|
||||
@@ -236,7 +239,7 @@ export function useLogAttributeActions({
|
||||
groupBy,
|
||||
replaceFilter,
|
||||
isBodyJsonQueryEnabled,
|
||||
isLiveLogs,
|
||||
isOldExplorerOrLive,
|
||||
handleChangeSelectedView,
|
||||
onApplyLogFilter,
|
||||
]);
|
||||
|
||||
168
frontend/src/container/LogDetailedView/index.tsx
Normal file
168
frontend/src/container/LogDetailedView/index.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { memo, useCallback } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { connect, useDispatch, useSelector } from 'react-redux';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import LogDetail from 'components/LogDetail';
|
||||
import { VIEW_TYPES } from 'components/LogDetail/constants';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getOldLogsOperatorFromNew } from 'hooks/logs/useActiveLog';
|
||||
import { getGeneratedFilterQueryString } from 'lib/getGeneratedFilterQueryString';
|
||||
import getStep from 'lib/getStep';
|
||||
import { getIdConditions } from 'pages/Logs/utils';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { bindActionCreators, Dispatch } from 'redux';
|
||||
import { ThunkDispatch } from 'redux-thunk';
|
||||
import { getLogs } from 'store/actions/logs/getLogs';
|
||||
import { getLogsAggregate } from 'store/actions/logs/getLogsAggregate';
|
||||
import { AppState } from 'store/reducers';
|
||||
import AppActions from 'types/actions';
|
||||
import {
|
||||
SET_DETAILED_LOG_DATA,
|
||||
SET_SEARCH_QUERY_STRING,
|
||||
TOGGLE_LIVE_TAIL,
|
||||
} from 'types/actions/logs';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { ILogsReducer } from 'types/reducer/logs';
|
||||
|
||||
type LogDetailedViewProps = {
|
||||
getLogs: (props: Parameters<typeof getLogs>[0]) => ReturnType<typeof getLogs>;
|
||||
getLogsAggregate: (
|
||||
props: Parameters<typeof getLogsAggregate>[0],
|
||||
) => ReturnType<typeof getLogsAggregate>;
|
||||
};
|
||||
|
||||
function LogDetailedView({
|
||||
getLogs,
|
||||
getLogsAggregate,
|
||||
}: LogDetailedViewProps): JSX.Element {
|
||||
const history = useHistory();
|
||||
const {
|
||||
detailedLog,
|
||||
searchFilter: { queryString },
|
||||
logLinesPerPage,
|
||||
idStart,
|
||||
liveTail,
|
||||
idEnd,
|
||||
order,
|
||||
} = useSelector<AppState, ILogsReducer>((state) => state.logs);
|
||||
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const dispatch = useDispatch<Dispatch<AppActions>>();
|
||||
|
||||
const onDrawerClose = (): void => {
|
||||
dispatch({
|
||||
type: SET_DETAILED_LOG_DATA,
|
||||
payload: null,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddToQuery = useCallback(
|
||||
(fieldKey: string, fieldValue: string, operator: string) => {
|
||||
const newOperator = getOldLogsOperatorFromNew(operator);
|
||||
const updatedQueryString = getGeneratedFilterQueryString(
|
||||
fieldKey,
|
||||
fieldValue,
|
||||
newOperator,
|
||||
queryString,
|
||||
);
|
||||
|
||||
history.replace(`${ROUTES.OLD_LOGS_EXPLORER}?q=${updatedQueryString}`);
|
||||
},
|
||||
[history, queryString],
|
||||
);
|
||||
|
||||
const handleClickActionItem = useCallback(
|
||||
(fieldKey: string, fieldValue: string, operator: string): void => {
|
||||
const newOperator = getOldLogsOperatorFromNew(operator);
|
||||
const updatedQueryString = getGeneratedFilterQueryString(
|
||||
fieldKey,
|
||||
fieldValue,
|
||||
newOperator,
|
||||
queryString,
|
||||
);
|
||||
|
||||
dispatch({
|
||||
type: SET_SEARCH_QUERY_STRING,
|
||||
payload: {
|
||||
searchQueryString: updatedQueryString,
|
||||
},
|
||||
});
|
||||
|
||||
if (liveTail === 'STOPPED') {
|
||||
getLogs({
|
||||
q: updatedQueryString,
|
||||
limit: logLinesPerPage,
|
||||
orderBy: 'timestamp',
|
||||
order,
|
||||
timestampStart: minTime,
|
||||
timestampEnd: maxTime,
|
||||
...getIdConditions(idStart, idEnd, order),
|
||||
});
|
||||
getLogsAggregate({
|
||||
timestampStart: minTime,
|
||||
timestampEnd: maxTime,
|
||||
step: getStep({
|
||||
start: minTime,
|
||||
end: maxTime,
|
||||
inputFormat: 'ns',
|
||||
}),
|
||||
q: updatedQueryString,
|
||||
});
|
||||
} else if (liveTail === 'PLAYING') {
|
||||
dispatch({
|
||||
type: TOGGLE_LIVE_TAIL,
|
||||
payload: 'PAUSED',
|
||||
});
|
||||
setTimeout(
|
||||
() =>
|
||||
dispatch({
|
||||
type: TOGGLE_LIVE_TAIL,
|
||||
payload: liveTail,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
}
|
||||
},
|
||||
[
|
||||
dispatch,
|
||||
getLogs,
|
||||
getLogsAggregate,
|
||||
idEnd,
|
||||
idStart,
|
||||
liveTail,
|
||||
logLinesPerPage,
|
||||
maxTime,
|
||||
minTime,
|
||||
order,
|
||||
queryString,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<LogDetail
|
||||
selectedTab={VIEW_TYPES.OVERVIEW}
|
||||
log={detailedLog}
|
||||
onClose={onDrawerClose}
|
||||
onAddToQuery={handleAddToQuery}
|
||||
onClickActionItem={handleClickActionItem}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface DispatchProps {
|
||||
getLogs: (props: Parameters<typeof getLogs>[0]) => (dispatch: never) => void;
|
||||
getLogsAggregate: (
|
||||
props: Parameters<typeof getLogsAggregate>[0],
|
||||
) => (dispatch: never) => void;
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (
|
||||
dispatch: ThunkDispatch<unknown, unknown, AppActions>,
|
||||
): DispatchProps => ({
|
||||
getLogs: bindActionCreators(getLogs, dispatch),
|
||||
getLogsAggregate: bindActionCreators(getLogsAggregate, dispatch),
|
||||
});
|
||||
|
||||
export default connect(null, mapDispatchToProps)(memo(LogDetailedView as any));
|
||||
@@ -5,6 +5,10 @@ import {
|
||||
QUERY_BUILDER_FUNCTIONS,
|
||||
} from 'constants/antlrQueryConstants';
|
||||
import { OPERATORS as QUERY_BUILDER_OPERATORS } from 'constants/queryBuilder';
|
||||
import {
|
||||
RESTRICTED_GROUP_BY_FIELDS,
|
||||
RESTRICTED_SELECTED_FIELDS,
|
||||
} from 'container/LogsFilters/config';
|
||||
import { MetricsType } from 'container/MetricsApplication/constant';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
|
||||
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
|
||||
@@ -14,10 +18,6 @@ import {
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import {
|
||||
RESTRICTED_GROUP_BY_FIELDS,
|
||||
RESTRICTED_SELECTED_FIELDS,
|
||||
} from './config';
|
||||
import { LogAttributeBucket } from './constants';
|
||||
import { generateFieldKeyForArray, getDataTypes } from './utils';
|
||||
|
||||
|
||||
26
frontend/src/container/LogLiveTail/config.ts
Normal file
26
frontend/src/container/LogLiveTail/config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export const TIME_PICKER_OPTIONS = [
|
||||
{
|
||||
value: 5,
|
||||
label: '5m',
|
||||
},
|
||||
{
|
||||
value: 15,
|
||||
label: '15m',
|
||||
},
|
||||
{
|
||||
value: 30,
|
||||
label: '30m',
|
||||
},
|
||||
{
|
||||
value: 60,
|
||||
label: '1hr',
|
||||
},
|
||||
{
|
||||
value: 360,
|
||||
label: '6hrs',
|
||||
},
|
||||
{
|
||||
value: 720,
|
||||
label: '12hrs',
|
||||
},
|
||||
];
|
||||
270
frontend/src/container/LogLiveTail/index.tsx
Normal file
270
frontend/src/container/LogLiveTail/index.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { connect, useDispatch, useSelector } from 'react-redux';
|
||||
import { green } from '@ant-design/colors';
|
||||
import { Pause, Play, EllipsisVertical } from '@signozhq/icons';
|
||||
import { Button, Flex, Popover, Select, Space } from 'antd';
|
||||
import { LiveTail } from 'api/logs/livetail';
|
||||
import dayjs from 'dayjs';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import getStep from 'lib/getStep';
|
||||
import { throttle } from 'lodash-es';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { bindActionCreators, Dispatch } from 'redux';
|
||||
import { ThunkDispatch } from 'redux-thunk';
|
||||
import { getLogsAggregate } from 'store/actions/logs/getLogsAggregate';
|
||||
import { AppState } from 'store/reducers';
|
||||
import AppActions from 'types/actions';
|
||||
import { UPDATE_AUTO_REFRESH_DISABLED } from 'types/actions/globalTime';
|
||||
import {
|
||||
FLUSH_LOGS,
|
||||
PUSH_LIVE_TAIL_EVENT,
|
||||
SET_LIVE_TAIL_START_TIME,
|
||||
SET_LOADING,
|
||||
TOGGLE_LIVE_TAIL,
|
||||
} from 'types/actions/logs';
|
||||
import { TLogsLiveTailState } from 'types/api/logs/liveTail';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { ILogsReducer } from 'types/reducer/logs';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { TIME_PICKER_OPTIONS } from './config';
|
||||
import { StopContainer, TimePickerCard, TimePickerSelect } from './styles';
|
||||
|
||||
function LogLiveTail({ getLogsAggregate }: Props): JSX.Element {
|
||||
const {
|
||||
liveTail,
|
||||
searchFilter: { queryString },
|
||||
liveTailStartRange,
|
||||
logs,
|
||||
idEnd,
|
||||
idStart,
|
||||
} = useSelector<AppState, ILogsReducer>((state) => state.logs);
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const { selectedAutoRefreshInterval } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const dispatch = useDispatch<Dispatch<AppActions>>();
|
||||
const handleLiveTail = (toggleState: TLogsLiveTailState): void => {
|
||||
dispatch({
|
||||
type: TOGGLE_LIVE_TAIL,
|
||||
payload: toggleState,
|
||||
});
|
||||
dispatch({
|
||||
type: UPDATE_AUTO_REFRESH_DISABLED,
|
||||
payload: toggleState === 'PLAYING',
|
||||
});
|
||||
};
|
||||
|
||||
const batchedEventsRef = useRef<ILog[]>([]);
|
||||
|
||||
const pushLiveLog = useCallback(() => {
|
||||
dispatch({
|
||||
type: PUSH_LIVE_TAIL_EVENT,
|
||||
payload: batchedEventsRef.current.reverse(),
|
||||
});
|
||||
batchedEventsRef.current = [];
|
||||
}, [dispatch]);
|
||||
|
||||
const pushLiveLogThrottled = useMemo(
|
||||
() => throttle(pushLiveLog, 1000),
|
||||
[pushLiveLog],
|
||||
);
|
||||
|
||||
const batchLiveLog = useCallback(
|
||||
(e: { data: string }): void => {
|
||||
batchedEventsRef.current.push(JSON.parse(e.data as string) as never);
|
||||
pushLiveLogThrottled();
|
||||
},
|
||||
[pushLiveLogThrottled],
|
||||
);
|
||||
|
||||
const firstLogsId = useMemo(() => logs[0]?.id, [logs]);
|
||||
|
||||
// This ref depicts thats whether the live tail is played from paused state or not.
|
||||
const liveTailSourceRef = useRef<EventSource>();
|
||||
|
||||
useEffect(() => {
|
||||
if (liveTail === 'PLAYING') {
|
||||
const timeStamp = dayjs().subtract(liveTailStartRange, 'minute').valueOf();
|
||||
const queryParams = new URLSearchParams({
|
||||
...(queryString ? { q: queryString } : {}),
|
||||
timestampStart: (timeStamp * 1e6) as never,
|
||||
...(liveTailSourceRef.current && firstLogsId
|
||||
? {
|
||||
idGt: firstLogsId,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
if (liveTailSourceRef.current) {
|
||||
liveTailSourceRef.current.close();
|
||||
}
|
||||
|
||||
const source = LiveTail(queryParams.toString());
|
||||
liveTailSourceRef.current = source;
|
||||
source.onmessage = function connectionMessage(e): void {
|
||||
batchLiveLog(e);
|
||||
};
|
||||
source.onerror = function connectionError(event: unknown): void {
|
||||
console.error(event);
|
||||
source.close();
|
||||
dispatch({
|
||||
type: TOGGLE_LIVE_TAIL,
|
||||
payload: 'STOPPED',
|
||||
});
|
||||
dispatch({
|
||||
type: SET_LOADING,
|
||||
payload: false,
|
||||
});
|
||||
notifications.error({
|
||||
message: 'Live tail stopped due to some error.',
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
if (liveTail === 'STOPPED') {
|
||||
liveTailSourceRef.current = undefined;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [liveTail, queryString, notifications, dispatch]);
|
||||
|
||||
const handleLiveTailStart = (): void => {
|
||||
handleLiveTail('PLAYING');
|
||||
const startTime =
|
||||
dayjs().subtract(liveTailStartRange, 'minute').valueOf() * 1e6;
|
||||
|
||||
const endTime = dayjs().valueOf() * 1e6;
|
||||
|
||||
getLogsAggregate({
|
||||
timestampStart: startTime,
|
||||
timestampEnd: endTime,
|
||||
step: getStep({
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
inputFormat: 'ns',
|
||||
}),
|
||||
q: queryString,
|
||||
...(idStart ? { idGt: idStart } : {}),
|
||||
...(idEnd ? { idLt: idEnd } : {}),
|
||||
});
|
||||
|
||||
if (!liveTailSourceRef.current) {
|
||||
dispatch({
|
||||
type: FLUSH_LOGS,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const OptionsPopOverContent = useMemo(
|
||||
() => (
|
||||
<TimePickerSelect
|
||||
getPopupContainer={popupContainer}
|
||||
disabled={liveTail === 'PLAYING'}
|
||||
value={liveTailStartRange}
|
||||
onChange={(value): void => {
|
||||
if (typeof value === 'number') {
|
||||
dispatch({
|
||||
type: SET_LIVE_TAIL_START_TIME,
|
||||
payload: value,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{TIME_PICKER_OPTIONS.map((optionData) => (
|
||||
<Select.Option key={optionData.label} value={optionData.value}>
|
||||
Last {optionData.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</TimePickerSelect>
|
||||
),
|
||||
[dispatch, liveTail, liveTailStartRange],
|
||||
);
|
||||
|
||||
const isDisabled = useMemo(
|
||||
() => selectedAutoRefreshInterval?.length > 0,
|
||||
[selectedAutoRefreshInterval],
|
||||
);
|
||||
|
||||
const onLiveTailStop = (): void => {
|
||||
handleLiveTail('STOPPED');
|
||||
dispatch({
|
||||
type: UPDATE_AUTO_REFRESH_DISABLED,
|
||||
payload: false,
|
||||
});
|
||||
dispatch({
|
||||
type: SET_LOADING,
|
||||
payload: false,
|
||||
});
|
||||
if (liveTailSourceRef.current) {
|
||||
liveTailSourceRef.current.close();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TimePickerCard>
|
||||
<Space size={0} align="center">
|
||||
{liveTail === 'PLAYING' ? (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={onLiveTailStop}
|
||||
title="Pause live tail"
|
||||
style={{ background: green[6] }}
|
||||
>
|
||||
<Flex align="center" gap={4}>
|
||||
<span>Pause</span>
|
||||
<Pause size="md" />
|
||||
</Flex>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleLiveTailStart}
|
||||
title="Start live tail"
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<Flex align="center" gap={4}>
|
||||
Go Live <Play size="md" />
|
||||
</Flex>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{liveTail !== 'STOPPED' && (
|
||||
<Button type="dashed" onClick={onLiveTailStop} title="Exit live tail">
|
||||
<StopContainer isDarkMode={isDarkMode} />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Popover
|
||||
getPopupContainer={popupContainer}
|
||||
placement="bottomRight"
|
||||
title="Select Live Tail Timing"
|
||||
trigger="click"
|
||||
content={OptionsPopOverContent}
|
||||
>
|
||||
<EllipsisVertical size="lg" />
|
||||
</Popover>
|
||||
</Space>
|
||||
</TimePickerCard>
|
||||
);
|
||||
}
|
||||
|
||||
interface DispatchProps {
|
||||
getLogsAggregate: typeof getLogsAggregate;
|
||||
}
|
||||
|
||||
type Props = DispatchProps;
|
||||
|
||||
const mapDispatchToProps = (
|
||||
dispatch: ThunkDispatch<unknown, unknown, AppActions>,
|
||||
): DispatchProps => ({
|
||||
getLogsAggregate: bindActionCreators(getLogsAggregate, dispatch),
|
||||
});
|
||||
|
||||
export default connect(null, mapDispatchToProps)(LogLiveTail);
|
||||
25
frontend/src/container/LogLiveTail/styles.ts
Normal file
25
frontend/src/container/LogLiveTail/styles.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Card, Select } from 'antd';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const TimePickerCard = styled(Card)`
|
||||
.ant-card-body {
|
||||
display: flex;
|
||||
padding: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export const TimePickerSelect = styled(Select)`
|
||||
min-width: 100px;
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
export const StopContainer = styled.div<Props>`
|
||||
height: 0.8rem;
|
||||
width: 0.8rem;
|
||||
border-radius: 0.1rem;
|
||||
background-color: ${({ isDarkMode }): string =>
|
||||
isDarkMode ? '#fff' : '#000'};
|
||||
`;
|
||||
95
frontend/src/container/LogsAggregate/index.tsx
Normal file
95
frontend/src/container/LogsAggregate/index.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useMemo } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { connect, useSelector } from 'react-redux';
|
||||
import { blue } from '@ant-design/colors';
|
||||
import Graph from 'components/Graph';
|
||||
import Spinner from 'components/Spinner';
|
||||
import dayjs from 'dayjs';
|
||||
import useInterval from 'hooks/useInterval';
|
||||
import getStep from 'lib/getStep';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { ThunkDispatch } from 'redux-thunk';
|
||||
import { getLogsAggregate } from 'store/actions/logs/getLogsAggregate';
|
||||
import { AppState } from 'store/reducers';
|
||||
import AppActions from 'types/actions';
|
||||
import { ILogsReducer } from 'types/reducer/logs';
|
||||
|
||||
import { Container } from './styles';
|
||||
|
||||
function LogsAggregate({ getLogsAggregate }: DispatchProps): JSX.Element {
|
||||
const {
|
||||
searchFilter: { queryString },
|
||||
idEnd,
|
||||
idStart,
|
||||
isLoadingAggregate,
|
||||
logsAggregate,
|
||||
liveTail,
|
||||
liveTailStartRange,
|
||||
} = useSelector<AppState, ILogsReducer>((state) => state.logs);
|
||||
|
||||
useInterval(
|
||||
() => {
|
||||
const startTime =
|
||||
dayjs().subtract(liveTailStartRange, 'minute').valueOf() * 1e6;
|
||||
|
||||
const endTime = dayjs().valueOf() * 1e6;
|
||||
|
||||
getLogsAggregate({
|
||||
timestampStart: startTime,
|
||||
timestampEnd: endTime,
|
||||
step: getStep({
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
inputFormat: 'ns',
|
||||
}),
|
||||
q: queryString,
|
||||
...(idStart ? { idGt: idStart } : {}),
|
||||
...(idEnd ? { idLt: idEnd } : {}),
|
||||
});
|
||||
},
|
||||
60000,
|
||||
liveTail === 'PLAYING',
|
||||
);
|
||||
|
||||
const graphData = useMemo(
|
||||
() => ({
|
||||
labels: logsAggregate.map((s) => new Date(s.timestamp / 1000000)),
|
||||
datasets: [
|
||||
{
|
||||
data: logsAggregate.map((s) => s.value),
|
||||
backgroundColor: blue[4],
|
||||
},
|
||||
],
|
||||
}),
|
||||
[logsAggregate],
|
||||
);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{isLoadingAggregate ? (
|
||||
<Spinner size="default" height="100%" />
|
||||
) : (
|
||||
<Graph
|
||||
name="usage"
|
||||
data={graphData}
|
||||
type="bar"
|
||||
containerHeight="100%"
|
||||
animate
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
interface DispatchProps {
|
||||
getLogsAggregate: typeof getLogsAggregate;
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (
|
||||
dispatch: ThunkDispatch<unknown, unknown, AppActions>,
|
||||
): DispatchProps => ({
|
||||
getLogsAggregate: bindActionCreators(getLogsAggregate, dispatch),
|
||||
});
|
||||
|
||||
export default connect(null, mapDispatchToProps)(LogsAggregate);
|
||||
11
frontend/src/container/LogsAggregate/styles.ts
Normal file
11
frontend/src/container/LogsAggregate/styles.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Card } from 'antd';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const Container = styled(Card)`
|
||||
position: relative;
|
||||
margin: 0.5rem 0;
|
||||
.ant-card-body {
|
||||
height: 20vh;
|
||||
min-height: 200px;
|
||||
}
|
||||
`;
|
||||
93
frontend/src/container/LogsFilters/FieldItem.tsx
Normal file
93
frontend/src/container/LogsFilters/FieldItem.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { ReactNode, useCallback, useMemo, useState } from 'react';
|
||||
import { Loader } from '@signozhq/icons';
|
||||
import { Button, Popover, Spin } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import {
|
||||
IField,
|
||||
IInterestingFields,
|
||||
ISelectedFields,
|
||||
} from 'types/api/logs/fields';
|
||||
|
||||
import { ICON_STYLE } from './config';
|
||||
import { Field } from './styles';
|
||||
|
||||
function FieldItem({
|
||||
name,
|
||||
buttonIcon,
|
||||
buttonOnClick,
|
||||
fieldData,
|
||||
fieldIndex,
|
||||
isLoading,
|
||||
iconHoverText,
|
||||
}: FieldItemProps): JSX.Element {
|
||||
const [isHovered, setIsHovered] = useState<boolean>(false);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const onClickHandler = useCallback(() => {
|
||||
if (!isLoading && buttonOnClick) {
|
||||
buttonOnClick({ fieldData, fieldIndex });
|
||||
}
|
||||
}, [buttonOnClick, fieldData, fieldIndex, isLoading]);
|
||||
|
||||
const renderContent = useMemo(() => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Spin
|
||||
spinning
|
||||
size="small"
|
||||
indicator={<Loader className="animate-spin" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isHovered) {
|
||||
return (
|
||||
<Popover content={<Typography>{iconHoverText}</Typography>}>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={buttonIcon}
|
||||
onClick={onClickHandler}
|
||||
/>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [buttonIcon, iconHoverText, isHovered, isLoading, onClickHandler]);
|
||||
|
||||
const onMouseHoverHandler = useCallback(
|
||||
(value: boolean) => (): void => {
|
||||
setIsHovered(value);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Field
|
||||
onMouseEnter={onMouseHoverHandler(true)}
|
||||
onMouseLeave={onMouseHoverHandler(false)}
|
||||
isDarkMode={isDarkMode}
|
||||
>
|
||||
<Typography style={ICON_STYLE.PLUS}>{name}</Typography>
|
||||
|
||||
{renderContent}
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
interface FieldItemProps {
|
||||
name: string;
|
||||
buttonIcon: ReactNode;
|
||||
buttonOnClick: (props: {
|
||||
fieldData: IInterestingFields | ISelectedFields;
|
||||
fieldIndex: number;
|
||||
}) => void;
|
||||
fieldData: IField;
|
||||
fieldIndex: number;
|
||||
isLoading: boolean;
|
||||
iconHoverText: string;
|
||||
}
|
||||
|
||||
export default FieldItem;
|
||||
11
frontend/src/container/LogsFilters/config.ts
Normal file
11
frontend/src/container/LogsFilters/config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { blue, red } from '@ant-design/colors';
|
||||
|
||||
export const RESTRICTED_SELECTED_FIELDS = ['timestamp', 'id'];
|
||||
|
||||
// Fields that can be filtered on but not grouped by in the log details view.
|
||||
export const RESTRICTED_GROUP_BY_FIELDS = ['body', 'trace_id'];
|
||||
|
||||
export const ICON_STYLE = {
|
||||
PLUS: { color: blue[5] },
|
||||
CLOSE: { color: red[5] },
|
||||
};
|
||||
119
frontend/src/container/LogsFilters/index.tsx
Normal file
119
frontend/src/container/LogsFilters/index.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { ChangeEvent, useCallback, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { CirclePlus, X } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Col } from 'antd';
|
||||
import CategoryHeading from 'components/Logs/CategoryHeading';
|
||||
import { fieldSearchFilter } from 'lib/logs/fieldSearch';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { ILogsReducer } from 'types/reducer/logs';
|
||||
|
||||
import { ICON_STYLE, RESTRICTED_SELECTED_FIELDS } from './config';
|
||||
import FieldItem from './FieldItem';
|
||||
import { CategoryContainer, FieldContainer } from './styles';
|
||||
import { IHandleInterestProps, IHandleRemoveInterestProps } from './types';
|
||||
import { onHandleAddInterest, onHandleRemoveInterest } from './utils';
|
||||
|
||||
function LogsFilters(): JSX.Element {
|
||||
const {
|
||||
fields: { interesting, selected },
|
||||
} = useSelector<AppState, ILogsReducer>((state) => state.logs);
|
||||
|
||||
const [selectedFieldLoading, setSelectedFieldLoading] = useState<number[]>([]);
|
||||
const [interestingFieldLoading, setInterestingFieldLoading] = useState<
|
||||
number[]
|
||||
>([]);
|
||||
|
||||
const [filterValuesInput, setFilterValuesInput] = useState('');
|
||||
const handleSearch = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
setFilterValuesInput((e.target as HTMLInputElement).value);
|
||||
};
|
||||
|
||||
const onHandleAddSelectedToInteresting = useCallback(
|
||||
({ fieldData, fieldIndex }: IHandleInterestProps) =>
|
||||
(): Promise<void> =>
|
||||
onHandleAddInterest({
|
||||
fieldData,
|
||||
fieldIndex,
|
||||
interesting,
|
||||
interestingFieldLoading,
|
||||
setInterestingFieldLoading,
|
||||
selected,
|
||||
}),
|
||||
[interesting, interestingFieldLoading, selected],
|
||||
);
|
||||
|
||||
const onHandleRemoveSelected = useCallback(
|
||||
({ fieldData, fieldIndex }: IHandleRemoveInterestProps) =>
|
||||
(): Promise<void> =>
|
||||
onHandleRemoveInterest({
|
||||
fieldData,
|
||||
fieldIndex,
|
||||
interesting,
|
||||
interestingFieldLoading,
|
||||
selected,
|
||||
setSelectedFieldLoading,
|
||||
}),
|
||||
[interesting, interestingFieldLoading, selected, setSelectedFieldLoading],
|
||||
);
|
||||
|
||||
return (
|
||||
<Col flex="250px">
|
||||
<Input
|
||||
placeholder="Filter Values"
|
||||
onInput={handleSearch}
|
||||
value={filterValuesInput}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
|
||||
<CategoryContainer>
|
||||
<CategoryHeading>SELECTED FIELDS</CategoryHeading>
|
||||
<FieldContainer>
|
||||
{selected
|
||||
.filter((field) => fieldSearchFilter(field.name, filterValuesInput))
|
||||
.filter((field) => RESTRICTED_SELECTED_FIELDS.indexOf(field.name) === -1)
|
||||
.map((field, idx) => (
|
||||
<FieldItem
|
||||
key={`${JSON.stringify(field)}`}
|
||||
name={field.name}
|
||||
fieldData={field}
|
||||
fieldIndex={idx}
|
||||
buttonIcon={<X style={ICON_STYLE.CLOSE} size="md" />}
|
||||
buttonOnClick={onHandleRemoveSelected({
|
||||
fieldData: field,
|
||||
fieldIndex: idx,
|
||||
})}
|
||||
isLoading={selectedFieldLoading.includes(idx)}
|
||||
iconHoverText="Remove from Selected Fields"
|
||||
/>
|
||||
))}
|
||||
</FieldContainer>
|
||||
</CategoryContainer>
|
||||
<CategoryContainer>
|
||||
<CategoryHeading>INTERESTING FIELDS</CategoryHeading>
|
||||
<FieldContainer>
|
||||
{interesting
|
||||
.filter((field) => fieldSearchFilter(field.name, filterValuesInput))
|
||||
.map((field, idx) => (
|
||||
<FieldItem
|
||||
key={`${JSON.stringify(field)}`}
|
||||
name={field.name}
|
||||
fieldData={field}
|
||||
fieldIndex={idx}
|
||||
buttonIcon={<CirclePlus style={ICON_STYLE.PLUS} size="md" />}
|
||||
buttonOnClick={onHandleAddSelectedToInteresting({
|
||||
fieldData: field,
|
||||
fieldIndex: idx,
|
||||
})}
|
||||
isLoading={interestingFieldLoading.includes(idx)}
|
||||
iconHoverText="Add to Selected Fields"
|
||||
/>
|
||||
))}
|
||||
</FieldContainer>
|
||||
</CategoryContainer>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
export default LogsFilters;
|
||||
29
frontend/src/container/LogsFilters/styles.ts
Normal file
29
frontend/src/container/LogsFilters/styles.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { blue, grey } from '@ant-design/colors';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const CategoryContainer = styled.div`
|
||||
margin: 1rem 0;
|
||||
padding-left: 0.2rem;
|
||||
`;
|
||||
|
||||
export const FieldContainer = styled(Typography.Text)`
|
||||
margin: 0.2rem 0;
|
||||
color: ${blue[4]};
|
||||
`;
|
||||
|
||||
export const Field = styled.div<{ isDarkMode: boolean }>`
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
height: 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
&:hover {
|
||||
background: ${({ isDarkMode }): string => (isDarkMode ? grey[7] : '#ddd')};
|
||||
}
|
||||
`;
|
||||
|
||||
export const ExtractField = styled(Typography.Text)`
|
||||
color: ${blue[4]};
|
||||
`;
|
||||
36
frontend/src/container/LogsFilters/types.ts
Normal file
36
frontend/src/container/LogsFilters/types.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { SetStateAction } from 'react';
|
||||
import {
|
||||
IField,
|
||||
IInterestingFields,
|
||||
ISelectedFields,
|
||||
} from 'types/api/logs/fields';
|
||||
|
||||
type SetLoading = (value: SetStateAction<number[]>) => void;
|
||||
|
||||
export type IHandleInterestProps = {
|
||||
fieldData: IInterestingFields;
|
||||
fieldIndex: number;
|
||||
};
|
||||
|
||||
export type IHandleRemoveInterestProps = {
|
||||
fieldData: ISelectedFields;
|
||||
fieldIndex: number;
|
||||
};
|
||||
|
||||
export interface OnHandleAddInterestProps {
|
||||
setInterestingFieldLoading: SetLoading;
|
||||
fieldIndex: number;
|
||||
fieldData: ISelectedFields;
|
||||
interesting: IField[];
|
||||
interestingFieldLoading: number[];
|
||||
selected: IField[];
|
||||
}
|
||||
|
||||
export interface OnHandleRemoveInterestProps {
|
||||
setSelectedFieldLoading: SetLoading;
|
||||
selected: IField[];
|
||||
interesting: IField[];
|
||||
interestingFieldLoading: number[];
|
||||
fieldData: IInterestingFields;
|
||||
fieldIndex: number;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user