Compare commits

..

13 Commits

Author SHA1 Message Date
Gaurav Tewari
6d7c08172a feat: maketrace columns appear in a specific order 2026-09-22 15:48:20 +05:30
Gaurav Tewari
d879273e44 chore: allow users to move trace id 2026-09-22 15:48:20 +05:30
Gaurav Tewari
a4314382d1 chore: update colors for drawers inputs 2026-09-22 15:48:20 +05:30
Gaurav Tewari
9844e81c36 chore: removed unused padding in mapping tabel 2026-09-22 15:48:20 +05:30
Gaurav Tewari
fa6197ced1 chore: migrate tabs in model pricing & attribute mapping to antD tabs 2026-09-22 15:48:11 +05:30
Gaurav Tewari
89bb599cbe chore: make label of add model cost look same 2026-09-22 15:29:30 +05:30
Gaurav Tewari
e3bc7fa8e8 fix: create new mapping color 2026-09-22 15:29:09 +05:30
Gaurav Tewari
d61b558334 fix: test json being send 2026-09-22 10:35:54 +05:30
Aditya Singh
d39467f5ef chore(logs): delete the old logs explorer (#12930)
#### Description

- `/logs/old-logs-explorer` has been rendering nothing since #8299
gutted its two endpoints. deleted the page along with its containers,
the logs redux slice, the legacy `api/logs` clients and the logql
parser. `LogViewMode` and the restricted field constants move out first
since they have consumers elsewhere.
- deleted what the removal orphans.. the logql test fixture,
`CategoryHeading` and the antd table behind `components/Logs/TableView`.
that folder does not go entirely, `useLogsTableColumns` and
`ColumnTypeRender` still have consumers.
- `ROUTES.LOGS` and `ROUTES.LOGS_EXPLORER` are the same path and both
were registered exact. Switch takes the first so the second entry never
ran and its chunk just duplicated the first. dropped the entry, both
constants stay.

#### Issues closed by this PR

Part of https://github.com/SigNoz/engineering-pod/issues/6103

#### Additional Information

- frontend only. `GET /api/v1/logs` and `/api/v1/logs/aggregate` stay
registered and still return empty payloads, so this can go in without a
backend change.
- overlaps #12919 (old trace explorer) on the `NewExplorerCTA` cleanup,
and overlaps the log details v1 removal on a few files it deletes
outright. whichever merges second needs a rebase.
- cc. @therealpandey
2026-09-22 04:44:25 +00:00
Gaurav Tewari
d457ce6144 feat(llm-observability): move module tabs to RouteTab , add tab icons & other ui changes (#12762)
#### Description

- Replaces the bespoke LLM Observability tab shell
(`container/LLMObservability/LLMObservability.tsx` +
`useLLMObservabilityTabs`) with the shared `RouteTab` component that
every other module page already uses. Tabs are now declared as
`TabRoutes` in `pages/LLMObservability/constants.tsx`.
- Adds icons to the tab labels across LLM Observability, Infrastructure
Monitoring, All Errors, Messaging Queues, Meter Explorer and Logs
Settings, following the existing `.tab-item` pattern.
- Fixes tab-panel padding and aligns the source-filter select height
with the adjacent search input.
- Removed the dashboardInfoWithActions


#### Additional Information

`AllErrors/config.ts` is renamed to `.tsx` since the tab labels now
contain JSX.

Visual Diff - 
1. Look into the wrapper  - 
 a. Before 
<img width="1474" height="997" alt="image"
src="https://github.com/user-attachments/assets/39fe854b-fa79-4e24-9227-4da77825f726"
/>
b. Now 
<img width="1489" height="991" alt="image"
src="https://github.com/user-attachments/assets/4c02e991-3764-4f66-9efc-bef934ca5e2a"
/>



2. Removed the dashboardInfoWithActions 
a. Before 
<img width="1392" height="304" alt="image"
src="https://github.com/user-attachments/assets/bb2efef8-5126-4728-b221-b2cc2b3da18f"
/>
b. now
<img width="1420" height="290" alt="image"
src="https://github.com/user-attachments/assets/50412047-5eff-4035-ac6c-ed79393070ac"
/>


3. After feedback from nitya we have to hide add variable and the
actions button as well.
a. Before
<img width="1368" height="597" alt="image"
src="https://github.com/user-attachments/assets/669de39b-bbc2-4b29-a16e-139457fbb483"
/>

b. Now
<img width="1382" height="728" alt="image"
src="https://github.com/user-attachments/assets/7973dc3a-9914-42ed-a334-887ea73b1ba0"
/>

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-09-22 04:44:00 +00:00
Nityananda Gohain
64fff60d7e chore: update dashboard for ai observability (#12875)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description
Adds the actual dashboard to the backend.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Part of https://github.com/SigNoz/engineering-pod/issues/4501

### Additional information
- Draft until the dashboard DTO changes merge; the query fixes below
land on top of them.
- will update the dashboard based on ai query builder
2026-09-21 17:57:58 +00:00
Nityananda Gohain
dd3b99f19c fix: update attribute mappings for ai (#12925)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

Version 2 of the `gen_ai.llm` and `gen_ai.tool` defaults, fixes only:

- Tool condition narrowed to `tool.name`, `ai.toolCall.`,
`tool_call_args`, `tool_response`; the bare `tool` substring was firing
on LLM spans.
- Dropped the `gen_ai.operation.name` mapper; `llm.request.type`
overwrote native values with non-semconv ones.
- Added `ai.response.toolCalls` as an output messages source for Vercel
tool-call turns.
- Renamed `gen_ai.response.finish_reason` to
`gen_ai.response.finish_reasons`.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

Part of https://github.com/SigNoz/engineering-pod/issues/6107
2026-09-21 17:14:18 +00:00
Aditya Singh
2ff7e7d3af test(traces-explorer): flaky "select a view" test on CI (#12931)
#### Description

- `select a view options` in the traces explorer test flakes on CI..
`findByRole` defaults to a 1s timeout and the saved views call has to
land and re-render the dropdown inside that window. locally the option
only shows up at ~550-680ms, so there is barely any headroom and a
loaded runner tips it over.
- bumped just that one query to 5s. the wait sits on the assertion that
actually times out, so nothing else in the test moves and it still fails
loudly if the option stops rendering.
- costs nothing when things are fast.. findByRole resolves the moment
the option appears, the timeout is only a cap. test stays ~1.2s before
and after.

#### Additional Information

- verified with a repro.. delaying the views handler by 1500ms fails
without the change with the same error CI gives, and passes with it.
- opening the dropdown before the views land is not the problem, antd
re-renders the options once the data arrives. so no reordering needed
here.
- the real driver is the `test / js` job at 7-10 min on a single
unsharded runner. a repo-wide `asyncUtilTimeout` default plus sharding
that job is the proper fix for this class of flake, not doing it here.
2026-09-21 16:43:41 +00:00
429 changed files with 3398 additions and 39165 deletions

View File

@@ -26,135 +26,6 @@ process on top of it.
5. **Verify in the browser**: [references/verify.md](references/verify.md). Never
report the story as done without it.
## Where it lands in the sidebar
The sidebar mirrors the app's own side nav (`container/SideNav/menuItems.tsx`), so
a page sits where someone would click it in the product. Four things decide that,
and all four are part of writing the story, not a follow-up.
**Title.** `Pages/<Area>/<Page>`, where `<Area>` is the nav section and `<Page>`
is the label the nav gives it.
- The leaf is the product's label, never the component's name: `MetricsExplorer`
is `Metrics/Explorer`, `MeterExplorer` is `Metering/Cost Meter`,
`AIAssistantPage` is `Noz`.
- Never repeat the area in the leaf: `Alerts/Rules`, not `Alerts/AlertRules`.
- A leaf never shares its name with a sibling folder. The folder wins and the
page becomes `List`, or `Overview` for a tab strip: `Services/List` beside
`Services/Detail`.
- Title Case with spaces. No camelCase, no kebab.
- Four levels is the floor to stay under: `Pages/Alerts/Channels/New` is as deep
as it goes.
- Pages nobody navigates to on purpose go under `Pages/System` (`Status`,
`Unauthorized`, `Workspace Locked`), and the pre-session pages under
`Pages/Auth`.
- A page whose permission stories earn their own folder becomes one:
`Pages/Settings/Billing/Overview` beside `Pages/Settings/Billing/Authz`. See
**Permission stories** below.
**Order.** The `storySort.order` literal in `.storybook/preview.tsx` carries the
order for every level. A new page in an existing area is appended to that area's
array, in the order the product lists it; a new area goes where the side nav
puts it. Storybook parses the order out of the file statically, so it has to
stay an inline literal. Missing entries fall to the end of their level rather
than disappearing, so a forgotten edit is a page at the bottom of its area, not
a broken sidebar.
**Tags.** Declared on the meta, right under `title`, and what the sidebar's tag
filter answers questions with. Only these:
| Tag | When |
| --- | --- |
| `authz` | The page gates UI on permission checks through `lib/authz` (`AuthZButton`, `AuthZGuard`, `useAuthZ`). Both the page's file and its `Authz` file carry it. |
| `role-gated` | The page still branches on the legacy role (`user.role`, `hasEditPermission`) and has no authz check. |
| `beta` | `isBeta` on its nav entry. Drop the tag when the product drops the badge. |
| `legacy` | Superseded by another page but still routed. The doc comment names the page to start from instead. |
| `play` | The story file has a `play` function, so at least one state is reached by an interaction. |
`autodocs` comes from `preview.tsx` and is never written on a meta.
**Doc comment on the meta.** What the page is, in the page's own terms, then a
blank line, then the route:
```tsx
const pageStory = storyMocks(logsExplorerMocks, {
route: explorerRoute('explorer'),
layout: 'app',
});
/**
* The logs explorer: the query builder, the list, the frequency chart and the log
* detail drawer, with quick filters and saved views beside them.
*
* Route: `/logs/logs-explorer`.
*/
const meta = {
title: 'Pages/Logs/Explorer',
tags: ['play'],
component: LogsModulePage,
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<LogsExplorerArgs>;
```
The `pageStory` const and the trailing `parameters` line are what make the doc
comment safe. The comment compiles to a `parameters` property that the csf plugin
appends after the spread, so a meta that spreads `storyMocks(...)` and stops
there loses `parameters.signoz` and renders the page against the global handlers
alone: every one of the page's endpoints misses. Restating `parameters` as a
literal gives the plugin something to merge into. `resolveStory` logs the
combination that says it happened, so the console names it rather than leaving it
to be found by reading the page.
It is the description on the page's Docs page, which is the only place a reader
who is not in the code finds out what the page is for. Two or three sentences:
what it shows, what drives it, and the gating worth knowing about (`Gated on
authz permissions`, `follows the legacy editor role`). A control-driven route
says so instead of a path: ``Route: `/metrics-explorer/*`, the tab control picks
which``.
## Permission stories
A page that gates UI on `lib/authz` keeps its permission states in a folder of
their own, so the page's own file stays about the page and the sidebar answers
"what does this permission do" in one place.
**Layout.** A second story file at `stories/authz/<Page>.authz.stories.tsx`,
titled `Pages/<Area>/<Page>/Authz`, which turns the page into a folder: its own
file is retitled `Pages/<Area>/<Page>/Overview`, and `.storybook/preview.tsx`
gains the sub-order (`'Billing', ['Overview', 'Authz']`). Both files carry the
`authz` tag and share the page's one mocks module, which the authz file imports
as `../<Page>.stories.mocks`. It declares no controls and no mock data of its
own: a permission story that needs a new response is a control the page's mocks
were missing.
**One story per permission the page reads**, named for what is gone: `NoRead`,
`NoList`, `NoUpdate`, `NoCreate`, `NoDelete`. Then the combinations the page
itself distinguishes, and only those: `NoManage` where two permissions gate one
button, `ReadOnly` where everything but reading is denied, `NoSubscriptionAccess`
where none of the resource's permissions are held, and `CheckFailed` for
`authzState: 'error'`, which is the page's fail-open path rather than a denial.
**Revoke, never allow-list.** Each story is a full grant minus what its name
says: `args: { revoked: ['read:subscription'] }`. The `Revoked` control subtracts
from the preset, so the story stays "an admin missing one permission" as the
catalogue grows, and the diff against the page's `Default` is the one permission.
Rebuilding the allow-list by hand drifts the moment a resource is added.
**Never a role preset in this folder.** `access: 'viewer'` moves the legacy role,
the side nav and every other resource's permissions at the same time, so the
story no longer shows what its name claims. A persona is a story on the page's
own file, and only when the product has that persona.
**Pair the revocation with the state that renders the gated control.** A button
that only exists on a trial needs the plan too:
`args: { plan: 'on-trial', revoked: ['create:subscription'] }`. A permission
whose denial changes nothing on screen gets no story: say so in the PR.
Verify these by their disabled states, not their text. The page reads the same
either way, so a story that is wrong looks right: read `disabled` off the buttons
the permission gates, and check the denial callout is there or gone.
## Rules
- **Default is the loaded page.** `export const Default: Story = {}` with no args,
@@ -179,29 +50,7 @@ the permission gates, and check the denial callout is there or gone.
- **File layout**: every story file for a page lives under
`src/pages/<Page>/stories/`: `<Page>.stories.tsx`, `<Page>.stories.mocks.tsx`,
payload builders in `stories/__story_mockdata__/<page>.ts`. Nothing
page-specific in `src/storybook/controls/`. A page that is a tab strip over
several routes gets one story file per tab, in its own folder under the module
page (`LogsModulePage/Pipelines/stories/Pipelines.stories.tsx`), each with its
own mocks and `__story_mockdata__/`; the builders more than one tab needs stay
in the module page's own `stories/__story_mockdata__/`
(`AlertList/stories/__story_mockdata__/alerts.ts`), which a tab reaches as
`../../stories/__story_mockdata__/alerts`. Every one of them renders the module page, so the tab
strip is there, and the `route` its mocks return decides which tab is open.
A page's permission stories go one level further down, in
`stories/authz/<Page>.authz.stories.tsx`, on the page's own mocks: see
**Permission stories**.
- **A state only a click reaches is a story with a `play` function**, not a
control: a drawer, a modal, an edit mode the page holds in component state.
Drive it with `userEvent` and the queries from `storybook/test`, take the first
of a repeated row action, and wait on the state's own text. The page fetches
before it renders a row, so the finder needs a timeout past the 1s default. A
state the app drops again on its own, such as one keyed on an array identity
that a refetch replaces, does not get a story: it would not survive being
looked at. A *sequence* of such states, a wizard's steps or a
questionnaire's pages, is still a control: declare the steps in the mocks
module and walk them from a `play` on the meta that destructures `mount`, which
is what makes Storybook replay it on an arg change. See
[references/controls.md](references/controls.md).
page-specific in `src/storybook/controls/`.
- **The mocks are AI-owned and say so.** `<Page>.stories.mocks.tsx` and every file
under a `__story_mockdata__/` open with this banner, above the imports:
@@ -225,13 +74,6 @@ the permission gates, and check the denial callout is there or gone.
writing a response shape inline, check if a builder exists; if not and the
shape will repeat, add it there. Page-specific builders stay in the page's
`__story_mockdata__/`.
- **The story's own doc comment is per state.** Every `export const` gets one:
what that state shows, not how it is built. It renders in the States list on
the page's Docs page, so `Undocumented.` there is a story nobody described.
- **Story names come from a fixed vocabulary** where one fits: `Default`,
`Viewer`, `Empty`, `Loading`, `Error`. Page-specific states get page-specific
names (`NoIngestion`, `Unlicensed`), never a second spelling of one of those
(`ViewerAccess`, `NonAdmin`).
- **No comment is the default.** Write one only for what the code cannot show:
a shape the backend dictates, an app bug the mock reproduces, an ordering or
cap the page depends on, a workaround and the reason for it. Never restate a
@@ -243,16 +85,6 @@ the permission gates, and check the denial callout is there or gone.
## Done means
- [ ] `Default` shows the page with data, checked in dark and light
- [ ] title follows the sidebar rules, tags declared, and the page's entry added
to the `storySort.order` literal in `.storybook/preview.tsx`
- [ ] the meta carries its doc comment with the `Route:` line, the meta restates
`parameters: { ...pageStory.parameters }` after the spread, and every story
export carries its own doc comment
- [ ] the page's Docs page renders: description, controls table, and one row per
state with no `Undocumented.`
- [ ] a page tagged `authz` has its `Authz` folder: one story per permission it
reads, each reached by `revoked`, none of them a role preset, and each one
checked by the `disabled` state of what the permission gates
- [ ] the mocks module and every `__story_mockdata__` file carry the AI-owned banner
- [ ] every control flipped once, its effect seen on screen
- [ ] console clean: no `[storybook] no msw handler`, no 501, no msw unhandled

View File

@@ -128,83 +128,20 @@ export const servicesMocks = defineStoryMocks({
// src/pages/Services/stories/Services.stories.tsx
type ServicesArgs = PageStoryArgs<typeof servicesMocks>;
const pageStory = storyMocks(servicesMocks, {
route: ROUTES.APPLICATION,
layout: 'app',
});
/**
* Every instrumented service with its p99, error rate and throughput.
*
* Route: `/services`.
*/
const meta = {
title: 'Pages/Services/List',
title: 'Pages/Services',
component: Services,
...pageStory,
parameters: { ...pageStory.parameters },
...storyMocks(servicesMocks, { route: ROUTES.APPLICATION, layout: 'app' }),
} satisfies Meta<ServicesArgs>;
```
`PageStoryArgs` folds in the global controls, so a story's `args` can set
`access`, `dataState` or `banner` next to the page's own knobs and stay typed.
## A step the page keeps in component state
A wizard's step, a questionnaire's page, a picker's next question: the page holds
it in `useState` and nothing in the URL says which one is open. It is still a
control. Declare the steps in the mocks module and drive them from a `play` on
the **meta**, so every story of the page inherits the walk and only sets `args`:
```tsx
// <Page>.stories.mocks.tsx
export const SETUP_STEPS = ['pick-source', 'pick-framework', 'configure'] as const;
export type SetupStep = (typeof SETUP_STEPS)[number];
controls: {
step: choiceControl<SetupStep>('Setup step', { group: SETUP, options: SETUP_STEPS, value: 'pick-source' }),
},
```
```tsx
// <Page>.stories.tsx
const meta = {
play: async ({ mount, args, canvasElement }): Promise<void> => {
await mount();
await advanceToSetupStep(canvasElement, args.step);
},
...storyMocks(pageMocks),
} satisfies Meta<PageArgs>;
export const Configure: Story = { args: { step: 'configure' } };
```
**Destructuring `mount` is what makes it a control.** Storybook re-runs a play
function on an arg change only for a story whose play asks to be remounted
(`usesMount`); otherwise it re-renders the tree the previous walk left behind and
the panel looks broken. With `mount` destructured, the story renders when `play`
calls it, and every arg change replays the walk from a fresh mount.
The walk itself:
- one `answer` function per step, in an array indexed the same as the step list,
so reaching step *n* is `answers.slice(0, STEPS.indexOf(step))`;
- answer each step with the least its Next button accepts, and prefer a "do this
later" over filling a slider;
- run them sequentially (`reduce` over a promise), since each answer is what
renders the step the next one reads;
- bail out when the page did not start where the walk expects, such as a source
deep-linked past the questions. Check for the first step's own text rather than
reading another control's value.
An endpoint that only settles the transition between two steps (the profile a
questionnaire saves before its last page) takes a plain resolver, or the Data
control on `loading` strands the walk halfway.
## Not a control
- Anything the global controls already cover: banner, side nav, data state,
access preset, granted permissions, revoked permissions, check state.
access preset, permissions, check state.
- A knob whose effect nobody can see on the page. Delete it or find the widget it
was supposed to drive.
- A raw payload as an object control. Controls carry intent (`5 dashboards`,
@@ -218,12 +155,9 @@ control on `loading` strands the walk halfway.
Default to a control. Write a story when the state is worth a link:
- the fresh workspace, because that is what a new user sees
- the restricted user, when permissions visibly change the page
- a page-defining mode (a tab, a category) that has its own layout
A permission that visibly changes the page is a story too, but it goes in the
page's `Authz` folder, one per permission, turned with the `Revoked` control.
See **Permission stories** in SKILL.md.
Combinations of controls do not need stories, which is what the panel is for.
Each story gets one prose doc comment: what it shows, in the page's own terms.

View File

@@ -12,12 +12,11 @@ cd frontend && pnpm storybook --ci --quiet # :6006, background it
A newly added `.stories.tsx` takes a few seconds to appear in `index.json` on an
already-running server; an empty first poll is not a broken `stories` glob.
Story ids come from the meta title: `Pages/Services/List`
`pages-services-list`, plus the story export in kebab-case. Render one story on
its own:
Story ids come from the meta title: `Pages/Services` `pages-services`, plus the
story export in kebab-case. Render one story on its own:
```
http://localhost:6006/iframe.html?id=pages-services-list--default&viewMode=story
http://localhost:6006/iframe.html?id=pages-services--default&viewMode=story
```
## Flip controls from the URL

View File

@@ -295,8 +295,6 @@
// Prevents bracket access on CSS modules (styles['kebab-case']) which fails with camelCaseOnly config
"signoz/no-dashboard-fetch-outside-root": "error",
// Forces useDashboardFetchRequired() outside the root V2 pages (allowlisted in overrides below)
"signoz/no-msw-in-story-file": "error",
// Bans msw imports in *.stories.tsx; handlers/mock data belong in the sibling .stories.mocks.tsx
"no-restricted-globals": [
"error",
{

View File

@@ -27,22 +27,12 @@ const mockAliases = [
find: /^(?:src\/)?api\/common\/logEvent$/,
replacement: `${srcPath}/storybook/mocks/logEvent.mock.ts`,
},
{
// jest: not replaced, the suite mounts a mock store per test.
find: /^(?:src\/)?store$/,
replacement: `${srcPath}/storybook/mocks/store.mock.ts`,
},
{
// jest: __mocks__/env.ts, which leaves `baseURL` empty because jsdom already
// resolves a relative `/api/...` against `http://localhost`.
find: /^(?:src\/)?constants\/env$/,
replacement: `${srcPath}/storybook/mocks/env.mock.ts`,
},
{
// jest: not replaced, a test opens the one tooltip it is about.
find: /^@signozhq\/ui\/tooltip$/,
replacement: `${srcPath}/storybook/mocks/tooltip.mock.tsx`,
},
];
/**
@@ -65,12 +55,12 @@ const isExcluded = (plugin: PluginOption): boolean =>
const config: StorybookConfig = {
framework: '@storybook/react-vite',
stories: ['../src/storybook/docs/**/*.mdx', '../src/**/*.stories.@(ts|tsx)'],
stories: ['../src/**/*.stories.@(ts|tsx)'],
// `../public` carries the fonts, icons and i18n bundles the app expects at
// the root; `./public` carries the msw worker, which must not ship in a
// production build.
staticDirs: ['../public', './public'],
addons: ['@storybook/addon-a11y', '@storybook/addon-docs'],
addons: ['@storybook/addon-a11y'],
core: { disableTelemetry: true },
viteFinal: async (viteConfig) => {
const plugins = (viteConfig.plugins ?? [])
@@ -87,14 +77,6 @@ const config: StorybookConfig = {
return {
...viteConfig,
build: {
...viteConfig.build,
// `vite.config.ts` sets this for the app; Storybook's builder replaces
// `build` wholesale, which leaves rolldown-vite on its default
// lightningcss. That one rejects `:global()` in a plain stylesheet, which
// the app has, and the static build dies in CSS minification.
cssMinify: 'esbuild',
},
plugins,
resolve: {
...viteConfig.resolve,

View File

@@ -6,17 +6,6 @@
-->
<link rel="stylesheet" href="storybook-fonts.css" />
<!--
Third-party frames are the one thing msw cannot answer: a cross-origin iframe
navigates outside the service worker's scope, so the YouTube embeds and the
docs pane in onboarding reach the real network. Same intent as the boot data
below, enforced by the browser instead.
-->
<meta
http-equiv="Content-Security-Policy"
content="frame-src 'self' blob: data:"
/>
<link rel="stylesheet" href="css/uPlot.min.css" />
<script>
@@ -35,38 +24,3 @@
},
};
</script>
<script>
// The wall clock every story reads. Chart windows, `4 mins ago` labels and
// trial countdowns all derive from `now`, and Chromatic does not freeze the
// clock, so a live one redraws every chart axis between two builds of the
// same code. `performance.now` and the timers keep running, so anything
// waiting on a timeout still resolves. `?storyClock=live`, or an ISO
// instant, overrides it.
//
// `new Date()` is the frozen instant, which is what the app renders from.
// `Date.now()` runs on from it instead, because it is also what code measures
// elapsed time with: `lodash.debounce` compares two `Date.now()` readings to
// decide its trailing call is due, so a frozen one re-arms its timer forever
// and every debounced input in the app (the onboarding catalogue search, the
// pipelines search, the log filter) silently stops filtering.
(() => {
const asked = new URLSearchParams(window.location.search).get('storyClock');
if (asked === 'live') return;
const frozen = Date.parse(asked || '2026-06-15T12:00:00.000Z');
if (Number.isNaN(frozen)) return;
const RealDate = Date;
const started = performance.now();
class FrozenDate extends RealDate {
constructor(...args) {
super(...(args.length ? args : [frozen]));
}
static now() {
return frozen + (performance.now() - started);
}
}
Object.defineProperty(window, 'Date', { value: FrozenDate, writable: true });
})();
</script>

View File

@@ -3,8 +3,6 @@ import type { SetupWorker } from 'msw';
import { setupWorker } from 'msw';
import { settleForCapture } from '../src/storybook/visual/settleForCapture';
import PageDocs from '../src/storybook/docs/PageDocs';
import ThemedDocsContainer from '../src/storybook/docs/ThemedDocsContainer';
import { withProviders } from '../src/storybook/decorators/withProviders';
import { globalMocks } from '../src/storybook/globals';
import { resetStoryHistory } from '../src/storybook/navigation/containment';
@@ -15,12 +13,7 @@ import {
} from '../src/storybook/runtime/resolveStory';
import { allModes } from './modes';
import i18n from '../src/ReactI18';
// `src/index.tsx` does this at boot: without it `@monaco-editor/react` falls back
// to its loader default and pulls Monaco from cdn.jsdelivr.net, which msw does
// not report because the requests look like static assets.
import '../src/lib/monaco/setup';
import '../src/ReactI18';
import '../src/styles.scss';
@@ -70,127 +63,10 @@ const { worker, ready } = (holder.__signozStorybookWorker ??=
};
})());
/**
* `t()` answers with the key until the namespace's JSON has landed, and a `play`
* that clicks as soon as the story renders is quick enough to catch it: the
* channel form's "Channel name is mandatory" arrives as `channel_name_required`.
* Every namespace under `public/locales/en` is loaded once, ahead of the first
* story.
*/
const translationsReady = i18n.loadNamespaces(
Object.keys(import.meta.glob('../public/locales/en/*.json')).map((path) =>
path.slice(path.lastIndexOf('/') + 1, -'.json'.length),
),
);
const preview: Preview = {
parameters: {
layout: 'fullscreen',
controls: { expanded: true },
// The sidebar order, mirroring the app's own side nav
// (`container/SideNav/menuItems.tsx`), so a page sits where someone would
// click it in the product. Storybook's default is the order the story files
// happen to be globbed in, which puts `src/modules` first. Anything missing
// from a level lands after the entries listed for it, in file order, so a new
// story shows up at the end of its area rather than disappearing. Stories
// inside a file are never listed, so they keep the order they are declared
// in, `Default` first. Storybook parses this out of the file, so it has to
// stay an inline literal.
options: {
storySort: {
order: [
'Docs',
'Pages',
[
'Home',
'Alerts',
[
'Rules',
'Triggered',
'Overview',
'History',
'Create',
'Edit',
'Planned Downtime',
'Routing Policies',
'Channels',
['List', 'New', 'Edit'],
],
'Dashboards',
['List', 'Detail', 'Panel Editor', 'Public'],
'Services',
['List', 'Detail', 'Top Level Operations', 'Service Map'],
'Logs',
['Explorer', 'Live Tail', 'Saved Views', 'Pipelines', 'Settings'],
'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
@@ -198,9 +74,6 @@ const preview: Preview = {
// one it is given.
chromatic: { modes: allModes },
},
// Every page story gets a docs page: the descriptions on the meta and on each
// story are the page's documentation, and without this they render nowhere.
tags: ['autodocs'],
globalTypes: {
theme: {
description: 'SigNoz color scheme',
@@ -246,21 +119,12 @@ const preview: Preview = {
world.apply();
world.install(worker);
await Promise.all([ready, translationsReady]);
await ready;
},
],
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,

View File

@@ -88,16 +88,10 @@ self.addEventListener('fetch', function (event) {
const { request } = event
const accept = request.headers.get('accept') || ''
// msw bypasses server-sent events here, because it answers a request in one
// piece and has no stream to hand back. A story is not a live connection
// either: it wants the backlog a page renders, and one response carries that
// fine. Left bypassed, `/api/v3/logs/livetail` reaches the real network and
// the live tail story is a spinner over ERR_CONNECTION_REFUSED. Restore the
// bypass and re-check `Pages/Logs/Live Tail` if msw regenerates this file.
//
// if (accept.includes('text/event-stream')) {
// return
// }
// Bypass server-sent events.
if (accept.includes('text/event-stream')) {
return
}
// Bypass navigation requests.
if (request.mode === 'navigate') {

View File

@@ -25,23 +25,7 @@ const IGNORED_MESSAGES = [
/violates the following Content Security Policy directive/,
];
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);
const messagesByPage = new WeakMap<Page, string[]>();
/**
* Only `console.error` fails a story. `console.warn` is dev-time advice from
@@ -59,14 +43,14 @@ const config: TestRunnerConfig = {
return;
}
const messages: CapturedMessage[] = [];
const messages: string[] = [];
messagesByPage.set(page, messages);
page.on('console', (message) => {
if (
message.type() === 'error' &&
!IGNORED_MESSAGES.some((pattern) => pattern.test(message.text()))
) {
messages.push({ at: Date.now(), text: `[error] ${message.text()}` });
messages.push(`[error] ${message.text()}`);
}
});
// The console message alone ("Failed to load resource") doesn't name the
@@ -74,23 +58,12 @@ const config: TestRunnerConfig = {
// actionable instead of just a status code.
page.on('response', (response) => {
if (response.status() >= 400) {
messages.push({
at: Date.now(),
text: `[response] ${response.status()} ${response.url()}`,
});
messages.push(`[response] ${response.status()} ${response.url()}`);
}
});
},
async postVisit(page, context): Promise<void> {
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);
const messages = messagesByPage.get(page) ?? [];
if (messages.length === 0) {
return;
}

View File

@@ -162,7 +162,6 @@
"@jest/globals": "30.4.1",
"@jest/types": "30.2.0",
"@storybook/addon-a11y": "10.5.9",
"@storybook/addon-docs": "10.5.9",
"@storybook/react-vite": "10.5.9",
"@storybook/test-runner": "0.24.5",
"@testing-library/dom": "8.20.0",

View File

@@ -1,41 +0,0 @@
/**
* Rule: no-msw-in-story-file
*
* A `.stories.tsx` file is the human-facing surface: it must not carry msw
* handlers or response payloads. Those belong in the sibling
* `<Page>.stories.mocks.tsx` module (and its `__story_mockdata__` builders).
*
* This rule flags any import from `msw` inside a `*.stories.tsx` file. It
* does not match `*.stories.mocks.tsx`, which is where msw imports belong.
*/
export default {
meta: {
type: 'suggestion',
docs: {
description:
'Disallow importing from msw inside a .stories.tsx file; move handlers/mock data to the sibling .stories.mocks.tsx module',
category: 'Storybook',
},
schema: [],
messages: {
noMsw:
'Do not import from msw in a .stories.tsx file. Move the handler and its mock data to the sibling <Page>.stories.mocks.tsx module (and __story_mockdata__ for builders).',
},
},
create(context) {
const filename = context.filename || '';
if (!filename.endsWith('.stories.tsx')) {
return {};
}
return {
ImportDeclaration(node) {
if (node.source.value === 'msw') {
context.report({ node, messageId: 'noMsw' });
}
},
};
},
};

View File

@@ -15,7 +15,6 @@ import noCssModuleBracketAccess from './rules/no-css-module-bracket-access.mjs';
import noDashboardFetchOutsideRoot from './rules/no-dashboard-fetch-outside-root.mjs';
import noConditionalTextNodesWithSiblings from './rules/no-conditional-text-nodes-with-siblings.mjs';
import noReturnTextNodes from './rules/no-return-text-nodes.mjs';
import noMswInStoryFile from './rules/no-msw-in-story-file.mjs';
export default {
meta: {
@@ -32,6 +31,5 @@ export default {
'no-dashboard-fetch-outside-root': noDashboardFetchOutsideRoot,
'no-conditional-text-nodes-with-siblings': noConditionalTextNodesWithSiblings,
'no-return-text-nodes': noReturnTextNodes,
'no-msw-in-story-file': noMswInStoryFile,
},
};

View File

@@ -363,9 +363,6 @@ importers:
'@storybook/addon-a11y':
specifier: 10.5.9
version: 10.5.9(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
'@storybook/addon-docs':
specifier: 10.5.9
version: 10.5.9(@types/react-dom@18.0.10)(@types/react@18.0.26)(esbuild@0.28.1)(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.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)
@@ -2187,12 +2184,6 @@ packages:
'@marijn/find-cluster-break@1.0.2':
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
'@mdx-js/react@3.1.1':
resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==}
peerDependencies:
'@types/react': '>=16'
react: '>=16'
'@monaco-editor/loader@1.7.0':
resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==}
@@ -3775,15 +3766,6 @@ packages:
peerDependencies:
storybook: ^10.5.9
'@storybook/addon-docs@10.5.9':
resolution: {integrity: sha512-8sFsMkZYrrdqCLdV+hnwTwDF7RaBsBPRwl4wfc8ve9Q/7Yhi5REe/Xjvd8x1yn6fBPPw9tnID9dx6Agdnr81fw==}
peerDependencies:
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
storybook: ^10.5.9
peerDependenciesMeta:
'@types/react':
optional: true
'@storybook/builder-vite@10.5.9':
resolution: {integrity: sha512-Zg4JbGQiHFPGlFJ9HM+XPgzKmU/RFPCymhohVRJhBBYfmgaQgz0flWWzscseCDpl638MNd8/r/H+nwuoBgSYDg==}
peerDependencies:
@@ -4207,9 +4189,6 @@ packages:
'@types/mdast@4.0.3':
resolution: {integrity: sha512-LsjtqsyF+d2/yFOYaN22dHZI1Cpwkrj+g06G8+qtUKlhovPW89YhqSnfKtMbkgmEtYpH2gydRNULd6y8mciAFg==}
'@types/mdx@2.0.14':
resolution: {integrity: sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg==}
'@types/ms@0.7.31':
resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==}
@@ -12220,12 +12199,6 @@ snapshots:
'@marijn/find-cluster-break@1.0.2': {}
'@mdx-js/react@3.1.1(@types/react@18.0.26)(react@18.2.0)':
dependencies:
'@types/mdx': 2.0.14
'@types/react': 18.0.26
react: 18.2.0
'@monaco-editor/loader@1.7.0':
dependencies:
state-local: 1.0.7
@@ -13646,25 +13619,6 @@ snapshots:
axe-core: 4.13.0
storybook: 10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0)
'@storybook/addon-docs@10.5.9(@types/react-dom@18.0.10)(@types/react@18.0.26)(esbuild@0.28.1)(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.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))
@@ -14110,8 +14064,6 @@ snapshots:
dependencies:
'@types/unist': 3.0.2
'@types/mdx@2.0.14': {}
'@types/ms@0.7.31': {}
'@types/node@16.18.25': {}

View File

@@ -61,6 +61,7 @@
"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"
}

View File

@@ -42,7 +42,6 @@
"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",
@@ -86,6 +85,7 @@
"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"
}

View File

@@ -28,4 +28,4 @@ until curl -sf http://127.0.0.1:6006/index.json >/dev/null 2>&1; do
sleep 1
done
pnpm exec test-storybook --ci --maxWorkers=2 --testTimeout 30000 "$@"
pnpm exec test-storybook --ci --maxWorkers=2 "$@"

View File

@@ -1597,10 +1597,6 @@ 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,

View File

@@ -154,14 +154,6 @@ 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'),
);

View File

@@ -26,13 +26,11 @@ import {
LiveLogs,
Login,
Logs,
LogsExplorer,
LogsIndexToFields,
LogsSaveViews,
MessagingQueuesMainPage,
MeterExplorerPage,
MetricsExplorer,
OldLogsExplorer,
OnboardingV2,
OrgOnboarding,
PasswordReset,
@@ -284,20 +282,6 @@ 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,

View File

@@ -1,23 +0,0 @@
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;

View File

@@ -1,26 +0,0 @@
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;

View File

@@ -1,26 +0,0 @@
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;

View File

@@ -1,24 +0,0 @@
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;

View File

@@ -1,23 +0,0 @@
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;

View File

@@ -1,22 +0,0 @@
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,
},
);

View File

@@ -1,69 +0,0 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import dayjs from 'dayjs';
import { screen, userEvent } from 'storybook/test';
import { withCanvas } from '@/storybook/decorators/withCanvas';
import CustomTimePicker from '../CustomTimePicker';
const minTime = dayjs('2025-01-15T11:00:00Z').valueOf() * 1_000_000;
const maxTime = dayjs('2025-01-15T12:00:00Z').valueOf() * 1_000_000;
function TimePickerFixture(): JSX.Element {
const [open, setOpen] = useState(false);
const [selectedTime, setSelectedTime] = useState('1h');
return (
<CustomTimePicker
isModalTimeSelection
items={[
{ label: 'Last 15 minutes', value: '15m' },
{ label: 'Last 1 hour', value: '1h' },
{ label: 'Last 6 hours', value: '6h' },
{ label: 'Custom', value: 'custom' },
]}
maxTime={maxTime}
minTime={minTime}
newPopover
open={open}
onCustomDateHandler={(): void => undefined}
onError={(): void => undefined}
onSelect={(value): void => setSelectedTime(value)}
onValidCustomDateChange={(): void => undefined}
selectedTime={selectedTime}
selectedValue="15 Jan 2025 11:00:00 - 15 Jan 2025 12:00:00"
setOpen={setOpen}
/>
);
}
const meta = {
title: 'Components/Custom Time Picker',
component: TimePickerFixture,
tags: ['play'],
decorators: [withCanvas({ maxWidth: 400 })],
} satisfies Meta<typeof TimePickerFixture>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Interaction: the time-range menu is open with its relative-range choices. */
export const TimeRangeMenuOpen: Story = {
play: async (): Promise<void> => {
await userEvent.click(await screen.findByRole('textbox'));
await screen.findByText('RELATIVE TIMES');
},
};
/** Interaction: the timezone menu is reached through the real time-range footer. */
export const TimezoneMenuOpen: Story = {
play: async (): Promise<void> => {
await userEvent.click(await screen.findByRole('textbox'));
await userEvent.click(
await screen.findByRole('button', { name: 'Change Timezone' }),
);
await screen.findByPlaceholderText('Search timezones...');
},
};

View File

@@ -1,28 +0,0 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { rest } from 'msw';
import { fieldKeysResponse } from '@/storybook/msw/__story_mockdata__/fields';
export const fieldSuggestionsHandlers = [
rest.get('http://localhost/api/v1/fields/keys', (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json(
fieldKeysResponse(['service.name', 'body'], {
signal: TelemetrytypesSignalDTO.logs,
}),
),
),
),
];
export const noFieldSuggestionsHandlers = [
rest.get('http://localhost/api/v1/fields/keys', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(fieldKeysResponse([]))),
),
];

View File

@@ -1,102 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { screen, userEvent } from 'storybook/test';
import { DataSource } from 'types/common/queryBuilder';
import FieldsSelector from '../FieldsSelector';
import {
fieldSuggestionsHandlers,
noFieldSuggestionsHandlers,
} from './FieldsSelector.stories.mocks';
const meta = {
title: 'Components/Fields Selector',
component: FieldsSelector,
tags: ['play'],
args: {
allowCustomFields: true,
defaultPosition: { x: 40, y: 40 },
fields: [
{
fieldContext: 'log',
fieldDataType: 'string',
name: 'timestamp',
signal: 'logs',
},
],
height: 560,
isOpen: true,
onClose: (): void => undefined,
onFieldsChange: (): void => undefined,
signal: DataSource.LOGS,
title: 'Edit log columns',
width: 420,
},
parameters: {
msw: {
handlers: fieldSuggestionsHandlers,
},
},
} satisfies Meta<typeof FieldsSelector>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Open: the draggable field editor shows its selected and available columns. */
export const Open: Story = {};
/** Mutation: adding a suggested field exposes the real unsaved-change footer. */
export const UnsavedChanges: Story = {
play: async (): Promise<void> => {
// One Add per suggested field, so the first row's is the one clicked.
const [addField] = await screen.findAllByRole('button', { name: 'Add' });
await userEvent.click(addField);
await screen.findByRole('button', { name: 'Save changes' });
},
};
/** Empty: the suggestion request succeeds with no columns to add. */
export const NoResults: Story = {
parameters: {
msw: {
handlers: noFieldSuggestionsHandlers,
},
},
};
/** Limit: available columns cannot be added once the configured maximum is reached. */
export const MaximumFields: Story = {
args: {
fields: [
{
fieldContext: 'log',
fieldDataType: 'string',
name: 'timestamp',
signal: 'logs',
},
{
fieldContext: 'log',
fieldDataType: 'string',
name: 'severity_text',
signal: 'logs',
},
],
maxFields: 2,
},
};
/** Required: mandatory fields remain present without removal controls. */
export const RequiredFields: Story = {
args: {
fields: [
{
fieldContext: 'resource',
fieldDataType: 'string',
name: 'service.name',
signal: 'logs',
},
],
requiredFields: ['resource:service.name:string'],
},
};

View File

@@ -1,12 +0,0 @@
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;

View File

@@ -1,6 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
import styled from 'styled-components';
export const CategoryHeadingText = styled(Typography.Text)`
font-size: 0.8rem;
`;

View File

@@ -1,33 +0,0 @@
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,
};

View File

@@ -1,24 +0,0 @@
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;

View File

@@ -1,32 +0,0 @@
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;`}
`;

View File

@@ -1,40 +1,5 @@
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';
import { TableColumnType as ColumnType } from 'antd';
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;
};

View File

@@ -1,59 +0,0 @@
.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;
}
}

View File

@@ -1,180 +0,0 @@
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 };
};

View File

@@ -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/LogsTable';
import { LogViewMode } from 'container/OptionsMenu/types';
import { FontSize, OptionsMenuConfig } from 'container/OptionsMenu/types';
import {
Check,

View File

@@ -1,156 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { screen, userEvent } from 'storybook/test';
import { withCanvas } from '@/storybook/decorators/withCanvas';
import type { GlobalMockArgs } from '@/storybook/globals';
import { CustomMultiSelect, CustomSelect } from '../index';
const options = [
{ label: 'Checkout', value: 'checkout' },
{ label: 'Frontend', value: 'frontend' },
{ label: 'Payments', value: 'payments' },
{ label: 'Search', value: 'search' },
];
const longOptions = Array.from({ length: 24 }, (_, index) => ({
label: `Service ${String(index + 1).padStart(2, '0')}`,
value: `service-${index + 1}`,
}));
const meta = {
title: 'Components/New Select',
component: CustomSelect,
tags: ['play'],
decorators: [withCanvas({ maxWidth: 360 })],
args: {
'aria-label': 'Service',
options,
placeholder: 'Select a service',
},
} satisfies Meta<typeof CustomSelect>;
export default meta;
type Story = StoryObj<typeof meta>;
type TooltipsStory = StoryObj<GlobalMockArgs>;
/** Interaction: the body-portal menu is open for stacking and clipping review. */
export const PortalOpen: Story = {
play: async (): Promise<void> => {
await userEvent.click(
await screen.findByRole('combobox', { name: 'Service' }),
);
await screen.findByRole('listbox');
},
};
/** Density: a long result list keeps the menu scrollable. */
export const LongResults: Story = {
args: { options: longOptions },
play: async (): Promise<void> => {
await userEvent.click(
await screen.findByRole('combobox', { name: 'Service' }),
);
await screen.findByText('Service 24');
},
};
/** Empty: the select reports its supported no-data state. */
export const NoResults: Story = {
args: { noDataMessage: 'No services found', options: [] },
play: async (): Promise<void> => {
await userEvent.click(
await screen.findByRole('combobox', { name: 'Service' }),
);
await screen.findByText('No services found');
},
};
/** Loading: the open menu keeps its in-progress refresh feedback visible. */
export const Loading: Story = {
args: {
loading: true,
options: [],
},
play: async (): Promise<void> => {
await userEvent.click(
await screen.findByRole('combobox', { name: 'Service' }),
);
await screen.findByText('Refreshing values...');
},
};
/** Error: a retryable failed request remains visible in the open menu. */
export const Error: Story = {
args: {
errorMessage: 'Could not load services',
onRetry: (): void => undefined,
options: [],
},
play: async (): Promise<void> => {
await userEvent.click(
await screen.findByRole('combobox', { name: 'Service' }),
);
await screen.findByText('Could not load services');
},
};
/** Selection: selected and unavailable options are distinguishable before choosing. */
export const SelectedDisabled: Story = {
args: {
options: [
{ label: 'Checkout', value: 'checkout' },
{ disabled: true, label: 'Legacy billing', value: 'legacy-billing' },
{ label: 'Payments', value: 'payments' },
],
value: 'checkout',
},
play: async (): Promise<void> => {
await userEvent.click(
await screen.findByRole('combobox', { name: 'Service' }),
);
await screen.findByRole('option', { name: 'Legacy billing' });
},
};
/** Overflow: a multi-select preserves its selected values when its trigger is constrained. */
export const MultiValueOverflow: Story = {
render: (): JSX.Element => (
<div style={{ maxWidth: 280 }}>
<CustomMultiSelect
aria-label="Services"
maxTagCount={2}
options={longOptions}
value={['service-1', 'service-2', 'service-3', 'service-4']}
/>
</div>
),
};
const LONG_LABEL_OPTION = {
label:
'checkout-service.production-eu-central-1.svc.cluster.local:8080/v1/orders/{orderId}/payment-authorisation',
value: 'checkout-payment-authorisation',
};
/**
* Every tooltip the select renders, held open: the selected chip revealing the
* option label it was cut from. Nothing bounds that label, so the chip is given
* one long enough to need the reveal.
*/
export const Tooltips: TooltipsStory = {
args: { tooltipsOpen: true },
render: (): JSX.Element => (
<div style={{ maxWidth: 280 }}>
<CustomMultiSelect
aria-label="Services"
maxTagCount={1}
maxTagTextLength={14}
options={[LONG_LABEL_OPTION, ...options]}
value={[LONG_LABEL_OPTION.value]}
/>
</div>
),
};

View File

@@ -1,15 +0,0 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
/**
* The catch-all has no route of its own: it answers for whatever pathname the
* `Switch` ran out of routes for, and it calls nothing.
*/
export const notFoundMocks = defineStoryMocks({
controls: {},
config: () => ({ route: '/no-such-page' }),
});

View File

@@ -1,42 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import NotFound from '../index';
import { notFoundMocks } from './NotFound.stories.mocks';
type NotFoundArgs = PageStoryArgs<typeof notFoundMocks>;
/**
* The catch-all route mounts it with no props, and its `defaultProps` is what
* keeps the component itself from typing as one that takes the story's args.
*/
function CatchAllPage(): JSX.Element {
return <NotFound />;
}
const pageStory = storyMocks(notFoundMocks, { layout: 'app' });
/**
* The shell around a pathname no route matched: the side nav stays, the content
* area carries the 404.
*
* Route: any unmatched path.
*/
const meta = {
title: 'Pages/System/Not Found',
component: CatchAllPage,
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<NotFoundArgs>;
export default meta;
type Story = StoryObj<NotFoundArgs>;
/**
* What the app shows for a pathname no route matched, inside the shell: the
* side nav is still there, and the way back is the home button.
*/
export const Default: Story = {};

View File

@@ -1,126 +0,0 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import {
QuickfiltertypesSourceDTO,
TelemetrytypesFieldContextDTO,
TelemetrytypesFieldDataTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { rest, type RequestHandler } from 'msw';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { attributeValuesResponse } from '@/storybook/msw/__story_mockdata__/attributes';
import { fieldKeysResponse } from '@/storybook/msw/__story_mockdata__/fields';
import { quickFiltersResponse } from '@/storybook/msw/__story_mockdata__/quickFilters';
import { FiltersType } from '../types';
const customFilters = [
{
name: 'service.name',
fieldDataType: TelemetrytypesFieldDataTypeDTO.string,
fieldContext: TelemetrytypesFieldContextDTO.resource,
},
{
name: 'deployment.environment',
fieldDataType: TelemetrytypesFieldDataTypeDTO.string,
fieldContext: TelemetrytypesFieldContextDTO.resource,
},
];
export const queryBuilder = {
currentQuery: {
builder: {
queryData: [
{
filter: { expression: '' },
filters: { items: [], op: 'AND' },
queryName: 'Logs query',
},
],
},
},
lastUsedQuery: 0,
panelType: 'graph',
redirectWithQueryBuilderData: (): void => undefined,
setLastUsedQuery: (): void => undefined,
};
export const checkboxConfig = [
{
attributeKey: {
dataType: DataTypes.String,
key: 'service.name',
type: 'resource',
},
defaultOpen: true,
title: 'Service name',
type: FiltersType.CHECKBOX,
},
];
export const attributeValuesHandler = (
values: readonly string[],
): RequestHandler =>
rest.get(
'http://localhost/api/v3/autocomplete/attribute_values',
(_req, res, ctx) =>
res(ctx.status(200), ctx.json(attributeValuesResponse(values))),
);
export const handlers = [
rest.get('http://localhost/api/v2/quick_filters/logs', (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json(
quickFiltersResponse(QuickfiltertypesSourceDTO.logs, customFilters),
),
),
),
rest.get('http://localhost/api/v1/fields/keys', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(fieldKeysResponse(['k8s.namespace.name']))),
),
attributeValuesHandler(['checkout', 'frontend', 'payments']),
];
export const loadingFiltersHandlers = [
rest.get('http://localhost/api/v2/quick_filters/logs', (_req, res, ctx) =>
res(ctx.delay('infinite')),
),
];
export const LONG_FILTER_VALUES = [
'checkout-service.production-eu-central-1.svc.cluster.local',
'payments-authorisation-worker.production-us-east-2.svc.cluster.local',
'catalog-availability-projector.staging-ap-south-1.svc.cluster.local',
];
export const selectedServiceQueryBuilder = {
...queryBuilder,
currentQuery: {
builder: {
queryData: [
{
filter: { expression: '' },
filters: {
items: [
{
key: {
dataType: DataTypes.String,
key: 'service.name',
type: 'resource',
},
op: 'in',
value: ['checkout', 'payments'],
},
],
op: 'AND',
},
queryName: 'Logs query',
},
],
},
},
};

View File

@@ -1,119 +0,0 @@
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] },
},
};

View File

@@ -1,64 +0,0 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Button } from '@signozhq/ui/button';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { withCanvas } from '@/storybook/decorators/withCanvas';
import { CustomSelect } from '../../NewSelect';
import SignozModal from '../SignozModal';
function ModalFixture(): JSX.Element {
const [open, setOpen] = useState(false);
return (
<>
<Button data-testid="open-signoz-modal" onClick={(): void => setOpen(true)}>
Open modal
</Button>
<SignozModal
footer={null}
open={open}
onCancel={(): void => setOpen(false)}
title="Create saved view"
>
<CustomSelect
aria-label="View scope"
options={[
{ label: 'This workspace', value: 'workspace' },
{ label: 'My views', value: 'personal' },
]}
placeholder="Select a scope"
/>
</SignozModal>
</>
);
}
const meta = {
title: 'Components/Signoz Modal',
component: ModalFixture,
tags: ['play'],
decorators: [withCanvas({ maxWidth: 400 })],
} satisfies Meta<typeof ModalFixture>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Interaction: the modal and its nested body-portal select are both genuinely open. */
export const OpenWithNestedSelect: Story = {
play: async ({ canvasElement }): Promise<void> => {
const trigger = within(canvasElement).getByTestId('open-signoz-modal');
await userEvent.click(trigger);
await screen.findByRole('dialog', { name: 'Create saved view' });
await userEvent.keyboard('{Escape}');
await waitFor(() => expect(trigger).toHaveFocus());
await userEvent.click(trigger);
await userEvent.click(
await screen.findByRole('combobox', { name: 'View scope' }),
);
await screen.findByRole('listbox');
},
};

View File

@@ -71,7 +71,7 @@ interface ITableConfig {
instance: Virtualizer<HTMLDivElement, Element>,
) => void;
}
export interface ITableV3Props<T> {
interface ITableV3Props<T> {
columns: ColumnDef<T, any>[];
data: T[];
config: ITableConfig;
@@ -201,5 +201,5 @@ export function TableV3<T>(props: ITableV3Props<T>): JSX.Element {
TableV3.defaultProps = {
customClassName: '',
virtualiserRef: undefined,
virtualiserRef: null,
};

View File

@@ -1,54 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import type { ColumnDef } from '@tanstack/react-table';
import { withCanvas } from '@/storybook/decorators/withCanvas';
import type { ITableV3Props } from '../TableV3';
import { TableV3 } from '../TableV3';
type TraceRow = {
id: string;
traceId: string;
service: string;
duration: string;
status: string;
};
const columns: ColumnDef<TraceRow>[] = [
{ accessorKey: 'traceId', header: 'Trace ID', size: 280 },
{ accessorKey: 'service', header: 'Service', size: 220 },
{ accessorKey: 'duration', header: 'Duration', size: 140 },
{ accessorKey: 'status', header: 'Status', size: 160 },
];
const rows: TraceRow[] = Array.from({ length: 40 }, (_, index) => ({
id: `trace-${index + 1}`,
traceId: `c0ffee${String(index + 1).padStart(10, '0')}7f4a9d1c`,
service: index % 2 === 0 ? 'checkout-service' : 'catalog-service',
duration: `${80 + index * 6} ms`,
status: index % 5 === 0 ? 'Error' : 'OK',
}));
const meta = {
title: 'Components/Table V3',
component: TableV3,
decorators: [withCanvas({ height: 360, maxWidth: 640, overflow: 'auto' })],
args: {
columns,
config: { defaultColumnMinSize: 120, defaultColumnMaxSize: 400 },
data: rows,
setColumnWidths: (): void => undefined,
},
} satisfies Meta<ITableV3Props<TraceRow>>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Density and overflow: the virtualized table's wide, resizable column layout. */
export const WideVirtualizedDataset: Story = {};
/** Data: the table's native no-row layout, with headers retained for structural review. */
export const EmptyDataset: Story = {
args: { data: [] },
};

View File

@@ -1,239 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Color } from '@signozhq/design-tokens';
import { Ellipsis } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import { GroupedStatusCounts } from 'container/InfraMonitoringK8sV2/components/GroupedStatusCounts';
import type { StatusCountItem } from 'container/InfraMonitoringK8sV2/components/GroupedStatusCounts';
import { ValidateColumnValueWrapper } from 'container/InfraMonitoringK8sV2/components/ValidateColumnValueWrapper';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { expect, screen, userEvent, within } from 'storybook/test';
import { withCanvas } from '@/storybook/decorators/withCanvas';
import type { GlobalMockArgs } from '@/storybook/globals';
import TanStackTable from '../index';
import type { TableColumnDef, TanStackTableProps } from '../types';
type ServiceRow = {
id: string;
service: string;
endpoint: string;
latency: string;
owner: string;
};
const rows: ServiceRow[] = [
{
id: 'checkout',
service: 'checkout-service',
endpoint: 'POST /api/v1/checkout',
latency: '184 ms',
owner: 'Payments platform',
},
{
id: 'catalog',
service: 'catalog-service',
endpoint: 'GET /api/v2/products/{productId}/availability',
latency: '96 ms',
owner: 'Storefront experience',
},
{
id: 'identity',
service: 'identity-service',
endpoint: 'POST /api/v1/session/refresh',
latency: '242 ms',
owner: 'Identity and access management',
},
];
const columns: TableColumnDef<ServiceRow>[] = [
{
id: 'service',
header: 'Service',
accessorKey: 'service',
pin: 'left',
width: { fixed: 180 },
enableSort: true,
cell: ({ value }): JSX.Element => (
<TanStackTable.Text>{String(value)}</TanStackTable.Text>
),
},
{
id: 'endpoint',
header: 'Endpoint',
accessorKey: 'endpoint',
width: { fixed: 320 },
cell: ({ value }): JSX.Element => (
<TanStackTable.Text title={String(value)}>
{String(value)}
</TanStackTable.Text>
),
},
{
id: 'latency',
header: 'P95 latency',
accessorKey: 'latency',
width: { fixed: 140 },
enableSort: true,
cell: ({ value }): JSX.Element => (
<TanStackTable.Text>{String(value)}</TanStackTable.Text>
),
},
{
id: 'owner',
header: 'Owner',
accessorKey: 'owner',
width: { fixed: 240 },
cell: ({ value }): JSX.Element => (
<TanStackTable.Text>{String(value)}</TanStackTable.Text>
),
},
];
const rowActions = (): JSX.Element => (
<DropdownMenuSimple
align="end"
menu={{
items: [
{ key: 'open', label: 'Open service details' },
{ key: 'copy', label: 'Copy service link' },
],
}}
>
<Button
aria-label="Service actions"
color="secondary"
size="icon"
variant="outlined"
>
<Ellipsis size={16} />
</Button>
</DropdownMenuSimple>
);
const meta = {
title: 'Components/TanStack Table View',
component: TanStackTable,
tags: ['play'],
decorators: [withCanvas({ height: 360, maxWidth: 640 })],
args: {
columns,
data: rows,
disableVirtualScroll: true,
getRowKey: (row): string => row.id,
},
} satisfies Meta<TanStackTableProps<ServiceRow>>;
export default meta;
type Story = StoryObj<typeof meta>;
type TooltipsStory = StoryObj<GlobalMockArgs>;
/** Data and overflow: pinned columns, clipped long cells, and a horizontal scroll surface. */
export const HorizontalOverflow: Story = {
args: { testId: 'tanstack-table' },
};
/** Data: a page-sized result with the shared pagination controls and total count. */
export const Pagination: Story = {
args: {
pagination: { total: 42, defaultLimit: 10, showTotalCount: true },
},
};
/** Data: the supported empty result keeps the table structure without a fabricated empty state. */
export const Empty: Story = {
args: { data: [], testId: 'tanstack-empty-table' },
};
/** Data: the table's real skeleton rows shown while the first page is loading. */
export const Loading: Story = {
args: { data: [], isLoading: true, skeletonRowCount: 5 },
};
/** Interaction: opens a row action menu rendered through the shared portal. */
export const RowActionsMenu: Story = {
args: { renderRowActions: rowActions, testId: 'tanstack-actions-table' },
play: async ({ canvasElement }): Promise<void> => {
const firstRow = within(canvasElement).getByTestId('tanstack-actions-table');
await userEvent.hover(firstRow.querySelector('tbody tr') as HTMLElement);
await userEvent.click(
await within(firstRow).findByLabelText('Service actions'),
);
await expect(
await screen.findByRole('menuitem', { name: 'Open service details' }),
).toBeVisible();
},
};
const RESTART_COUNTS: StatusCountItem[] = [
{
label: 'Restarts in the last 24 hours',
value: 37,
color: Color.BG_CHERRY_500,
breakdown: [
{ label: 'CrashLoopBackOff', value: 14 },
{ label: 'OOMKilled', value: 11 },
{ label: 'Liveness probe failed', value: 6 },
{ label: 'Readiness probe failed', value: 4 },
{ label: 'Image pull backoff', value: 2 },
],
},
];
const tooltipColumns: TableColumnDef<ServiceRow>[] = [
columns[0],
{
id: 'cpuRequest',
header: 'CPU request',
width: { fixed: 140 },
cell: ({ rowId }): JSX.Element => (
<ValidateColumnValueWrapper
attribute="CPU request"
entity={InfraMonitoringEntity.PODS}
rowId={rowId}
value={-1}
>
<TanStackTable.Text>0.5</TanStackTable.Text>
</ValidateColumnValueWrapper>
),
},
{
id: 'restarts',
header: 'Restarts',
width: { fixed: 140 },
cell: ({ rowId }): JSX.Element => (
<GroupedStatusCounts items={RESTART_COUNTS} rowId={rowId} />
),
},
];
/**
* Both tooltips a hovered row carries, held open: the plain sentence explaining
* a missing value, and the status breakdown, which is elements rather than text
* and grows a row per reason. Neither is rendered until the row is hovered, so
* the play hovers the first one and the control holds what it uncovered.
*/
export const Tooltips: TooltipsStory = {
args: { tooltipsOpen: true },
render: (): JSX.Element => (
<TanStackTable
columns={tooltipColumns}
data={rows}
disableVirtualScroll
getRowKey={(row): string => row.id}
testId="tanstack-tooltips-table"
/>
),
play: async ({ canvasElement }): Promise<void> => {
const table = within(canvasElement).getByTestId('tanstack-tooltips-table');
await userEvent.hover(table.querySelector('tbody tr') as HTMLElement);
// Both tooltips are rendered by the hovered row, so this is what says the
// control has something to hold open.
await screen.findByText('Restarts in the last 24 hours');
},
};

View File

@@ -37,7 +37,6 @@ 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',

View File

@@ -450,12 +450,6 @@ export default function ChatInput({
return;
}
el.style.height = 'auto';
// A hidden composer (a closed drawer, a story swapping in) measures 0.
// Leaving the height on `auto` keeps the `rows` fallback until there is
// something real to measure, instead of pinning the field shut.
if (el.scrollHeight === 0) {
return;
}
el.style.height = `${Math.min(el.scrollHeight, TEXTAREA_MAX_HEIGHT_PX)}px`;
}, [text]);

View File

@@ -1,7 +1,6 @@
.tableWrapper {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.toolbar {

View File

@@ -2,11 +2,10 @@
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-2) var(--spacing-2);
--tabs-content-padding: 0;
--tabs-text-color: var(--l1-foreground);
--tabs-active-text-color: var(--l1-foreground);
:global(.ant-tabs-tabpane) {
padding: var(--spacing-0) var(--spacing-8);
}
}
.pageError {

View File

@@ -1,9 +1,8 @@
import { useCallback } from 'react';
import { Divider } from '@signozhq/ui/divider';
import { Tabs } from '@signozhq/ui/tabs';
import { Tabs } from 'antd';
import { useConfirmableAction } from 'hooks/useConfirmableAction';
import AttributeMappingHeader from './components/AttributeMappingHeader/AttributeMappingHeader';
import AttributeMappingActions from './components/AttributeMappingActions/AttributeMappingActions';
import AttributeMappingsTab from './AttributeMappingsTab/AttributeMappingsTab';
import DiscardChangesDialog from './components/DiscardChangesDialog/DiscardChangesDialog';
import GroupFormDrawer from './components/GroupFormDrawer/GroupFormDrawer';
@@ -59,24 +58,23 @@ function LLMObservabilityAttributeMapping(): JSX.Element {
className={styles.llmObservabilityAttributeMapping}
data-testid="llm-observability-attribute-mapping-page"
>
<AttributeMappingHeader
isDirty={editor.isDirty}
isSaving={editor.isSaving}
onDiscard={discardConfirm.request}
onSave={editor.save}
/>
{editor.saveError && (
<div className={styles.pageError} role="alert">
{editor.saveError}
</div>
)}
<Divider />
<Tabs
testId="attribute-mapping-tabs"
defaultValue={MAPPINGS_TAB_KEY}
defaultActiveKey={MAPPINGS_TAB_KEY}
items={tabItems}
tabBarExtraContent={
<AttributeMappingActions
isDirty={editor.isDirty}
isSaving={editor.isSaving}
onDiscard={discardConfirm.request}
onSave={editor.save}
/>
}
/>
{groupDrawer.isOpen && (
<GroupFormDrawer

View File

@@ -63,6 +63,26 @@ const EDITED_SPAN_JSON = `{
}
}`;
const SPAN_WITH_EXTRA_KEY_JSON = `{
"attributes": {
"input.value": "What is quantum computing?"
},
"resource": {
"service.name": "llm-gateway"
},
"demo": {
"name": "demo"
}
}`;
const EXTRA_KEY_RESULT_SPAN = {
attributes: {
'input.value': 'What is quantum computing?',
[MAPPED_ATTRIBUTE_KEY]: 'What is quantum computing?',
},
resource: { 'service.name': 'llm-gateway' },
};
const SPAN_INPUT_KEY = LOCALSTORAGE.LLM_ATTRIBUTE_MAPPING_TEST_SPAN;
describe('TestTab — sample-span flow', () => {
@@ -104,6 +124,47 @@ describe('TestTab — sample-span flow', () => {
expect(screen.queryByTestId('test-error')).not.toBeInTheDocument();
});
it('trims extra top-level keys and sends only the envelope', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
let body: { spans?: { attributes?: Record<string, unknown> }[] } | undefined;
server.use(
rest.post(TEST_ENDPOINT, async (req, res, ctx) => {
body = await req.json();
return res(
ctx.status(200),
ctx.json(makeTestResponse([EXTRA_KEY_RESULT_SPAN])),
);
}),
);
render(<LLMObservabilityAttributeMapping />);
await user.click(screen.getByRole('tab', { name: 'Test' }));
const runBtn = await screen.findByTestId('run-test-button');
await user.clear(screen.getByTestId('monaco'));
await user.paste(SPAN_WITH_EXTRA_KEY_JSON);
await waitFor(() =>
expect(screen.getByTestId('monaco')).toHaveValue(SPAN_WITH_EXTRA_KEY_JSON),
);
expect(screen.queryByTestId('test-input-error')).not.toBeInTheDocument();
await user.click(runBtn);
await expect(
screen.findByTestId('test-results'),
).resolves.toBeInTheDocument();
expect(body?.spans?.[0]?.attributes).toStrictEqual({
'input.value': 'What is quantum computing?',
});
expect(screen.getByTestId('test-result-0-attributes')).toHaveTextContent(
MAPPED_ATTRIBUTE_KEY,
);
expect(screen.getByTestId('test-result-0-resource')).toBeInTheDocument();
expect(screen.getByText('populated')).toBeInTheDocument();
});
it('surfaces a backend error and renders no results', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(

View File

@@ -0,0 +1,51 @@
import { parseSpanInput } from '../testPayload';
describe('parseSpanInput', () => {
it('reads the envelope and trims extra top-level keys', () => {
const span = parseSpanInput(`{
"attributes": { "llm.model_name": "gpt-4o" },
"resource": { "service.name": "llm-gateway" },
"demo": { "name": "demo" }
}`);
expect(span.attributes).toStrictEqual({ 'llm.model_name': 'gpt-4o' });
expect(span.resource).toStrictEqual({ 'service.name': 'llm-gateway' });
});
it('reads a clean envelope', () => {
const span = parseSpanInput(`{
"attributes": { "llm.model_name": "gpt-4o" },
"resource": { "service.name": "llm-gateway" }
}`);
expect(span.attributes).toStrictEqual({ 'llm.model_name': 'gpt-4o' });
expect(span.resource).toStrictEqual({ 'service.name': 'llm-gateway' });
});
it('treats an envelope-less object as a bare attribute map', () => {
const span = parseSpanInput('{ "llm.model_name": "gpt-4o", "demo": "x" }');
expect(span.attributes).toStrictEqual({
'llm.model_name': 'gpt-4o',
demo: 'x',
});
expect(span.resource).toStrictEqual({});
});
it('drops an envelope key that is not an object', () => {
const span = parseSpanInput(
'{ "attributes": { "llm.provider": "openai" }, "resource": "oops" }',
);
expect(span.attributes).toStrictEqual({ 'llm.provider': 'openai' });
expect(span.resource).toStrictEqual({});
});
it.each([
[' ', 'Paste a JSON span object to run the test.'],
['{ "a": }', 'Invalid JSON — check for trailing commas or missing quotes.'],
['[1, 2]', 'Span must be a JSON object of attribute key-value pairs.'],
])('rejects %p', (input, message) => {
expect(() => parseSpanInput(input)).toThrow(message);
});
});

View File

@@ -51,13 +51,9 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
// Any other top-level key (a real span carries name, spanId, kind...) is trimmed.
function isSpanEnvelope(parsed: Record<string, unknown>): boolean {
const keys = Object.keys(parsed);
return (
keys.length > 0 &&
keys.every((key) => key === 'attributes' || key === 'resource') &&
(isPlainObject(parsed.attributes) || isPlainObject(parsed.resource))
);
return isPlainObject(parsed.attributes) || isPlainObject(parsed.resource);
}
export function parseSpanInput(input: string): SpantypesSpanMapperTestSpanDTO {

View File

@@ -72,20 +72,15 @@ describe('LLMObservabilityAttributeMapping', () => {
const attributeMappingsTab = screen.getByRole('tab', {
name: 'Attribute Mappings',
});
expect(attributeMappingsTab).toHaveAttribute('data-state', 'active');
expect(attributeMappingsTab).toHaveAttribute('aria-selected', 'true');
await expect(
screen.findByTestId('attribute-mappings-tab'),
).resolves.toBeInTheDocument();
});
it('renders the header with its description and no Save/Discard while pristine', () => {
it('renders no Save/Discard while pristine', () => {
render(<LLMObservabilityAttributeMapping />);
expect(
screen.getByText(
'Configure source-to-target attribute remapping for LLM traces',
),
).toBeInTheDocument();
// The actions only appear once there are staged changes.
expect(screen.queryByTestId('save-changes-btn')).not.toBeInTheDocument();
expect(screen.queryByTestId('discard-changes-btn')).not.toBeInTheDocument();
@@ -124,7 +119,11 @@ describe('LLMObservabilityAttributeMapping', () => {
await user.click(screen.getByRole('tab', { name: 'Attribute Mappings' }));
await screen.findByTestId('attribute-mappings-tab');
expect(screen.queryByTestId('span-json-editor')).not.toBeInTheDocument();
// antd keeps a visited pane mounted and marks it aria-hidden, rather than
// unmounting it the way the previous tabs did.
expect(
screen.getByTestId('span-json-editor').closest('[role="tabpanel"]'),
).toHaveAttribute('aria-hidden', 'true');
await user.click(screen.getByRole('tab', { name: 'Test' }));

View File

@@ -0,0 +1,10 @@
.actions {
display: flex;
align-items: center;
gap: var(--spacing-6);
}
.unsavedChanges {
font-size: var(--periscope-font-size-base);
color: var(--accent-amber);
}

View File

@@ -0,0 +1,53 @@
import { Button } from '@signozhq/ui/button';
import { useCanManageAttributeMapping } from '../../hooks/useCanManageAttributeMapping';
import styles from './AttributeMappingActions.module.scss';
interface AttributeMappingActionsProps {
isDirty: boolean;
isSaving: boolean;
onDiscard: () => void;
onSave: () => void;
}
function AttributeMappingActions({
isDirty,
isSaving,
onDiscard,
onSave,
}: AttributeMappingActionsProps): JSX.Element | null {
const canManage = useCanManageAttributeMapping();
if (!canManage || !isDirty) {
return null;
}
return (
<div className={styles.actions}>
<span className={styles.unsavedChanges} data-testid="unsaved-changes">
Unsaved changes
</span>
<Button
variant="outlined"
color="secondary"
onClick={onDiscard}
disabled={isSaving}
testId="discard-changes-btn"
>
Discard
</Button>
<Button
variant="solid"
color="primary"
onClick={onSave}
loading={isSaving}
disabled={isSaving}
testId="save-changes-btn"
>
{isSaving ? 'Saving…' : 'Save changes'}
</Button>
</div>
);
}
export default AttributeMappingActions;

View File

@@ -1,18 +0,0 @@
.pageHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-left: var(--spacing-2);
margin-top: var(--spacing-4);
}
.pageHeaderActions {
display: flex;
align-items: center;
gap: var(--spacing-6);
}
.unsavedChanges {
font-size: var(--periscope-font-size-base);
color: var(--accent-amber);
}

View File

@@ -1,56 +0,0 @@
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { useCanManageAttributeMapping } from '../../hooks/useCanManageAttributeMapping';
import styles from './AttributeMappingHeader.module.scss';
interface AttributeMappingHeaderProps {
isDirty: boolean;
isSaving: boolean;
onDiscard: () => void;
onSave: () => void;
}
function AttributeMappingHeader({
isDirty,
isSaving,
onDiscard,
onSave,
}: AttributeMappingHeaderProps): JSX.Element {
const canManage = useCanManageAttributeMapping();
return (
<header className={styles.pageHeader}>
<Typography.Text as="p" size="base" color="muted">
Configure source-to-target attribute remapping for LLM traces
</Typography.Text>
{canManage && isDirty && (
<div className={styles.pageHeaderActions}>
<span className={styles.unsavedChanges} data-testid="unsaved-changes">
Unsaved changes
</span>
<Button
variant="outlined"
color="secondary"
onClick={onDiscard}
disabled={isSaving}
testId="discard-changes-btn"
>
Discard
</Button>
<Button
variant="solid"
color="primary"
onClick={onSave}
loading={isSaving}
disabled={isSaving}
testId="save-changes-btn"
>
{isSaving ? 'Saving…' : 'Save changes'}
</Button>
</div>
)}
</header>
);
}
export default AttributeMappingHeader;

View File

@@ -1,4 +1,7 @@
.groupForm {
--input-foreground: var(--l1-foreground);
--input-placeholder-color: var(--l3-foreground);
display: flex;
flex-direction: column;
gap: var(--spacing-10);
@@ -18,11 +21,8 @@
}
.groupFormLabel {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--l3-foreground);
font-size: var(--periscope-font-size-base);
color: var(--l2-foreground);
}
.groupFormHint {

View File

@@ -5,17 +5,12 @@
}
.label {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--l3-foreground);
font-size: var(--periscope-font-size-base);
color: var(--l2-foreground);
}
.labelHint {
font-weight: var(--font-weight-normal);
text-transform: none;
letter-spacing: normal;
color: var(--l3-foreground);
}
.keys {

View File

@@ -1,4 +1,6 @@
.form {
--input-foreground: var(--l1-foreground);
--input-placeholder-color: var(--l3-foreground);
display: flex;
flex-direction: column;
gap: var(--spacing-10);
@@ -12,17 +14,12 @@
}
.label {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--l3-foreground);
font-size: var(--periscope-font-size-base);
color: var(--l2-foreground);
}
.labelHint {
font-weight: var(--font-weight-normal);
text-transform: none;
letter-spacing: normal;
color: var(--l3-foreground);
}
.hint {

View File

@@ -11,10 +11,12 @@ const PERSISTED_KEY = `@signoz/table-columns/${STORAGE_KEY}`;
const ROWS = [{ id: 't1', trace_id: 'abc', 'service.name': 'checkout' }];
// An aggregate outside the default order starts hidden, so the persisted
// defaults are observable.
const COLUMNS = buildTraceViewColumns([
{ name: 'trace_id' },
{ name: 'service.name', fieldContext: 'resource' },
{ name: 'start_time' },
{ name: 'unlisted_aggregate' },
]);
function RaceHarness(): JSX.Element {
@@ -66,7 +68,9 @@ describe('TracesTable column-init race', () => {
await expect(screen.findByRole('table')).resolves.toBeInTheDocument();
expect(screen.getByText('trace_id')).toBeInTheDocument();
expect(screen.queryByText('start_time')).not.toBeInTheDocument();
expect(persistedState()?.hiddenColumnIds).toStrictEqual(['start_time']);
expect(screen.queryByText('unlisted_aggregate')).not.toBeInTheDocument();
expect(persistedState()?.hiddenColumnIds).toStrictEqual([
'unlisted_aggregate',
]);
});
});

View File

@@ -128,14 +128,7 @@ describe('TracesView column persistence', () => {
await findTable();
await waitFor(() => {
expect(persistedState()?.hiddenColumnIds).toStrictEqual([
'start_time',
'end_time',
'error_count',
'input',
'output',
'trace:tool_call_count:float64',
]);
expect(persistedState()?.hiddenColumnIds).toStrictEqual([]);
});
expect(screen.getByText(OPTIONS_TRIGGER)).toBeInTheDocument();
expect(screen.getByText('llm_call_count')).toBeInTheDocument();
@@ -160,7 +153,7 @@ describe('TracesView column persistence', () => {
expect(screen.queryByText(OPTIONS_TRIGGER)).not.toBeInTheDocument();
});
it('renders only the default-visible columns when the field keys fail', async () => {
it('renders the display-only columns when the field keys fail', async () => {
mockFieldKeysFailure();
renderTracesView();
@@ -168,8 +161,8 @@ describe('TracesView column persistence', () => {
expect(screen.getByText('root_span_name')).toBeInTheDocument();
expect(screen.getByText('trace_id')).toBeInTheDocument();
expect(screen.queryByText('input')).not.toBeInTheDocument();
expect(screen.queryByText('output')).not.toBeInTheDocument();
expect(screen.getByText('input')).toBeInTheDocument();
expect(screen.queryByText('llm_call_count')).not.toBeInTheDocument();
});
it('leaves an existing selection untouched while the field keys fail', async () => {

View File

@@ -109,17 +109,24 @@ describe('useTraceViewColumns', () => {
const { result } = await renderColumns();
expect(columnNames(result.current.columns)).toStrictEqual([
'trace_id',
'service.name',
'root_span_name',
'estimated_total_cost',
'trace_duration_nano',
'span_count',
'trace_id',
'total_tokens',
'input_tokens',
'output_tokens',
'distinct_tool_count',
'llm_call_count',
'tool_call_count',
'start_time',
'end_time',
'error_count',
'input',
'output',
...AGGREGATE_KEYS,
'max_llm_duration_nano',
]);
});
@@ -127,14 +134,24 @@ describe('useTraceViewColumns', () => {
const { result } = await renderColumns();
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
'trace_id',
'service.name',
'root_span_name',
'estimated_total_cost',
'trace_duration_nano',
'span_count',
'trace_id',
'llm_call_count',
'total_tokens',
'estimated_total_cost',
'input_tokens',
'output_tokens',
'distinct_tool_count',
'llm_call_count',
'tool_call_count',
'start_time',
'end_time',
'error_count',
'input',
'output',
'max_llm_duration_nano',
]);
});
@@ -193,14 +210,24 @@ describe('useTraceViewColumns', () => {
expect(result.current.canPersistColumns).toBe(true);
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
'trace_id',
'service.name',
'root_span_name',
'estimated_total_cost',
'trace_duration_nano',
'span_count',
'trace_id',
'llm_call_count',
'total_tokens',
'estimated_total_cost',
'input_tokens',
'output_tokens',
'distinct_tool_count',
'llm_call_count',
'tool_call_count',
'start_time',
'end_time',
'error_count',
'input',
'output',
'max_llm_duration_nano',
]);
});
});

View File

@@ -5,20 +5,43 @@ import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
/** Always visible: it is the row's link to the trace. */
/** Always present: it is the row's link to the trace, but it can be reordered. */
export const TRACE_ID_COLUMN_ID = 'trace_id';
/** Everything else starts hidden; only applied at first init, since the store persists hidden ids. */
const DEFAULT_VISIBLE_FIELDS = new Set([
/** Fallback order, until the user drags a column; unlisted fields keep the order the keys endpoint returns them in. */
const DEFAULT_COLUMN_ORDER = [
TRACE_ID_COLUMN_ID,
'service.name',
'root_span_name',
'estimated_total_cost',
'trace_duration_nano',
'span_count',
'llm_call_count',
'total_tokens',
'estimated_total_cost',
TRACE_ID_COLUMN_ID,
]);
'input_tokens',
'output_tokens',
'distinct_tool_count',
'llm_call_count',
'tool_call_count',
'start_time',
'end_time',
'error_count',
'input',
'output',
'max_llm_duration_nano',
];
const orderRank = (field: TelemetryFieldKey): number => {
const index = DEFAULT_COLUMN_ORDER.indexOf(field.name);
return index === -1 ? Number.MAX_SAFE_INTEGER : index;
};
export const sortByDefaultOrder = (
fields: TelemetryFieldKey[],
): TelemetryFieldKey[] =>
[...fields].sort((a, b) => orderRank(a) - orderRank(b));
/** Anything the keys endpoint adds beyond the ordered set starts hidden; only applied at first init, since the store persists hidden ids. */
const DEFAULT_VISIBLE_FIELDS = new Set(DEFAULT_COLUMN_ORDER);
export const buildTraceViewColumns = (
fields: TelemetryFieldKey[],
@@ -27,7 +50,7 @@ export const buildTraceViewColumns = (
...getFieldColumn(field),
defaultVisibility: DEFAULT_VISIBLE_FIELDS.has(field.name),
// The shared column builder pins anything in TIMESTAMP_FIELD_NAMES; these stay movable.
enableMove: field.name !== TRACE_ID_COLUMN_ID,
enableMove: true,
enableRemove: field.name !== TRACE_ID_COLUMN_ID,
canBeHidden: field.name !== TRACE_ID_COLUMN_ID,
}));

View File

@@ -21,7 +21,11 @@ import {
TRACE_VIEW_COLUMN_EXTRA_FIELDS,
TRACE_VIEW_FIELD_KEYS,
} from '../constants';
import { buildTraceViewColumns, TRACE_ID_COLUMN_ID } from './configs';
import {
buildTraceViewColumns,
sortByDefaultOrder,
TRACE_ID_COLUMN_ID,
} from './configs';
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
@@ -55,7 +59,10 @@ export function useTraceViewColumns(): UseTraceViewColumns {
);
const availableFields = useMemo(
() => mergeExtraFields(TRACE_VIEW_COLUMN_EXTRA_FIELDS, fetchedFields),
() =>
sortByDefaultOrder(
mergeExtraFields(TRACE_VIEW_COLUMN_EXTRA_FIELDS, fetchedFields),
),
[fetchedFields],
);

View File

@@ -1,13 +0,0 @@
.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);
}
}

View File

@@ -1,24 +0,0 @@
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;

View File

@@ -6,7 +6,13 @@
//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*='dashboardPageHeader']),
:global([class*='dashboardInfoWithActions']) {
display: none;
}
// Embedded read-only: the add-variable and panel-action triggers are dead here.
:global([class*='addSlot']),
:global([data-testid^='panel-actions-']) {
display: none;
}
}

View File

@@ -2,10 +2,10 @@
display: flex;
flex-direction: column;
gap: var(--spacing-8);
--tabs-content-padding: 0;
margin-top: var(--spacing-3);
--tabs-text-color: var(--l1-foreground);
--tabs-active-text-color: var(--l1-foreground);
:global(.ant-tabs-tabpane) {
padding: var(--spacing-0) var(--spacing-8);
}
}
.tabLabel {

View File

@@ -1,5 +1,5 @@
import { Badge } from '@signozhq/ui/badge';
import { Tabs } from '@signozhq/ui/tabs';
import { Tabs } from 'antd';
import { useListUnmappedLLMModels } from 'api/generated/services/llmpricingrules';
import { parseAsStringEnum, useQueryState } from 'nuqs';
@@ -26,7 +26,7 @@ function LLMObservabilityModelPricing(): JSX.Element {
data-testid="llm-observability-model-pricing-page"
>
<Tabs
value={activeTab}
activeKey={activeTab}
onChange={(key): void => {
void setActiveTab(key as typeof activeTab);
}}

View File

@@ -18,6 +18,8 @@
.filtersBarSource {
width: 160px;
/* Trigger defaults to 2.25rem; the search input and Add button are both 2rem. */
--select-trigger-height: 32px;
}
.pageError {

View File

@@ -1,3 +1,7 @@
.fieldLabel {
composes: fieldLabel from './shared.module.scss';
}
.drawerSection {
composes: drawerSection from './shared.module.scss';
}
@@ -17,6 +21,9 @@
--dialog-header-padding: var(--spacing-10) var(--spacing-12);
--dialog-footer-padding: var(--spacing-8) var(--spacing-12);
--input-foreground: var(--l1-foreground);
--input-placeholder-color: var(--l3-foreground);
display: flex;
overflow: hidden;

View File

@@ -109,7 +109,7 @@ function ModelCostDrawer({
drawerHeaderProps={{ className: styles.title }}
>
<div className={styles.drawerSection}>
<label htmlFor="billing-model-id">
<label htmlFor="billing-model-id" className={styles.fieldLabel}>
Billing Model ID{' '}
<span className={styles.required} aria-hidden="true">
*
@@ -144,7 +144,9 @@ function ModelCostDrawer({
</div>
<div className={styles.drawerSection}>
<label htmlFor="provider-select">Provider</label>
<label htmlFor="provider-select" className={styles.fieldLabel}>
Provider
</label>
<Controller
name="provider"
control={control}

View File

@@ -1,3 +1,7 @@
.fieldLabel {
composes: fieldLabel from '../../shared.module.scss';
}
.drawerSection {
composes: drawerSection from '../../shared.module.scss';
}

View File

@@ -67,9 +67,7 @@ function ExtraPricingBuckets({
return (
<div className={cx(styles.extraBucketsSection, styles.drawerSection)}>
<div className={styles.extraBucketsSectionHead}>
<Typography.Text as="span" size="small" color="muted">
Extra Pricing Buckets
</Typography.Text>
<span className={styles.fieldLabel}>Extra Pricing Buckets</span>
<Typography.Text as="span" size="small" color="muted">
Optional
</Typography.Text>
@@ -116,7 +114,9 @@ function ExtraPricingBuckets({
{addedBuckets.length > 0 && (
<div className={cx(styles.pricingField, styles.cacheModeField)}>
<label htmlFor="cache-mode">Cache mode</label>
<label htmlFor="cache-mode" className={styles.fieldLabel}>
Cache mode
</label>
<SelectSimple
id="cache-mode"
value={pricing.cacheMode}

View File

@@ -1,3 +1,7 @@
.fieldLabel {
composes: fieldLabel from '../../shared.module.scss';
}
.drawerSection {
composes: drawerSection from '../../shared.module.scss';
}

View File

@@ -37,12 +37,12 @@ function PatternEditor({
return (
<div className={styles.drawerSection}>
<Typography.Text as="span">
<span className={styles.fieldLabel}>
Model name patterns{' '}
<Typography.Text as="span" color="muted">
(prefix match)
</Typography.Text>
</Typography.Text>
</span>
<div className={styles.patternBox}>
<div className={styles.patternChips}>
{patterns.map((pattern) => (

View File

@@ -1,3 +1,7 @@
.fieldLabel {
composes: fieldLabel from '../../shared.module.scss';
}
.drawerSection {
composes: drawerSection from '../../shared.module.scss';
}

View File

@@ -24,9 +24,7 @@ function PricingFields({
return (
<div className={cx(styles.drawerSection, styles.drawerSurface)}>
<div className={styles.drawerSurfaceHead}>
<Typography.Text size="base" weight="bold">
Pricing (per 1M tokens, USD)
</Typography.Text>
<span className={styles.fieldLabel}>Pricing (per 1M tokens, USD)</span>
{isReadOnly && (
<span className={styles.managedLabel} data-testid="drawer-readonly-label">
@@ -38,7 +36,7 @@ function PricingFields({
</div>
<div className={styles.pricingGrid}>
<div className={styles.pricingField}>
<label htmlFor="input-cost">
<label htmlFor="input-cost" className={styles.fieldLabel}>
Input Cost{' '}
<span className={styles.required} aria-hidden="true">
*
@@ -58,7 +56,7 @@ function PricingFields({
/>
</div>
<div className={styles.pricingField}>
<label htmlFor="output-cost">
<label htmlFor="output-cost" className={styles.fieldLabel}>
Output Cost{' '}
<span className={styles.required} aria-hidden="true">
*

View File

@@ -1,3 +1,7 @@
.fieldLabel {
composes: fieldLabel from '../../shared.module.scss';
}
.drawerSection {
composes: drawerSection from '../../shared.module.scss';
}

View File

@@ -2,7 +2,6 @@ import { useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { Lock } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import styles from './SourceSelector.module.scss';
@@ -42,9 +41,7 @@ function SourceSelector({
return (
<div className={cx(styles.drawerSection, styles.drawerSurface)}>
<div className={styles.drawerSurfaceHead}>
<Typography.Text weight="bold" size="base">
Source
</Typography.Text>
<span className={styles.fieldLabel}>Source</span>
{isReadOnly && (
<span className={styles.managedLabel} data-testid="drawer-managed-label">

View File

@@ -47,6 +47,14 @@
color: var(--accent-cherry);
}
/* Single treatment for every label in the drawer, so field labels and the */
.fieldLabel {
font-size: var(--periscope-font-size-base);
font-weight: var(--font-weight-medium);
line-height: var(--spacing-10);
color: var(--l2-foreground);
}
.pricingField {
display: flex;
flex-direction: column;

View File

@@ -167,7 +167,7 @@ describe('UnpricedModelsTab (integration)', () => {
await screen.findByTestId(`unpriced-model-name-${MODEL}`);
// Open the row's dropdown and take the "Create pricing for …" escape hatch
// Open the row's dropdown and take the "Create a new pricing model" escape hatch
// instead of mapping onto an existing billing model.
await user.click(screen.getByTestId(`map-to-select-${MODEL}`));
await user.click(await screen.findByTestId(`map-to-create-${MODEL}`));

View File

@@ -14,6 +14,24 @@
width: 280px;
}
.footer {
padding: var(--spacing-2);
background-color: var(--l2-background);
}
.createItem {
gap: var(--spacing-4);
font-style: normal;
color: var(--accent-primary);
--command-item-cursor: pointer;
--command-item-svg-size: var(--spacing-7);
&[data-selected='true'] {
background-color: var(--callout-primary-background);
color: var(--accent-primary);
}
}
.skeletonList {
display: flex;
flex-direction: column;

View File

@@ -119,15 +119,18 @@ function MapToBillingModelSelect({
options scroll. Escape hatch when no existing billing model fits:
define this model's own pricing rather than mapping onto another. */}
<ComboboxSeparator alwaysRender />
<ComboboxCreateItem
inputValue={modelName}
value={`create-pricing-${modelName}`}
prefix={<Plus size={14} />}
onSelect={handleCreateNew}
testId={`map-to-create-${modelName}`}
>
Create pricing for &quot;{modelName}&quot;
</ComboboxCreateItem>
<div className={styles.footer}>
<ComboboxCreateItem
className={styles.createItem}
inputValue={modelName}
value={`create-pricing-${modelName}`}
prefix={<Plus size={14} />}
onSelect={handleCreateNew}
testId={`map-to-create-${modelName}`}
>
Create a new pricing model
</ComboboxCreateItem>
</div>
</ComboboxCommand>
</ComboboxContent>
</Combobox>

View File

@@ -1,71 +0,0 @@
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 };
}

View File

@@ -1,136 +0,0 @@
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);

View File

@@ -1,14 +0,0 @@
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;
`;

View File

@@ -1,6 +1,4 @@
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';
@@ -14,23 +12,19 @@ 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 {
@@ -65,7 +59,6 @@ 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();
@@ -185,11 +178,6 @@ 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,
});

View File

@@ -10,7 +10,6 @@ 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';
@@ -27,6 +26,7 @@ import {
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { RESTRICTED_SELECTED_FIELDS } from '../config';
import { DataType } from '../TableView';
import {
filterKeyForField,
@@ -141,10 +141,9 @@ export default function TableViewActions(
const { stagedQuery, updateQueriesData } = useQueryBuilder();
const { dataType, logType: fieldType } = getFieldAttributes(record.field);
// there is no option for where clause in old logs explorer and live logs page or infra monitoring
const isOldLogsExplorerOrLiveLogsPage = useMemo(
// there is no option for where clause in live logs page or infra monitoring
const isLiveLogsOrInfraPage = useMemo(
() =>
pathname === ROUTES.OLD_LOGS_EXPLORER ||
pathname === ROUTES.LIVE_LOGS ||
pathname === ROUTES.INFRASTRUCTURE_MONITORING_HOSTS ||
pathname === ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES,
@@ -400,7 +399,7 @@ export default function TableViewActions(
)}
/>
</Tooltip>
{!isOldLogsExplorerOrLiveLogsPage && (
{!isLiveLogsOrInfraPage && (
<Popover
open={isOpen}
onOpenChange={setIsOpen}
@@ -487,7 +486,7 @@ export default function TableViewActions(
)}
/>
</Tooltip>
{!isOldLogsExplorerOrLiveLogsPage && (
{!isLiveLogsOrInfraPage && (
<Popover
open={isOpen}
onOpenChange={setIsOpen}

View File

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

View File

@@ -1,5 +1,10 @@
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,

View File

@@ -61,8 +61,7 @@ export function useLogAttributeActions({
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
?.active || false;
const isOldExplorerOrLive =
pathname === ROUTES.OLD_LOGS_EXPLORER || pathname === ROUTES.LIVE_LOGS;
const isLiveLogs = pathname === ROUTES.LIVE_LOGS;
const filterFor = useCallback(
(context: FieldContext, isFilterIn: boolean): void => {
@@ -221,7 +220,7 @@ export function useLogAttributeActions({
!handleChangeSelectedView ||
!buildLogFilterTarget(fieldKeyPath, undefined, isBodyJsonQueryEnabled)
.groupBySupported ||
isOldExplorerOrLive,
isLiveLogs,
},
{
key: LogDetailsAction.REPLACE_FILTER,
@@ -229,9 +228,7 @@ export function useLogAttributeActions({
icon: <RefreshCw size={12} />,
onClick: replaceFilter,
shouldHide: (_key, fieldKeyPath): boolean =>
!handleChangeSelectedView ||
isRestricted(fieldKeyPath) ||
isOldExplorerOrLive,
!handleChangeSelectedView || isRestricted(fieldKeyPath) || isLiveLogs,
},
];
}, [
@@ -239,7 +236,7 @@ export function useLogAttributeActions({
groupBy,
replaceFilter,
isBodyJsonQueryEnabled,
isOldExplorerOrLive,
isLiveLogs,
handleChangeSelectedView,
onApplyLogFilter,
]);

View File

@@ -1,168 +0,0 @@
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));

View File

@@ -5,10 +5,6 @@ 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';
@@ -18,6 +14,10 @@ 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';

View File

@@ -1,26 +0,0 @@
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',
},
];

View File

@@ -1,270 +0,0 @@
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);

View File

@@ -1,25 +0,0 @@
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'};
`;

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