mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-06 18:20:50 +01:00
Compare commits
20 Commits
feat/trace
...
test/inter
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
867b958318 | ||
|
|
088fe04a20 | ||
|
|
0002d9c5f8 | ||
|
|
c8b6e83474 | ||
|
|
5607edb302 | ||
|
|
680ac5e88f | ||
|
|
09f9d2f3c8 | ||
|
|
203d7a0753 | ||
|
|
3ba8c9390c | ||
|
|
6cc07a4d75 | ||
|
|
856cd91491 | ||
|
|
f22196a2c0 | ||
|
|
0438850c45 | ||
|
|
2cc9421350 | ||
|
|
c692297ca6 | ||
|
|
cc6ddd5233 | ||
|
|
d72424d9d0 | ||
|
|
bbec5691e0 | ||
|
|
cf61f7b31a | ||
|
|
02055934ba |
2
.github/workflows/goci.yaml
vendored
2
.github/workflows/goci.yaml
vendored
@@ -67,7 +67,7 @@ jobs:
|
||||
with:
|
||||
go-version: "1.24"
|
||||
- name: check-semconv-generated-files
|
||||
run: make semconv-check
|
||||
run: go run ./scripts/semconv -check
|
||||
build:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
|
||||
4
Makefile
4
Makefile
@@ -262,10 +262,6 @@ py-clean: ## Clear all pycache and pytest cache from tests directory recursively
|
||||
semconv-generate: ## Regenerate semantic-convention families for Go and TypeScript
|
||||
@go run ./scripts/semconv
|
||||
|
||||
.PHONY: semconv-check
|
||||
semconv-check: ## Fail if the generated semantic-convention files are stale
|
||||
@go run ./scripts/semconv -check
|
||||
|
||||
.PHONY: gen-mocks
|
||||
gen-mocks:
|
||||
@echo ">> Generating mocks"
|
||||
|
||||
@@ -84,9 +84,9 @@ A storage answers four questions and nothing else:
|
||||
| WhenAbsent | Absent row reads | Positive filter | Raw select | Multi-candidate column | Field keys |
|
||||
|---|---|---|---|---|---|
|
||||
| `AlwaysPresent` | a real value | no guard | no guard | no branch, ends the candidate list | table columns |
|
||||
| `AbsentIsSentinel` | `''`, 0, false, and that is not a value | exists guard | exists guard | presence branch | map attributes, cast JSON paths, string families of such members |
|
||||
| `AbsentIsSentinel` | `''`, 0, false, and that is not a value | exists guard | exists guard | presence branch | map attributes, cast JSON paths, string families |
|
||||
| `AbsentIsNull` | NULL | no guard | no guard | presence branch | multi-era folds, body JSON paths, numeric families |
|
||||
| `AbsentIsValue` | `''`, and that is the keyless contract | no guard | no guard | no presence branch | metrics labels, rule state history labels, and families of such members |
|
||||
| `AbsentIsValue` | `''`, and that is the keyless contract | no guard | no guard | no presence branch | metrics labels, rule state history labels |
|
||||
|
||||
### The generic layer
|
||||
|
||||
@@ -109,7 +109,7 @@ The functions, from the outside in:
|
||||
| `RejectsBodyFunction(traits, operator)` | Runs before resolution. A storage without body functions (`has`, `hasAny`, `hasAll`, `hasToken`, `search`) errors. The fingerprint side of a split skips the term, because the main query evaluates it. After resolution, `Condition` errors when `has`, `hasAny`, `hasAll`, or `hasToken` lands on a map-backed key (resource, attribute, scope), before the split can drop it. |
|
||||
| `SharedCondition(...)` | The `Compile` of every storage without its own condition language: `LogicalRead`, the shared data-type collision cast, `OperatorCondition`, then the guard rule. |
|
||||
| `OperatorCondition(...)` | The operator switch over an already cast read. A storage with its own cast policy composes with it. |
|
||||
| `LogicalRead(...)` | The only place family expressions are built. A single-member field reads through its member. A family merges the member reads, current member first: `COALESCE(NULLIF(m1, ''), NULLIF(m2, ''), '')` for strings, `multiIf` with a NULL tail for numbers. It ORs the member presence tests. A row without any member reads what the tail of the merge reads. When every member reads its sentinel as a value, so does the family. A member with a value map reads through `TransformRead`. `NOT EXISTS` is the read's `Absence`, the storage's own negated form. |
|
||||
| `LogicalRead(...)` | The only place family expressions are built. A single-member field reads through its member. A family merges the member reads, current member first: `COALESCE(NULLIF(m1, ''), NULLIF(m2, ''), '')` for strings, `multiIf` with a NULL tail for numbers. It ORs the member presence tests. A row without any member reads what the tail of the merge reads. A member with a value map reads through `TransformRead`. `NOT EXISTS` is the read's `Absence`, the storage's own negated form. |
|
||||
|
||||
### A resolved key
|
||||
|
||||
|
||||
@@ -70,3 +70,49 @@
|
||||
Object.defineProperty(window, 'Date', { value: FrozenDate, writable: true });
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// Storybook has no microphone. Chromium's recognizer asks for one and for
|
||||
// Google's speech service, and the `not-allowed` or `network` it reports makes
|
||||
// the assistant hide its mic for the rest of the tab. This one starts
|
||||
// listening at once and hears only what a story passes to `hear`.
|
||||
(() => {
|
||||
class StorySpeechRecognition extends EventTarget {
|
||||
static listening = null;
|
||||
lang = 'en-US';
|
||||
continuous = false;
|
||||
interimResults = false;
|
||||
onstart = null;
|
||||
onresult = null;
|
||||
onerror = null;
|
||||
onend = null;
|
||||
|
||||
start() {
|
||||
StorySpeechRecognition.listening = this;
|
||||
setTimeout(() => this.onstart?.());
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (StorySpeechRecognition.listening === this) {
|
||||
StorySpeechRecognition.listening = null;
|
||||
}
|
||||
setTimeout(() => this.onend?.());
|
||||
}
|
||||
|
||||
abort() {
|
||||
this.stop();
|
||||
}
|
||||
|
||||
/** An interim result: the words so far, still listening. */
|
||||
hear(transcript) {
|
||||
const result = Object.assign([{ transcript, confidence: 1 }], {
|
||||
isFinal: false,
|
||||
});
|
||||
this.onresult?.({ resultIndex: 0, results: [result] });
|
||||
}
|
||||
}
|
||||
|
||||
window.SpeechRecognition = StorySpeechRecognition;
|
||||
window.webkitSpeechRecognition = StorySpeechRecognition;
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -122,7 +122,21 @@ const preview: Preview = {
|
||||
['List', 'New', 'Edit'],
|
||||
],
|
||||
'Dashboards',
|
||||
['List', 'Detail', 'Panel Editor', 'Public'],
|
||||
[
|
||||
'List',
|
||||
'Detail',
|
||||
[
|
||||
'Overview',
|
||||
'Settings',
|
||||
'Variables',
|
||||
'New Variable',
|
||||
'Edit Variable',
|
||||
'Authz',
|
||||
],
|
||||
'Panel Editor',
|
||||
['Overview', 'Chart Options', 'Thresholds and Links'],
|
||||
'Public',
|
||||
],
|
||||
'Services',
|
||||
['List', 'Detail', 'Top Level Operations', 'Service Map'],
|
||||
'Logs',
|
||||
|
||||
@@ -23,6 +23,13 @@ const IGNORED_MESSAGES = [
|
||||
// (YouTube embeds, the docs pane) so they hit the real network instead of
|
||||
// an unanswered msw request; the block is the point, not a bug.
|
||||
/violates the following Content Security Policy directive/,
|
||||
// The filter editor's ANTLR parser reports every syntax error through
|
||||
// `console.error` (`line 1:14 missing ...`), so each partial expression
|
||||
// typed into it logs one; the editor shows the same errors on screen.
|
||||
/^line \d+:\d+ /,
|
||||
// Chromium's echo of a 4xx/5xx response, which the `[response]` entry below
|
||||
// already reports with its URL and the time its request left.
|
||||
/^Failed to load resource: the server responded with a status of /,
|
||||
];
|
||||
|
||||
interface CapturedMessage {
|
||||
@@ -86,7 +93,9 @@ const config: TestRunnerConfig = {
|
||||
page.on('response', (response) => {
|
||||
if (response.status() >= 400) {
|
||||
messages.push({
|
||||
at: Date.now(),
|
||||
// The browser's clock, when the request left: a busy runner receives
|
||||
// the outgoing story's responses after the next story has started.
|
||||
at: response.request().timing().startTime,
|
||||
text: `[response] ${response.status()} ${response.url()}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
/** Suggestions wait on a 300ms debounce and a fetch, past the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
/** Throws until `found` holds something, which is what `waitFor` retries on. */
|
||||
const present = <TValue>(
|
||||
found: TValue | null | undefined,
|
||||
what: string,
|
||||
): TValue => {
|
||||
if (found === null || found === undefined) {
|
||||
throw new Error(`${what} not found`);
|
||||
}
|
||||
|
||||
return found;
|
||||
};
|
||||
|
||||
const suggestionList = (canvasElement: HTMLElement): HTMLElement | null =>
|
||||
canvasElement.querySelector<HTMLElement>('.cm-tooltip-autocomplete');
|
||||
|
||||
const suggestionRow = (
|
||||
canvasElement: HTMLElement,
|
||||
text: string,
|
||||
): HTMLElement | undefined => {
|
||||
const list = suggestionList(canvasElement);
|
||||
|
||||
return list
|
||||
? within(list)
|
||||
.queryAllByRole('option')
|
||||
.find((option) => option.textContent?.includes(text))
|
||||
: undefined;
|
||||
};
|
||||
|
||||
/** Ctrl+Space, the editor's own shortcut for asking for suggestions. */
|
||||
const requestSuggestions = (editor: HTMLElement): void => {
|
||||
editor.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: ' ',
|
||||
code: 'Space',
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const currentView = (
|
||||
canvasElement: HTMLElement,
|
||||
): { editor: HTMLElement; view: EditorView } => {
|
||||
// An explorer renders one editor per query; the first is the one on screen.
|
||||
const editor = present(
|
||||
canvasElement.querySelector<HTMLElement>(
|
||||
'.code-mirror-where-clause .cm-content',
|
||||
),
|
||||
'filter editor',
|
||||
);
|
||||
|
||||
return {
|
||||
editor,
|
||||
view: present(EditorView.findFromDOM(editor), 'editor view'),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The explorers wrap the filter in `OverlayScrollbar`, which initialises when
|
||||
* the browser is idle. Initialising moves the content, the editor with it, and
|
||||
* focuses the editor again through the DOM, which puts the caret back at the
|
||||
* start and swaps the suggestions for the key list. Throws until every wrapper
|
||||
* around the filter has initialised.
|
||||
*/
|
||||
const assertScrollbarsReady = (editor: HTMLElement): void => {
|
||||
for (
|
||||
let wrapper = editor.closest('.overlay-scrollbar');
|
||||
wrapper;
|
||||
wrapper = wrapper.parentElement?.closest('.overlay-scrollbar') ?? null
|
||||
) {
|
||||
if (!wrapper.hasAttribute('data-overlayscrollbars')) {
|
||||
throw new Error('scrollbars around the filter still initialising');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Waits until `text` shows in the suggestion list, asking for suggestions
|
||||
* whenever the list is shut. Focus and typing only open it once the keys have
|
||||
* loaded, and moving the caret never does.
|
||||
*/
|
||||
const waitForSuggestion = (
|
||||
canvasElement: HTMLElement,
|
||||
text: string,
|
||||
): Promise<HTMLElement> =>
|
||||
waitFor(
|
||||
() => {
|
||||
const { editor } = currentView(canvasElement);
|
||||
|
||||
if (!suggestionList(canvasElement)) {
|
||||
requestSuggestions(editor);
|
||||
}
|
||||
|
||||
return present(suggestionRow(canvasElement, text), `suggestion "${text}"`);
|
||||
},
|
||||
{ ...untilLoaded, interval: 250 },
|
||||
);
|
||||
|
||||
/**
|
||||
* Focuses the filter once the scrollbars around it have initialised, and waits
|
||||
* for its suggestion list.
|
||||
*/
|
||||
const focusFilter = async (canvasElement: HTMLElement): Promise<EditorView> => {
|
||||
await waitFor(
|
||||
() => {
|
||||
const { editor, view } = currentView(canvasElement);
|
||||
|
||||
assertScrollbarsReady(editor);
|
||||
|
||||
if (!view.hasFocus) {
|
||||
view.focus();
|
||||
}
|
||||
|
||||
if (!suggestionList(canvasElement)) {
|
||||
requestSuggestions(editor);
|
||||
}
|
||||
|
||||
return present(suggestionList(canvasElement), 'suggestion list');
|
||||
},
|
||||
{ ...untilLoaded, interval: 250 },
|
||||
);
|
||||
|
||||
return currentView(canvasElement).view;
|
||||
};
|
||||
|
||||
/** Waits for a row of the suggestion list. */
|
||||
export const findSuggestion = (
|
||||
canvasElement: HTMLElement,
|
||||
text: string,
|
||||
): Promise<HTMLElement> => waitForSuggestion(canvasElement, text);
|
||||
|
||||
/** Focuses the empty filter: every key, with any recent filters above them. */
|
||||
export const openKeySuggestions = async (
|
||||
canvasElement: HTMLElement,
|
||||
row: string,
|
||||
): Promise<void> => {
|
||||
await focusFilter(canvasElement);
|
||||
await waitForSuggestion(canvasElement, row);
|
||||
};
|
||||
|
||||
/**
|
||||
* Focuses the filter and types onto the end of it one character at a time,
|
||||
* each as the transaction a keystroke makes, leaving the caret at the end so
|
||||
* the suggestion list follows what was typed. Quotes and brackets are not
|
||||
* closed for it: type both.
|
||||
*
|
||||
* `userEvent.type` cannot be used: CodeMirror redraws the line as tokens are
|
||||
* highlighted, which strands the caret `userEvent` tracks.
|
||||
*/
|
||||
export const typeFilter = async (
|
||||
canvasElement: HTMLElement,
|
||||
text: string,
|
||||
): Promise<void> => {
|
||||
const view = await focusFilter(canvasElement);
|
||||
|
||||
for (const character of text) {
|
||||
const at = view.state.doc.length;
|
||||
|
||||
view.dispatch({
|
||||
changes: { from: at, insert: character },
|
||||
selection: { anchor: at + character.length },
|
||||
userEvent: 'input.type',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Types an expression, then steps the caret back inside it, before a closing
|
||||
* bracket or parenthesis, where the suggestions are about what goes in there.
|
||||
*/
|
||||
export const typeFilterWithCaretBack = async (
|
||||
canvasElement: HTMLElement,
|
||||
text: string,
|
||||
stepsBack: number,
|
||||
row: string,
|
||||
): Promise<void> => {
|
||||
await typeFilter(canvasElement, text);
|
||||
|
||||
const { view } = currentView(canvasElement);
|
||||
|
||||
view.dispatch({
|
||||
selection: { anchor: view.state.doc.length - stepsBack },
|
||||
userEvent: 'select',
|
||||
});
|
||||
await waitForSuggestion(canvasElement, row);
|
||||
};
|
||||
|
||||
/**
|
||||
* Moves focus off the filter, which is when the expression is validated and
|
||||
* the error marker can show.
|
||||
*/
|
||||
export const blurFilter = async (canvasElement: HTMLElement): Promise<void> => {
|
||||
await userEvent.keyboard('{Escape}');
|
||||
await userEvent.click(canvasElement.ownerDocument.body);
|
||||
};
|
||||
|
||||
/** Types an expression, leaves the filter and opens its validation errors. */
|
||||
export const showFilterErrors = async (
|
||||
canvasElement: HTMLElement,
|
||||
text: string,
|
||||
): Promise<void> => {
|
||||
await typeFilter(canvasElement, text);
|
||||
await blurFilter(canvasElement);
|
||||
|
||||
const marker = await waitFor(
|
||||
() =>
|
||||
present(
|
||||
canvasElement.querySelector<HTMLElement>('.query-status-container button'),
|
||||
'error marker',
|
||||
),
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.hover(marker);
|
||||
await waitFor(
|
||||
() =>
|
||||
present(
|
||||
canvasElement.ownerDocument.querySelector('.query-validation-error'),
|
||||
'validation error',
|
||||
),
|
||||
untilLoaded,
|
||||
);
|
||||
};
|
||||
@@ -233,12 +233,23 @@ function TanStackTableInner<TData, TItemKey = string>(
|
||||
[effectiveColumns],
|
||||
);
|
||||
|
||||
// A rebuilt column def is a new cell component to React, which remounts every
|
||||
// cell it renders. Read the row callbacks through refs so inline ones do not.
|
||||
const isRowActiveRef = useRef(isRowActive);
|
||||
isRowActiveRef.current = isRowActive;
|
||||
const getRowKeyDataRef = useRef(getRowKeyData);
|
||||
getRowKeyDataRef.current = getRowKeyData;
|
||||
|
||||
const tanstackColumns = useMemo<ColumnDef<TData>[]>(
|
||||
() =>
|
||||
effectiveColumns.map((colDef) =>
|
||||
buildTanstackColumnDef<TData, TItemKey>(colDef, isRowActive, getRowKeyData),
|
||||
buildTanstackColumnDef<TData, TItemKey>(
|
||||
colDef,
|
||||
(row) => isRowActiveRef.current?.(row) ?? false,
|
||||
(index) => getRowKeyDataRef.current(index),
|
||||
),
|
||||
),
|
||||
[effectiveColumns, isRowActive, getRowKeyData],
|
||||
[effectiveColumns],
|
||||
);
|
||||
|
||||
const getRowId = useCallback(
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { UrlUpdateEvent } from 'nuqs/adapters/testing';
|
||||
|
||||
import { renderTanStackTable } from './testUtils';
|
||||
import TanStackTable from '../index';
|
||||
import type { TableColumnDef } from '../types';
|
||||
import {
|
||||
defaultData,
|
||||
renderTanStackTable,
|
||||
renderWithProviders,
|
||||
type TestRow,
|
||||
} from './testUtils';
|
||||
|
||||
jest.mock('hooks/useDarkMode', () => ({
|
||||
useIsDarkMode: (): boolean => false,
|
||||
@@ -810,6 +818,58 @@ describe('TanStackTableView Integration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('cell identity', () => {
|
||||
it('keeps cells mounted across re-renders that pass new inline row callbacks', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onMount = jest.fn();
|
||||
|
||||
function MountTracker({ id }: { id: string }): JSX.Element {
|
||||
useEffect(() => onMount(id), [id]);
|
||||
return <span data-testid={`tracked-${id}`}>Row {id}</span>;
|
||||
}
|
||||
|
||||
const columns: TableColumnDef<TestRow>[] = [
|
||||
{
|
||||
id: 'id',
|
||||
header: 'ID',
|
||||
accessorKey: 'id',
|
||||
cell: ({ row }): JSX.Element => <MountTracker id={row.id} />,
|
||||
},
|
||||
];
|
||||
|
||||
function Harness(): JSX.Element {
|
||||
const [, setRenders] = useState(0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="rerender"
|
||||
onClick={(): void => setRenders((count) => count + 1)}
|
||||
>
|
||||
Re-render
|
||||
</button>
|
||||
<TanStackTable<TestRow>
|
||||
data={defaultData}
|
||||
columns={columns}
|
||||
getRowKey={(row): string => row.id}
|
||||
isRowActive={(row): boolean => row.id === '1'}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
renderWithProviders(<Harness />);
|
||||
await screen.findByTestId('tracked-2');
|
||||
onMount.mockClear();
|
||||
|
||||
await user.click(screen.getByTestId('rerender'));
|
||||
await user.hover(screen.getByTestId('tracked-2'));
|
||||
|
||||
expect(onMount).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('disableVirtualScroll', () => {
|
||||
it('throws error when used with onEndReached', () => {
|
||||
expect(() => {
|
||||
|
||||
@@ -1,82 +1,32 @@
|
||||
// Code generated by scripts/semconv. DO NOT EDIT.
|
||||
|
||||
// An empty contexts/signals/applyToMetrics array places no constraint on
|
||||
// that axis.
|
||||
export type SemconvMember = {
|
||||
readonly name: string;
|
||||
readonly contexts: readonly string[];
|
||||
readonly signals: readonly string[];
|
||||
readonly applyToMetrics: readonly string[];
|
||||
};
|
||||
|
||||
export type SemconvFamily = {
|
||||
readonly current: string;
|
||||
readonly old: readonly string[];
|
||||
readonly kind: 'attribute' | 'metric';
|
||||
readonly members: readonly SemconvMember[];
|
||||
readonly contexts: readonly string[];
|
||||
readonly signals: readonly string[];
|
||||
readonly applyToMetrics: readonly string[];
|
||||
readonly valueMap: Readonly<Record<string, string>>;
|
||||
};
|
||||
|
||||
export const SEMCONV_FAMILIES: readonly SemconvFamily[] = [
|
||||
{
|
||||
current: 'container.cpu.usage',
|
||||
kind: 'metric',
|
||||
members: [
|
||||
{
|
||||
name: 'container.cpu.utilization',
|
||||
contexts: [],
|
||||
signals: [],
|
||||
applyToMetrics: [],
|
||||
},
|
||||
],
|
||||
current: 'db.system.name',
|
||||
old: ['db.system'],
|
||||
kind: 'attribute',
|
||||
contexts: [],
|
||||
signals: [],
|
||||
applyToMetrics: [],
|
||||
valueMap: {},
|
||||
},
|
||||
{
|
||||
current: 'deployment.environment.name',
|
||||
old: ['deployment.environment'],
|
||||
kind: 'attribute',
|
||||
members: [
|
||||
{
|
||||
name: 'deployment.environment',
|
||||
contexts: [],
|
||||
signals: [],
|
||||
applyToMetrics: [],
|
||||
},
|
||||
],
|
||||
contexts: ['attribute', 'resource'],
|
||||
signals: ['logs', 'metrics', 'traces'],
|
||||
valueMap: {},
|
||||
},
|
||||
{
|
||||
current: 'k8s.node.cpu.usage',
|
||||
kind: 'metric',
|
||||
members: [
|
||||
{
|
||||
name: 'k8s.node.cpu.utilization',
|
||||
contexts: [],
|
||||
signals: [],
|
||||
applyToMetrics: [],
|
||||
},
|
||||
],
|
||||
contexts: [],
|
||||
signals: [],
|
||||
valueMap: {},
|
||||
},
|
||||
{
|
||||
current: 'k8s.pod.cpu.usage',
|
||||
kind: 'metric',
|
||||
members: [
|
||||
{
|
||||
name: 'k8s.pod.cpu.utilization',
|
||||
contexts: [],
|
||||
signals: [],
|
||||
applyToMetrics: [],
|
||||
},
|
||||
],
|
||||
contexts: [],
|
||||
signals: [],
|
||||
applyToMetrics: [],
|
||||
valueMap: {},
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useBottomStripStore } from 'container/BottomStrip/store/useBottomStripStore';
|
||||
import { StripItemKind } from 'container/BottomStrip/types';
|
||||
|
||||
import { useExceptionsStripInfo } from '../useExceptionsStripInfo';
|
||||
|
||||
describe('useExceptionsStripInfo', () => {
|
||||
beforeEach(() => {
|
||||
useBottomStripStore.setState({ left: null, ownerId: null });
|
||||
});
|
||||
|
||||
it('shows the rows on the page against the total', () => {
|
||||
renderHook(() => useExceptionsStripInfo({ shownCount: 25, totalCount: 500 }));
|
||||
|
||||
expect(useBottomStripStore.getState().left).toMatchObject([
|
||||
{ kind: StripItemKind.Text, text: '25 of 500 exceptions' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('still says n of m when the whole list fits on one page', () => {
|
||||
renderHook(() => useExceptionsStripInfo({ shownCount: 42, totalCount: 42 }));
|
||||
|
||||
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
|
||||
text: '42 of 42 exceptions',
|
||||
});
|
||||
});
|
||||
|
||||
it('says exception, not exceptions, when there is one', () => {
|
||||
renderHook(() => useExceptionsStripInfo({ shownCount: 1, totalCount: 1 }));
|
||||
|
||||
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
|
||||
text: '1 of 1 exception',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows zero before the counts land', () => {
|
||||
renderHook(() => useExceptionsStripInfo({ shownCount: 0, totalCount: 0 }));
|
||||
|
||||
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
|
||||
text: '0 of 0 exceptions',
|
||||
});
|
||||
});
|
||||
|
||||
it('clears the strip when the page unmounts', () => {
|
||||
const { unmount } = renderHook(() =>
|
||||
useExceptionsStripInfo({ shownCount: 25, totalCount: 500 }),
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(useBottomStripStore.getState().left).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -38,7 +38,6 @@ import { Exception, PayloadProps } from 'types/api/errors/getAll';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { FilterDropdownExtendsProps } from './types';
|
||||
import { useExceptionsStripInfo } from './useExceptionsStripInfo';
|
||||
import {
|
||||
extractFilterValues,
|
||||
getDefaultFilterValue,
|
||||
@@ -161,11 +160,6 @@ function AllErrors(): JSX.Element {
|
||||
},
|
||||
]);
|
||||
|
||||
useExceptionsStripInfo({
|
||||
shownCount: data?.payload?.length ?? 0,
|
||||
totalCount: errorCountResponse.data?.payload ?? 0,
|
||||
});
|
||||
|
||||
const isFetching = isErrorsFetching || errorCountResponse.isFetching;
|
||||
useEffect(() => {
|
||||
setIsFetching(isFetching);
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useBottomStrip } from 'container/BottomStrip/useBottomStrip';
|
||||
import { type StripItem, StripItemKind } from 'container/BottomStrip/types';
|
||||
import { pluralize } from 'utils/pluralize';
|
||||
|
||||
interface UseExceptionsStripInfoArgs {
|
||||
shownCount: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export function useExceptionsStripInfo({
|
||||
shownCount,
|
||||
totalCount,
|
||||
}: UseExceptionsStripInfoArgs): void {
|
||||
const items = useMemo<StripItem[]>(
|
||||
() => [
|
||||
{
|
||||
kind: StripItemKind.Text,
|
||||
text: `${shownCount} of ${pluralize(totalCount, 'exception')}`,
|
||||
},
|
||||
],
|
||||
[shownCount, totalCount],
|
||||
);
|
||||
|
||||
useBottomStrip(items);
|
||||
}
|
||||
@@ -854,9 +854,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
<ChangelogModal changelog={changelog} onClose={toggleChangelogModal} />
|
||||
)}
|
||||
|
||||
<Toaster
|
||||
offset={{ bottom: 'calc(var(--bottom-strip-height, 0px) + 24px)' }}
|
||||
/>
|
||||
<Toaster />
|
||||
</Layout>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
@@ -26,7 +26,6 @@ import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
import { useHomeStripInfo } from 'container/Home/useHomeStripInfo';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
@@ -65,8 +64,6 @@ const homeInterval = 30 * 60 * 1000;
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export default function Home(): JSX.Element {
|
||||
useHomeStripInfo();
|
||||
|
||||
const { user } = useAppContext();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useGetAlerts } from 'api/generated/services/alerts';
|
||||
import { useBottomStripStore } from 'container/BottomStrip/store/useBottomStripStore';
|
||||
import { StripItemKind } from 'container/BottomStrip/types';
|
||||
|
||||
import { useHomeStripInfo } from '../useHomeStripInfo';
|
||||
|
||||
jest.mock('api/generated/services/alerts', () => ({
|
||||
useGetAlerts: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUseGetAlerts = useGetAlerts as jest.Mock;
|
||||
|
||||
describe('useHomeStripInfo', () => {
|
||||
beforeEach(() => {
|
||||
useBottomStripStore.setState({ left: null, ownerId: null });
|
||||
});
|
||||
|
||||
it('counts the firing alert instances', () => {
|
||||
mockUseGetAlerts.mockReturnValue({ data: { data: [{}, {}, {}] } });
|
||||
|
||||
renderHook(() => useHomeStripInfo());
|
||||
|
||||
expect(useBottomStripStore.getState().left).toMatchObject([
|
||||
{ kind: StripItemKind.Text, text: '3 alerts firing' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('says alert, not alerts, when only one is firing', () => {
|
||||
mockUseGetAlerts.mockReturnValue({ data: { data: [{}] } });
|
||||
|
||||
renderHook(() => useHomeStripInfo());
|
||||
|
||||
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
|
||||
text: '1 alert firing',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows zero before the response lands', () => {
|
||||
mockUseGetAlerts.mockReturnValue({ data: undefined });
|
||||
|
||||
renderHook(() => useHomeStripInfo());
|
||||
|
||||
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
|
||||
text: '0 alerts firing',
|
||||
});
|
||||
});
|
||||
|
||||
it('clears the strip when the page unmounts', () => {
|
||||
mockUseGetAlerts.mockReturnValue({ data: { data: [{}, {}, {}] } });
|
||||
|
||||
const { unmount } = renderHook(() => useHomeStripInfo());
|
||||
|
||||
unmount();
|
||||
|
||||
expect(useBottomStripStore.getState().left).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useGetAlerts } from 'api/generated/services/alerts';
|
||||
import { useBottomStrip } from 'container/BottomStrip/useBottomStrip';
|
||||
import { type StripItem, StripItemKind } from 'container/BottomStrip/types';
|
||||
import { pluralize } from 'utils/pluralize';
|
||||
|
||||
export function useHomeStripInfo(): void {
|
||||
// Firing instances, not rules, matching the triggered alerts page.
|
||||
const { data } = useGetAlerts();
|
||||
|
||||
const count = data?.data?.length ?? 0;
|
||||
|
||||
const items = useMemo<StripItem[]>(
|
||||
() => [
|
||||
{ kind: StripItemKind.Text, text: `${pluralize(count, 'alert')} firing` },
|
||||
],
|
||||
[count],
|
||||
);
|
||||
|
||||
useBottomStrip(items);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useBottomStripStore } from 'container/BottomStrip/store/useBottomStripStore';
|
||||
import { StripItemKind } from 'container/BottomStrip/types';
|
||||
|
||||
import { useAlertRulesStripInfo } from '../useAlertRulesStripInfo';
|
||||
|
||||
describe('useAlertRulesStripInfo', () => {
|
||||
beforeEach(() => {
|
||||
useBottomStripStore.setState({ left: null, ownerId: null });
|
||||
});
|
||||
|
||||
it('shows the rows on the page against the total', () => {
|
||||
renderHook(() => useAlertRulesStripInfo({ shownCount: 15, totalCount: 17 }));
|
||||
|
||||
expect(useBottomStripStore.getState().left).toMatchObject([
|
||||
{ kind: StripItemKind.Text, text: '15 of 17 rules' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('still says n of m when the whole list fits on one page', () => {
|
||||
renderHook(() => useAlertRulesStripInfo({ shownCount: 17, totalCount: 17 }));
|
||||
|
||||
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
|
||||
text: '17 of 17 rules',
|
||||
});
|
||||
});
|
||||
|
||||
it('says rule, not rules, when there is only one', () => {
|
||||
renderHook(() => useAlertRulesStripInfo({ shownCount: 1, totalCount: 1 }));
|
||||
|
||||
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
|
||||
text: '1 of 1 rule',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows zero when nothing matched', () => {
|
||||
renderHook(() => useAlertRulesStripInfo({ shownCount: 0, totalCount: 12 }));
|
||||
|
||||
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
|
||||
text: '0 of 12 rules',
|
||||
});
|
||||
});
|
||||
|
||||
it('clears the strip when the page unmounts', () => {
|
||||
const { unmount } = renderHook(() =>
|
||||
useAlertRulesStripInfo({ shownCount: 15, totalCount: 17 }),
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(useBottomStripStore.getState().left).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,6 @@ import { ALERT_RULES_PARAMS, useAlertRulesFilters } from './hooks';
|
||||
import styles from './ListAlertRules.module.scss';
|
||||
import { getAlertRuleColumns } from './table.config';
|
||||
import type { AlertRule } from './types';
|
||||
import { useAlertRulesStripInfo } from './useAlertRulesStripInfo';
|
||||
import { useAlertRulesData } from './useAlertRulesData';
|
||||
import { useAlertRulesHandlers } from './useAlertRulesHandlers';
|
||||
|
||||
@@ -88,11 +87,6 @@ function ListAlertRules(): JSX.Element {
|
||||
return filteredRules.slice(start, start + limit);
|
||||
}, [filteredRules, page, limit]);
|
||||
|
||||
useAlertRulesStripInfo({
|
||||
shownCount: paginatedRules.length,
|
||||
totalCount: filteredRules.length,
|
||||
});
|
||||
|
||||
const columnsWithActions = useMemo(() => {
|
||||
if (!action) {
|
||||
return columns;
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useBottomStrip } from 'container/BottomStrip/useBottomStrip';
|
||||
import { type StripItem, StripItemKind } from 'container/BottomStrip/types';
|
||||
import { pluralize } from 'utils/pluralize';
|
||||
|
||||
interface UseAlertRulesStripInfoArgs {
|
||||
/** Rows on the current page, matching the table's own footer. */
|
||||
shownCount: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export function useAlertRulesStripInfo({
|
||||
shownCount,
|
||||
totalCount,
|
||||
}: UseAlertRulesStripInfoArgs): void {
|
||||
const items = useMemo<StripItem[]>(
|
||||
() => [
|
||||
{
|
||||
kind: StripItemKind.Text,
|
||||
text: `${shownCount} of ${pluralize(totalCount, 'rule')}`,
|
||||
},
|
||||
],
|
||||
[shownCount, totalCount],
|
||||
);
|
||||
|
||||
useBottomStrip(items);
|
||||
}
|
||||
@@ -155,39 +155,6 @@ describe('ResetPassword Component', () => {
|
||||
{ timeout: 200 },
|
||||
);
|
||||
});
|
||||
|
||||
it('clears password mismatch error when confirm password is emptied', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<ResetPassword version="1.0.0" />, undefined, {
|
||||
initialRoute: '/password-reset?token=reset-token-123',
|
||||
});
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText(/enter new password/i);
|
||||
const confirmPasswordInput = screen.getByPlaceholderText(
|
||||
/confirm your new password/i,
|
||||
);
|
||||
const submitButton = screen.getByRole('button', {
|
||||
name: /reset password/i,
|
||||
});
|
||||
|
||||
await user.type(passwordInput, 'password123');
|
||||
await user.type(confirmPasswordInput, 'password456');
|
||||
await user.tab();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/passwords don't match/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.clear(confirmPasswordInput);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText(/passwords don't match/i),
|
||||
).not.toBeInTheDocument();
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Successful Password Reset', () => {
|
||||
@@ -321,65 +288,6 @@ describe('ResetPassword Component', () => {
|
||||
expect(screen.queryByText(/invalid token/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('clears API error when the user edits the password', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.post(RESET_PASSWORD_ENDPOINT, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(400),
|
||||
ctx.json({
|
||||
error: {
|
||||
code: 'invalid_password',
|
||||
message: 'password must be at least 8 characters long',
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<ResetPassword version="1.0.0" />, undefined, {
|
||||
initialRoute: '/password-reset?token=reset-token-123',
|
||||
});
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText(/enter new password/i);
|
||||
const confirmPasswordInput = screen.getByPlaceholderText(
|
||||
/confirm your new password/i,
|
||||
);
|
||||
const submitButton = screen.getByRole('button', {
|
||||
name: /reset password/i,
|
||||
});
|
||||
|
||||
await user.type(passwordInput, 'weak');
|
||||
await user.type(confirmPasswordInput, 'weak');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/password must be at least 8 characters long/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.type(passwordInput, 'Er1!');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText(/password must be at least 8 characters long/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.type(confirmPasswordInput, 'Er1!');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loading States', () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useResetPassword } from 'api/generated/services/users';
|
||||
import AuthError from 'components/AuthError/AuthError';
|
||||
import AuthPageContainer from 'components/AuthPageContainer';
|
||||
import ROUTES from 'constants/routes';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import history from 'lib/history';
|
||||
import { ArrowRight, CircleAlert, KeyRound } from '@signozhq/icons';
|
||||
@@ -22,7 +23,10 @@ import './ResetPassword.styles.scss';
|
||||
type FormValues = { password: string; confirmPassword: string };
|
||||
|
||||
function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
|
||||
const [confirmPasswordTouched, setConfirmPasswordTouched] = useState(false);
|
||||
const [confirmPasswordError, setConfirmPasswordError] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const [isValidPassword, setIsValidPassword] = useState(false);
|
||||
const { t } = useTranslation(['common']);
|
||||
const { search } = useLocation();
|
||||
const params = new URLSearchParams(search);
|
||||
@@ -33,7 +37,6 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
|
||||
mutate: resetPassword,
|
||||
isLoading,
|
||||
error: mutationError,
|
||||
reset: resetMutation,
|
||||
} = useResetPassword();
|
||||
|
||||
const errorMessage = useMemo(
|
||||
@@ -42,30 +45,11 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
|
||||
);
|
||||
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const password = Form.useWatch('password', form);
|
||||
const confirmPassword = Form.useWatch('confirmPassword', form);
|
||||
|
||||
const isPasswordMismatch =
|
||||
Boolean(confirmPassword) && password !== confirmPassword;
|
||||
const showPasswordMismatchError = confirmPasswordTouched && isPasswordMismatch;
|
||||
const isValidPassword =
|
||||
Boolean(password?.trim()) &&
|
||||
Boolean(confirmPassword?.trim()) &&
|
||||
password === confirmPassword;
|
||||
|
||||
const handleValuesChange = (): void => {
|
||||
if (mutationError) {
|
||||
resetMutation();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (): void => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
const handleFormSubmit = (): void => {
|
||||
const { password } = form.getFieldsValue();
|
||||
|
||||
resetPassword(
|
||||
{ data: { password: form.getFieldValue('password'), token } },
|
||||
{ data: { password, token: token || '' } },
|
||||
{
|
||||
onSuccess: (): void => {
|
||||
notifications.success({
|
||||
@@ -79,6 +63,80 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
|
||||
);
|
||||
};
|
||||
|
||||
const validatePassword = (): boolean => {
|
||||
const { password, confirmPassword } = form.getFieldsValue();
|
||||
|
||||
if (
|
||||
password &&
|
||||
confirmPassword &&
|
||||
password.trim() &&
|
||||
confirmPassword.trim() &&
|
||||
password.length > 0 &&
|
||||
confirmPassword.length > 0
|
||||
) {
|
||||
return password === confirmPassword;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleValuesChange = useDebouncedFn((): void => {
|
||||
const { password, confirmPassword } = form.getFieldsValue();
|
||||
|
||||
if (!password || !confirmPassword) {
|
||||
setIsValidPassword(false);
|
||||
}
|
||||
|
||||
// Only clear error if passwords match while typing (but don't set error until blur)
|
||||
if (
|
||||
password &&
|
||||
confirmPassword &&
|
||||
password.trim() &&
|
||||
confirmPassword.trim()
|
||||
) {
|
||||
const isValid = validatePassword();
|
||||
setIsValidPassword(isValid);
|
||||
|
||||
// Only clear error if passwords match, don't set error on mismatch
|
||||
if (isValid) {
|
||||
setConfirmPasswordError(false);
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
|
||||
const handlePasswordBlur = (): void => {
|
||||
const { confirmPassword } = form.getFieldsValue();
|
||||
// Only validate if confirm password has a value
|
||||
if (confirmPassword && confirmPassword.trim()) {
|
||||
const isValid = validatePassword();
|
||||
setIsValidPassword(isValid);
|
||||
setConfirmPasswordError(!isValid);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmPasswordBlur = (): void => {
|
||||
const { password, confirmPassword } = form.getFieldsValue();
|
||||
if (
|
||||
password &&
|
||||
password.trim() &&
|
||||
confirmPassword &&
|
||||
confirmPassword.trim()
|
||||
) {
|
||||
const isValid = validatePassword();
|
||||
setIsValidPassword(isValid);
|
||||
setConfirmPasswordError(!isValid);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (): void => {
|
||||
const isValid = validatePassword();
|
||||
setIsValidPassword(isValid);
|
||||
|
||||
if (token) {
|
||||
handleFormSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthPageContainer>
|
||||
<div className="reset-password-card">
|
||||
@@ -100,7 +158,6 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
|
||||
<FormContainer
|
||||
form={form}
|
||||
onFinish={handleSubmit}
|
||||
onValuesChange={handleValuesChange}
|
||||
className="reset-password-form"
|
||||
>
|
||||
<div className="reset-password-form-container">
|
||||
@@ -114,6 +171,8 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
|
||||
>
|
||||
<AntdInput.Password
|
||||
tabIndex={0}
|
||||
onChange={handleValuesChange}
|
||||
onBlur={handlePasswordBlur}
|
||||
id="password"
|
||||
data-testid="password"
|
||||
placeholder="Enter new password"
|
||||
@@ -130,7 +189,8 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
|
||||
rules={[{ required: true, message: 'Please enter confirm password!' }]}
|
||||
>
|
||||
<AntdInput.Password
|
||||
onBlur={(): void => setConfirmPasswordTouched(true)}
|
||||
onChange={handleValuesChange}
|
||||
onBlur={handleConfirmPasswordBlur}
|
||||
id="confirmPassword"
|
||||
data-testid="confirmPassword"
|
||||
placeholder="Confirm your new password"
|
||||
@@ -141,7 +201,7 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showPasswordMismatchError && (
|
||||
{confirmPasswordError && (
|
||||
<Callout
|
||||
type="error"
|
||||
size="small"
|
||||
@@ -153,7 +213,7 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{errorMessage && !showPasswordMismatchError && (
|
||||
{errorMessage && !confirmPasswordError && (
|
||||
<AuthError error={errorMessage} />
|
||||
)}
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ import { getTotalRPS } from 'utils/services';
|
||||
|
||||
import { getColumns } from '../Columns/ServiceColumn';
|
||||
import { ServiceMetricsTableProps } from '../types';
|
||||
import { useServicesStripInfo } from '../useServicesStripInfo';
|
||||
import { getServiceListFromQuery } from '../utils';
|
||||
|
||||
function ServiceMetricTable({
|
||||
@@ -68,8 +67,6 @@ function ServiceMetricTable({
|
||||
[isLoading, queries, topLevelOperations],
|
||||
);
|
||||
|
||||
useServicesStripInfo(services.length);
|
||||
|
||||
const { search } = useLocation();
|
||||
const tableColumns = useMemo(() => getColumns(search, true), [search]);
|
||||
const [RPS, setRPS] = useState(0);
|
||||
|
||||
@@ -18,7 +18,6 @@ import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { Tags } from 'hooks/useResourceAttribute/types';
|
||||
|
||||
import SkipOnBoardingModal from '../SkipOnBoardModal';
|
||||
import { useServicesStripInfo } from '../useServicesStripInfo';
|
||||
import ServiceTraceTable from './ServiceTracesTable';
|
||||
|
||||
function ServiceTraces(): JSX.Element {
|
||||
@@ -43,8 +42,6 @@ function ServiceTraces(): JSX.Element {
|
||||
|
||||
const services = data || [];
|
||||
|
||||
useServicesStripInfo(services.length);
|
||||
|
||||
const [skipOnboarding, setSkipOnboarding] = useState(
|
||||
localStorageGet(SKIP_ONBOARDING) === 'true',
|
||||
);
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useBottomStripStore } from 'container/BottomStrip/store/useBottomStripStore';
|
||||
import { StripItemKind } from 'container/BottomStrip/types';
|
||||
|
||||
import { useServicesStripInfo } from '../useServicesStripInfo';
|
||||
|
||||
describe('useServicesStripInfo', () => {
|
||||
beforeEach(() => {
|
||||
useBottomStripStore.setState({ left: null, ownerId: null });
|
||||
});
|
||||
|
||||
it('shows how many services are listed', () => {
|
||||
renderHook(() => useServicesStripInfo(18));
|
||||
|
||||
expect(useBottomStripStore.getState().left).toMatchObject([
|
||||
{ kind: StripItemKind.Text, text: '18 services' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('says service, not services, when there is one', () => {
|
||||
renderHook(() => useServicesStripInfo(1));
|
||||
|
||||
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
|
||||
text: '1 service',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows zero when there are none', () => {
|
||||
renderHook(() => useServicesStripInfo(0));
|
||||
|
||||
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
|
||||
text: '0 services',
|
||||
});
|
||||
});
|
||||
|
||||
it('clears the strip when the page unmounts', () => {
|
||||
const { unmount } = renderHook(() => useServicesStripInfo(18));
|
||||
|
||||
unmount();
|
||||
|
||||
expect(useBottomStripStore.getState().left).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useBottomStrip } from 'container/BottomStrip/useBottomStrip';
|
||||
import { type StripItem, StripItemKind } from 'container/BottomStrip/types';
|
||||
import { pluralize } from 'utils/pluralize';
|
||||
|
||||
export function useServicesStripInfo(count: number): void {
|
||||
const items = useMemo<StripItem[]>(
|
||||
() => [{ kind: StripItemKind.Text, text: pluralize(count, 'service') }],
|
||||
[count],
|
||||
);
|
||||
|
||||
useBottomStrip(items);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { encode } from 'js-base64';
|
||||
import type { Tags } from 'hooks/useResourceAttribute/types';
|
||||
import { fireEvent, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import {
|
||||
choiceControl,
|
||||
@@ -31,6 +32,9 @@ import {
|
||||
type ServiceHealth,
|
||||
} from './__story_mockdata__/serviceMap';
|
||||
|
||||
/** The keys are only fetched once the select opens, past the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
const GRAPH = 'Service map · graph';
|
||||
const FILTERS = 'Service map · filters';
|
||||
|
||||
@@ -129,3 +133,80 @@ export const serviceMapMocks = defineStoryMocks({
|
||||
],
|
||||
config: (values) => ({ route: serviceMapRoute(values.filters) }),
|
||||
});
|
||||
|
||||
/** Opens the select under a test id; it closes again after every pick. */
|
||||
export const openSelect = async (
|
||||
canvasElement: HTMLElement,
|
||||
testId: string,
|
||||
): Promise<void> => {
|
||||
const select = await within(canvasElement).findByTestId(
|
||||
testId,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.click(within(select).getByRole('combobox'));
|
||||
};
|
||||
|
||||
export const OPEN_DROPDOWN =
|
||||
'.ant-select-dropdown:not(.ant-select-dropdown-hidden)';
|
||||
|
||||
/** antd keeps a hidden copy of each label for screen readers; the title skips it. */
|
||||
const visibleOption = (title: string): HTMLElement | null =>
|
||||
document.querySelector<HTMLElement>(
|
||||
`${OPEN_DROPDOWN} .ant-select-item-option[title="${title}"]`,
|
||||
);
|
||||
|
||||
/**
|
||||
* Picks the option titled `title` in the open dropdown. `userEvent.click`
|
||||
* moves focus off the select on the way, which closes it before the option
|
||||
* takes the click.
|
||||
*/
|
||||
export const pickOption = async (title: string): Promise<void> => {
|
||||
const option = await waitFor(() => {
|
||||
const match = visibleOption(title);
|
||||
|
||||
if (!match) {
|
||||
throw new Error(`option "${title}" not found`);
|
||||
}
|
||||
|
||||
return match;
|
||||
}, untilLoaded);
|
||||
|
||||
await fireEvent.click(option);
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens the attribute filter on its next step. Each single-choice pick closes
|
||||
* the dropdown and swaps the select for the next step's, and a click that lands
|
||||
* before the swap opens nothing, so it opens again until `title` shows.
|
||||
*/
|
||||
export const openAttributeFilterOn = (
|
||||
canvasElement: HTMLElement,
|
||||
title: string,
|
||||
): Promise<void> =>
|
||||
waitFor(
|
||||
async () => {
|
||||
if (visibleOption(title)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!document.querySelector(OPEN_DROPDOWN)) {
|
||||
await openSelect(canvasElement, 'resource-attributes-filter');
|
||||
}
|
||||
|
||||
throw new Error(`option "${title}" not shown`);
|
||||
},
|
||||
{ ...untilLoaded, interval: 500 },
|
||||
);
|
||||
|
||||
/** Stages `k8s.cluster.name IN` and leaves the filter open on its values. */
|
||||
export const stageClusterIn = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<void> => {
|
||||
await openAttributeFilterOn(canvasElement, 'k8s.cluster.name');
|
||||
await pickOption('k8s.cluster.name');
|
||||
await openAttributeFilterOn(canvasElement, 'IN');
|
||||
await pickOption('IN');
|
||||
await openAttributeFilterOn(canvasElement, 'staging-eu');
|
||||
};
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { serviceMapMocks } from './ServiceMap.stories.mocks';
|
||||
import {
|
||||
OPEN_DROPDOWN,
|
||||
openAttributeFilterOn,
|
||||
openSelect,
|
||||
pickOption,
|
||||
serviceMapMocks,
|
||||
stageClusterIn,
|
||||
} from './ServiceMap.stories.mocks';
|
||||
|
||||
import ServiceMapContainer from '../index';
|
||||
|
||||
@@ -97,3 +104,49 @@ export const FilterAttributes: Story = {
|
||||
await screen.findByText('k8s.cluster.name', undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/** A key staged as a chip, the filter open again on how to match it. */
|
||||
export const FilterOperators: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openAttributeFilterOn(canvasElement, 'k8s.cluster.name');
|
||||
await pickOption('k8s.cluster.name');
|
||||
await openAttributeFilterOn(canvasElement, 'Not IN');
|
||||
},
|
||||
};
|
||||
|
||||
/** A key and `IN` staged, the filter open on the values the key holds. */
|
||||
export const FilterValues: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await stageClusterIn(canvasElement);
|
||||
},
|
||||
};
|
||||
|
||||
/** Two values ticked before the filter is left, which is what applies it. */
|
||||
export const FilterValuesSelected: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await stageClusterIn(canvasElement);
|
||||
await pickOption('prod-us-east');
|
||||
await pickOption('prod-eu-west');
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(
|
||||
document.querySelectorAll('.ant-select-item-option-selected'),
|
||||
).toHaveLength(2),
|
||||
untilLoaded,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** The environment selector open on the environments the calls came from. */
|
||||
export const EnvironmentOptions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openSelect(canvasElement, 'resource-environment-filter');
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(
|
||||
document.querySelector(`${OPEN_DROPDOWN} .ant-select-item-option`),
|
||||
).not.toBeNull(),
|
||||
untilLoaded,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -11,8 +11,6 @@ import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantS
|
||||
import { VariantContext } from 'container/AIAssistant/VariantContext';
|
||||
import Noz from 'components/Noz/Noz';
|
||||
|
||||
import { useAIAssistantStripInfo } from './useAIAssistantStripInfo';
|
||||
|
||||
import styles from './AIAssistantPage.module.scss';
|
||||
import ConversationsList from 'container/AIAssistant/components/ConversationsList';
|
||||
|
||||
@@ -43,8 +41,6 @@ export default function AIAssistantPage(): JSX.Element {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useAIAssistantStripInfo();
|
||||
|
||||
const conversations = useAIAssistantStore((s) => s.conversations);
|
||||
const activeConversationId = useAIAssistantStore(
|
||||
(s) => s.activeConversationId,
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import { useBottomStripStore } from 'container/BottomStrip/store/useBottomStripStore';
|
||||
import { StripItemKind } from 'container/BottomStrip/types';
|
||||
|
||||
import { useAIAssistantStripInfo } from '../useAIAssistantStripInfo';
|
||||
|
||||
function seed(conversations: Record<string, unknown>): void {
|
||||
useAIAssistantStore.setState({ conversations } as never);
|
||||
}
|
||||
|
||||
describe('useAIAssistantStripInfo', () => {
|
||||
beforeEach(() => {
|
||||
useBottomStripStore.setState({ left: null, ownerId: null });
|
||||
});
|
||||
|
||||
it('counts only the conversations that are not archived', () => {
|
||||
seed({
|
||||
a: { id: 'a' },
|
||||
b: { id: 'b' },
|
||||
c: { id: 'c', archived: true },
|
||||
});
|
||||
|
||||
renderHook(() => useAIAssistantStripInfo());
|
||||
|
||||
expect(useBottomStripStore.getState().left).toMatchObject([
|
||||
{ kind: StripItemKind.Text, text: '2 conversations' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('says one conversation, not 1 conversations', () => {
|
||||
seed({ a: { id: 'a' } });
|
||||
|
||||
renderHook(() => useAIAssistantStripInfo());
|
||||
|
||||
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
|
||||
text: '1 conversation',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows zero when there are none', () => {
|
||||
seed({});
|
||||
|
||||
renderHook(() => useAIAssistantStripInfo());
|
||||
|
||||
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
|
||||
text: '0 conversations',
|
||||
});
|
||||
});
|
||||
|
||||
it('clears the strip when the page unmounts', () => {
|
||||
seed({ a: { id: 'a' } });
|
||||
|
||||
const { unmount } = renderHook(() => useAIAssistantStripInfo());
|
||||
|
||||
unmount();
|
||||
|
||||
expect(useBottomStripStore.getState().left).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { AI_API_PATH, setAIBackendUrl } from 'api/AIAPIInstance';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import { rest, type RequestHandler } from 'msw';
|
||||
import { expect, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import {
|
||||
choiceControl,
|
||||
@@ -270,3 +271,40 @@ export const longActionTooltipHandlers: RequestHandler[] = onBothBases(
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/** The recognizer `.storybook/preview-head.html` installs in place of the browser's. */
|
||||
interface StorySpeechRecognition {
|
||||
listening: { hear: (transcript: string) => void } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks the composer's mic and waits for the recording to start. The composer
|
||||
* stays disabled until the thread has loaded.
|
||||
*/
|
||||
export const startVoiceInput = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
const mic = await waitFor(
|
||||
async () => {
|
||||
const button = canvas.getByRole('button', { name: /start voice input/i });
|
||||
|
||||
// Depending on its version, Button disables through `disabled` or `aria-disabled`.
|
||||
await expect(button).toBeEnabled();
|
||||
await expect(button).not.toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
return button;
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
|
||||
await userEvent.click(mic);
|
||||
await canvas.findByRole('status', { name: /recording voice input/i });
|
||||
};
|
||||
|
||||
/** Speaks into the open recording: the words so far, still listening. */
|
||||
export const speak = (transcript: string): void => {
|
||||
(
|
||||
window as unknown as { webkitSpeechRecognition: StorySpeechRecognition }
|
||||
).webkitSpeechRecognition.listening?.hear(transcript);
|
||||
};
|
||||
|
||||
@@ -10,6 +10,8 @@ import AIAssistantPage from '../AIAssistantPage';
|
||||
import {
|
||||
aiAssistantMocks,
|
||||
longActionTooltipHandlers,
|
||||
speak,
|
||||
startVoiceInput,
|
||||
} from './AIAssistantPage.stories.mocks';
|
||||
import type { ThreadPart } from './__story_mockdata__/aiAssistant';
|
||||
|
||||
@@ -47,11 +49,15 @@ type Story = StoryObj<AIAssistantArgs>;
|
||||
/** The thread list resolves before the thread does, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
/** How long what a click opened has to stay on screen to count as open. */
|
||||
const HOLD_MS = 1_000;
|
||||
|
||||
/**
|
||||
* Click something, and keep clicking until what it opens is on screen. The
|
||||
* message list remounts its items while it measures a freshly loaded thread, so
|
||||
* a single click can land on a row that is about to be replaced, taking the
|
||||
* state it just set with it.
|
||||
* state it just set with it. The replacement can land after that state has
|
||||
* rendered, so it has to still be there `HOLD_MS` later.
|
||||
*/
|
||||
const clickUntil = async (
|
||||
find: () => Promise<HTMLElement>,
|
||||
@@ -60,6 +66,10 @@ const clickUntil = async (
|
||||
await waitFor(async () => {
|
||||
await userEvent.click(await find());
|
||||
await screen.findByText(opens, undefined, { timeout: 1_000 });
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, HOLD_MS);
|
||||
});
|
||||
screen.getByText(opens);
|
||||
}, untilLoaded);
|
||||
};
|
||||
|
||||
@@ -173,6 +183,18 @@ export const ApprovalDiff: Story = {
|
||||
play: openApprovalDiff,
|
||||
};
|
||||
|
||||
/**
|
||||
* Dictating a question: the mic listening, the words heard so far in the
|
||||
* composer, and the controls to discard them or stop and send.
|
||||
*/
|
||||
export const VoiceRecording: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await startVoiceInput(canvasElement);
|
||||
speak('why did checkout p99 jump after the 14:00 deploy');
|
||||
await within(canvasElement).findByDisplayValue(/checkout p99 jump/);
|
||||
},
|
||||
};
|
||||
|
||||
/** The comment box a thumbs down opens, which a thumbs up does not. */
|
||||
export const NegativeFeedback: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
@@ -245,9 +267,11 @@ export const TooltipsInApprovalDiff: Story = {
|
||||
};
|
||||
|
||||
/**
|
||||
* The conversation count in the bottom strip, in place of the build version.
|
||||
* Archived threads are left out of it.
|
||||
* The recording controls' tooltips, held open: discard, stop and send, and the
|
||||
* send button beside them, which waits for words.
|
||||
*/
|
||||
export const BottomStrip: Story = {
|
||||
args: { bottomStrip: true },
|
||||
export const TooltipsInVoiceRecording: Story = {
|
||||
args: { tooltipsOpen: true, contents: BRIEF },
|
||||
play: async ({ canvasElement }): Promise<void> =>
|
||||
startVoiceInput(canvasElement),
|
||||
};
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import { useBottomStrip } from 'container/BottomStrip/useBottomStrip';
|
||||
import { type StripItem, StripItemKind } from 'container/BottomStrip/types';
|
||||
import { pluralize } from 'utils/pluralize';
|
||||
|
||||
export function useAIAssistantStripInfo(): void {
|
||||
const conversations = useAIAssistantStore((state) => state.conversations);
|
||||
|
||||
const count = Object.values(conversations).filter(
|
||||
(conversation) => !conversation.archived,
|
||||
).length;
|
||||
|
||||
const items = useMemo<StripItem[]>(
|
||||
() => [{ kind: StripItemKind.Text, text: pluralize(count, 'conversation') }],
|
||||
[count],
|
||||
);
|
||||
|
||||
useBottomStrip(items);
|
||||
}
|
||||
@@ -133,19 +133,3 @@ export const ColumnPicker: Story = {
|
||||
export const Tooltips: Story = {
|
||||
args: { tooltipsOpen: true },
|
||||
};
|
||||
|
||||
/**
|
||||
* The rule count in the bottom strip, in place of the build version: the rows on
|
||||
* the page against the total, the same pair the table's own footer prints.
|
||||
*/
|
||||
export const BottomStrip: Story = {
|
||||
args: { bottomStrip: true },
|
||||
};
|
||||
|
||||
/** The same count on a second page, where the two numbers come apart. */
|
||||
export const BottomStripPaginated: Story = {
|
||||
args: { bottomStrip: true, rules: RULE_MAX },
|
||||
parameters: {
|
||||
signoz: { route: '/alerts?tab=AlertRules&page=2&limit=10' },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -79,30 +79,61 @@ export const NewDowntime: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
const clickFirstRowAction = async (
|
||||
canvasElement: HTMLElement,
|
||||
action: 'edit' | 'delete',
|
||||
): Promise<void> => {
|
||||
const icon = (
|
||||
await within(canvasElement).findByText(
|
||||
FIRST_DOWNTIME_NAME,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
)
|
||||
)
|
||||
.closest('.header-content')
|
||||
// The row action holds edit then delete, neither of them labelled.
|
||||
?.querySelectorAll('.action-btn svg')[action === 'edit' ? 0 : 1];
|
||||
|
||||
if (!icon) {
|
||||
throw new Error(`Downtime ${action} action did not render`);
|
||||
}
|
||||
|
||||
await userEvent.click(icon);
|
||||
};
|
||||
|
||||
/** The deletion confirmation opened from the first schedule's real row action. */
|
||||
export const DeleteDowntimeConfirm: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const action = (
|
||||
await within(canvasElement).findByText(
|
||||
FIRST_DOWNTIME_NAME,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
)
|
||||
)
|
||||
.closest('.header-content')
|
||||
// The row action holds edit then delete, neither of them labelled.
|
||||
?.querySelectorAll('.action-btn svg')[1];
|
||||
|
||||
if (!action) {
|
||||
throw new Error('Downtime delete action did not render');
|
||||
}
|
||||
|
||||
await userEvent.click(action);
|
||||
await clickFirstRowAction(canvasElement, 'delete');
|
||||
// The modal titles itself and its confirm button the same.
|
||||
await screen.findByRole('button', { name: 'Delete Schedule' });
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The first schedule opened for editing: the specific rules it silences sit
|
||||
* above the rule picker, each with its own remove button.
|
||||
*/
|
||||
export const EditDowntime: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await clickFirstRowAction(canvasElement, 'edit');
|
||||
await screen.findByText(/edit planned downtime/i);
|
||||
await screen.findByText(/^Postgres connections/);
|
||||
},
|
||||
};
|
||||
|
||||
/** The new-downtime form set to repeat weekly, which adds the days and duration. */
|
||||
export const NewDowntimeRecurring: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await NewDowntime.play?.({ canvasElement } as never);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('combobox', { name: 'Repeats every' }),
|
||||
);
|
||||
await userEvent.click(await screen.findByText('Weekly'));
|
||||
await screen.findByText('Duration');
|
||||
},
|
||||
};
|
||||
|
||||
/** A client-side search with no matching downtime schedule. */
|
||||
export const SearchNoResults: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
|
||||
@@ -6,9 +6,19 @@
|
||||
import { rest } from 'msw';
|
||||
import set from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { countControl, toggleControl } from '@/storybook/controls/controls';
|
||||
import {
|
||||
choiceControl,
|
||||
countControl,
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import {
|
||||
RESPONSE_STATES,
|
||||
type ResponseState,
|
||||
respondWith,
|
||||
} from '@/storybook/runtime/responseState';
|
||||
import { fieldValuesResponse } from '@/storybook/msw/__story_mockdata__/fields';
|
||||
|
||||
import {
|
||||
@@ -23,6 +33,9 @@ import {
|
||||
type ListErrorsBody,
|
||||
} from './__story_mockdata__/exceptions';
|
||||
|
||||
/** The page fetches before it renders a row, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
const LIST = 'Exceptions · list';
|
||||
const FILTERS = 'Exceptions · filters';
|
||||
|
||||
@@ -42,6 +55,13 @@ export const exceptionsMocks = defineStoryMocks({
|
||||
value: 6,
|
||||
max: EXCEPTION_QUICK_FILTER_CAP,
|
||||
}),
|
||||
filterKeys: choiceControl<ResponseState>('Filter keys', {
|
||||
group: FILTERS,
|
||||
description:
|
||||
'How `/autocomplete/attribute_keys` answers when the resource filter opens, apart from the page-wide Data control.',
|
||||
options: RESPONSE_STATES,
|
||||
value: 'loaded',
|
||||
}),
|
||||
filterPanel: toggleControl('Quick filters panel', {
|
||||
group: FILTERS,
|
||||
description:
|
||||
@@ -92,7 +112,7 @@ export const exceptionsMocks = defineStoryMocks({
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v3/autocomplete/attribute_keys',
|
||||
response.json((req) =>
|
||||
respondWith(values.filterKeys, (req) =>
|
||||
exceptionAttributeKeysResponse(req.url.searchParams.get('searchText')),
|
||||
),
|
||||
),
|
||||
@@ -111,3 +131,61 @@ export const exceptionsMocks = defineStoryMocks({
|
||||
set(LOCALSTORAGE.SHOW_EXCEPTIONS_QUICK_FILTERS, String(values.filterPanel));
|
||||
},
|
||||
});
|
||||
|
||||
export const openResourceFilter = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<HTMLElement> => {
|
||||
const filter = await within(canvasElement).findByTestId(
|
||||
'qb-search-select',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.click(within(filter).getByRole('combobox'));
|
||||
|
||||
return filter;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clicks the visible row whose label is `text`. The dropdown renders in the
|
||||
* body, a key row carries its type beside the label, and antd keeps a hidden
|
||||
* copy of each label for screen readers that takes no clicks.
|
||||
*
|
||||
* The key list renders twice after the filter opens, since a second key
|
||||
* request empties it until it answers. The click happens in the same task as
|
||||
* the lookup: `fireEvent` from `storybook/test` dispatches a tick later, which
|
||||
* can land on a row already removed and never reach React.
|
||||
*/
|
||||
export const pickSuggestion = async (text: string): Promise<void> => {
|
||||
await waitFor(() => {
|
||||
const match = Array.from(
|
||||
document.querySelectorAll<HTMLElement>(
|
||||
'.query-builder-search.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option',
|
||||
),
|
||||
).find((option) =>
|
||||
Array.from(option.querySelectorAll('*')).some(
|
||||
(node) => node.children.length === 0 && node.textContent === text,
|
||||
),
|
||||
);
|
||||
|
||||
if (!match) {
|
||||
throw new Error(`suggestion "${text}" not found`);
|
||||
}
|
||||
|
||||
// `userEvent.click` moves focus off the search input on the way, which closes
|
||||
// the dropdown before the row takes the click.
|
||||
match.click();
|
||||
}, untilLoaded);
|
||||
};
|
||||
|
||||
export const commitFilter = async (
|
||||
key: string,
|
||||
operator: string,
|
||||
value: string,
|
||||
): Promise<void> => {
|
||||
await pickSuggestion(key);
|
||||
await screen.findByText('Operator for', { exact: false }, untilLoaded);
|
||||
await pickSuggestion(operator);
|
||||
await screen.findByText('Value(s) for', { exact: false }, untilLoaded);
|
||||
await pickSuggestion(value);
|
||||
};
|
||||
|
||||
@@ -5,7 +5,12 @@ import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { exceptionsMocks } from './AllErrors.stories.mocks';
|
||||
import {
|
||||
commitFilter,
|
||||
exceptionsMocks,
|
||||
openResourceFilter,
|
||||
pickSuggestion,
|
||||
} from './AllErrors.stories.mocks';
|
||||
import AllErrors from '../index';
|
||||
|
||||
type AllErrorsArgs = PageStoryArgs<typeof exceptionsMocks>;
|
||||
@@ -126,10 +131,107 @@ export const QuickFiltersSettingsWithBanner: Story = {
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
/**
|
||||
* The exception count in the bottom strip, in place of the build version: the
|
||||
* rows on the page against the total the count query returns.
|
||||
*/
|
||||
export const BottomStrip: Story = {
|
||||
args: { bottomStrip: true },
|
||||
/** The resource filter opened: every key the exceptions can be narrowed by. */
|
||||
export const FilterKeySuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openResourceFilter(canvasElement);
|
||||
await screen.findByText('Suggested Filters', undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/** The key list grown past its first rows with the Show all shortcut. */
|
||||
export const FilterAllKeys: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openResourceFilter(canvasElement);
|
||||
await screen.findByText('Show all filter items', undefined, untilLoaded);
|
||||
await userEvent.keyboard('{Control>}/{/Control}');
|
||||
await screen.findByText('cloud.region', undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/** A partial key: the typed text as a free search, then the keys that match. */
|
||||
export const FilterPartialKey: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const filter = await openResourceFilter(canvasElement);
|
||||
|
||||
await userEvent.type(within(filter).getByRole('combobox'), 'serv');
|
||||
await screen.findByText('service.namespace', undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/** A key picked: the operators the exceptions page allows for it. */
|
||||
export const FilterOperatorSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openResourceFilter(canvasElement);
|
||||
await pickSuggestion('service.name');
|
||||
await screen.findByText('Operator for', { exact: false }, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/** A key and an operator picked: the values the key holds. */
|
||||
export const FilterValueSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openResourceFilter(canvasElement);
|
||||
await pickSuggestion('service.name');
|
||||
await pickSuggestion('=');
|
||||
await screen.findByText('Value(s) for', { exact: false }, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/** Two conditions committed as chips, with the dropdown closed again. */
|
||||
export const FilterChips: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openResourceFilter(canvasElement);
|
||||
await commitFilter('service.name', '=', 'checkout');
|
||||
await commitFilter('deployment.environment', '!=', 'staging');
|
||||
await userEvent.click(canvasElement.ownerDocument.body);
|
||||
await within(canvasElement).findByText(
|
||||
'deployment.environment != staging',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A committed chip clicked to change it: its text goes back into the input,
|
||||
* with the dropdown shut until the input is typed into.
|
||||
*/
|
||||
export const FilterEditChip: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openResourceFilter(canvasElement);
|
||||
await commitFilter('service.name', '=', 'checkout');
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByText(
|
||||
'service.name = checkout',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
// The select remounts whenever its chips change, so it is looked up again.
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(
|
||||
within(within(canvasElement).getByTestId('qb-search-select')).getByRole(
|
||||
'combobox',
|
||||
),
|
||||
).toHaveValue('service.name = checkout'),
|
||||
untilLoaded,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** The key list while its request is still in flight. */
|
||||
export const FilterKeysLoading: Story = {
|
||||
args: { filterKeys: 'loading' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openResourceFilter(canvasElement);
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(
|
||||
document.querySelector('.query-builder-search .ant-spin'),
|
||||
).not.toBeNull(),
|
||||
untilLoaded,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -21,7 +21,8 @@ const pageStory = storyMocks(panelEditorMocks, { layout: 'app' });
|
||||
* Route: `/dashboard/:dashboardId/panel/:panelId`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Dashboards/Panel Editor',
|
||||
title: 'Pages/Dashboards/Panel Editor/Overview',
|
||||
tags: ['authz'],
|
||||
// The page is wrapped in `withAuthZPage`, which types its props as an index
|
||||
// signature; the story's args are what the controls resolve to.
|
||||
component: PanelEditorPage as ComponentType<PanelEditorArgs>,
|
||||
@@ -49,6 +50,36 @@ export const NewPanel: Story = {
|
||||
args: { panel: 'new' },
|
||||
};
|
||||
|
||||
/** A number panel: one value, formatted, with comparison thresholds. */
|
||||
export const NumberPanel: Story = {
|
||||
args: { panel: 'p99-latency' },
|
||||
};
|
||||
|
||||
/** A bar chart, whose Visualization section offers stacking. */
|
||||
export const BarChartPanel: Story = {
|
||||
args: { panel: 'errors-by-status' },
|
||||
};
|
||||
|
||||
/** A pie chart, with a legend per slice and no axes. */
|
||||
export const PieChartPanel: Story = {
|
||||
args: { panel: 'traffic-share' },
|
||||
};
|
||||
|
||||
/** A new area chart: the time series options with the area filled. */
|
||||
export const AreaChartPanel: Story = {
|
||||
args: { panel: 'new', newPanelKind: 'area-chart' },
|
||||
};
|
||||
|
||||
/** A new histogram, where the display options are its buckets. */
|
||||
export const HistogramPanel: Story = {
|
||||
args: { panel: 'new', newPanelKind: 'histogram' },
|
||||
};
|
||||
|
||||
/** A new text panel: a markdown body in place of the query builder. */
|
||||
export const TextPanel: Story = {
|
||||
args: { panel: 'new', newPanelKind: 'text' },
|
||||
};
|
||||
|
||||
/** A list panel, where the config pane is the column editor. */
|
||||
export const ListPanel: Story = {
|
||||
args: { panel: 'recent-logs' },
|
||||
|
||||
@@ -22,11 +22,13 @@ import type { PanelKind } from '../../../DashboardContainer/Panels/types/panelKi
|
||||
export const NEW_PANEL_KINDS = [
|
||||
'time-series',
|
||||
'bar-chart',
|
||||
'area-chart',
|
||||
'number',
|
||||
'pie-chart',
|
||||
'table',
|
||||
'histogram',
|
||||
'list',
|
||||
'text',
|
||||
] as const;
|
||||
|
||||
export type NewPanelKind = (typeof NEW_PANEL_KINDS)[number];
|
||||
@@ -34,11 +36,13 @@ export type NewPanelKind = (typeof NEW_PANEL_KINDS)[number];
|
||||
const KIND_BY_OPTION: Record<NewPanelKind, PanelKind> = {
|
||||
'time-series': 'signoz/TimeSeriesPanel',
|
||||
'bar-chart': 'signoz/BarChartPanel',
|
||||
'area-chart': 'signoz/AreaChartPanel',
|
||||
number: 'signoz/NumberPanel',
|
||||
'pie-chart': 'signoz/PieChartPanel',
|
||||
table: 'signoz/TablePanel',
|
||||
histogram: 'signoz/HistogramPanel',
|
||||
list: 'signoz/ListPanel',
|
||||
text: 'signoz/TextPanel',
|
||||
};
|
||||
|
||||
export const newPanelKindOf = (option: NewPanelKind): PanelKind =>
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Route } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { expect, screen, userEvent, waitFor } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { panelEditorMocks } from '../PanelEditorPage.stories.mocks';
|
||||
import {
|
||||
expandSection,
|
||||
openConfigSelect,
|
||||
pickSegment,
|
||||
} from './panelEditorPlay';
|
||||
|
||||
import PanelEditorPage from '../../PanelEditorPage';
|
||||
|
||||
type PanelEditorArgs = PageStoryArgs<typeof panelEditorMocks>;
|
||||
|
||||
const pageStory = storyMocks(panelEditorMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* The panel editor's display options, one section open at a time: what each
|
||||
* panel kind lets a user tune about how its data is drawn, and the preview
|
||||
* following along.
|
||||
*
|
||||
* Route: `/dashboard/:dashboardId/panel/:panelId`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Dashboards/Panel Editor/Chart Options',
|
||||
tags: ['authz', 'play'],
|
||||
component: PanelEditorPage as ComponentType<PanelEditorArgs>,
|
||||
render: (): JSX.Element => (
|
||||
<Route path={ROUTES.DASHBOARD_PANEL_EDITOR} component={PanelEditorPage} />
|
||||
),
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<PanelEditorArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<PanelEditorArgs>;
|
||||
|
||||
/** The panel type picker, open on the kinds this query can be drawn as. */
|
||||
export const PanelTypeOpen: Story = {
|
||||
play: async () => {
|
||||
await openConfigSelect('panel-editor-v2-type-switcher');
|
||||
await screen.findByRole('listbox');
|
||||
},
|
||||
};
|
||||
|
||||
/** The time range the panel reads, open on its choices. */
|
||||
export const TimePreferenceOpen: Story = {
|
||||
play: async () => {
|
||||
await openConfigSelect('panel-editor-v2-time-preference');
|
||||
await screen.findByRole('listbox');
|
||||
},
|
||||
};
|
||||
|
||||
/** Formatting & Units: the unit and decimals the values are shown with. */
|
||||
export const Formatting: Story = {
|
||||
play: async () => {
|
||||
await expandSection('Formatting & Units');
|
||||
await screen.findByTestId('panel-editor-v2-decimals');
|
||||
},
|
||||
};
|
||||
|
||||
/** The unit picker, open on its categories. */
|
||||
export const UnitPickerOpen: Story = {
|
||||
play: async (context) => {
|
||||
await Formatting.play?.(context);
|
||||
await openConfigSelect('panel-editor-v2-unit');
|
||||
await screen.findByRole('listbox');
|
||||
},
|
||||
};
|
||||
|
||||
/** Axes: soft bounds for the y axis and its scale. */
|
||||
export const Axes: Story = {
|
||||
play: async () => {
|
||||
await expandSection('Axes');
|
||||
await screen.findByTestId('panel-editor-v2-log-scale');
|
||||
},
|
||||
};
|
||||
|
||||
/** The y axis on a log scale, which the preview redraws with. */
|
||||
export const AxesLogScale: Story = {
|
||||
play: async (context) => {
|
||||
await Axes.play?.(context);
|
||||
await pickSegment('panel-editor-v2-log-scale', 'Log');
|
||||
},
|
||||
};
|
||||
|
||||
/** Legend: its position, and a color per series the query returned. */
|
||||
export const Legend: Story = {
|
||||
play: async () => {
|
||||
await expandSection('Legend');
|
||||
await screen.findByTestId('panel-editor-v2-legend-position');
|
||||
},
|
||||
};
|
||||
|
||||
/** The legend moved to the right of the preview. */
|
||||
export const LegendRight: Story = {
|
||||
play: async (context) => {
|
||||
await Legend.play?.(context);
|
||||
await pickSegment('panel-editor-v2-legend-position', 'Right');
|
||||
},
|
||||
};
|
||||
|
||||
/** Chart appearance: line style, interpolation, fill and points. */
|
||||
export const ChartAppearance: Story = {
|
||||
play: async () => {
|
||||
await expandSection('Chart appearance');
|
||||
await screen.findByTestId('panel-editor-v2-line-style');
|
||||
},
|
||||
};
|
||||
|
||||
/** Dashed lines under a gradient fill, with the points drawn. */
|
||||
export const ChartAppearanceDashedGradient: Story = {
|
||||
play: async (context) => {
|
||||
await ChartAppearance.play?.(context);
|
||||
await pickSegment('panel-editor-v2-line-style', 'Dashed');
|
||||
await pickSegment('panel-editor-v2-fill-mode', 'Gradient');
|
||||
const points = screen.getByTestId('panel-editor-v2-show-points');
|
||||
|
||||
await userEvent.click(points);
|
||||
await waitFor(() => expect(points).toHaveAttribute('aria-checked', 'true'));
|
||||
},
|
||||
};
|
||||
|
||||
/** An area chart's appearance, which adds the fill opacity. */
|
||||
export const AreaChartAppearance: Story = {
|
||||
args: { panel: 'new', newPanelKind: 'area-chart' },
|
||||
play: async () => {
|
||||
await expandSection('Chart appearance');
|
||||
await screen.findByTestId('panel-editor-v2-fill-opacity');
|
||||
},
|
||||
};
|
||||
|
||||
/** A bar chart with its series stacked on each other. */
|
||||
export const BarStacked: Story = {
|
||||
args: { panel: 'errors-by-status' },
|
||||
play: async () => {
|
||||
await expandSection('Visualization');
|
||||
const stack = await screen.findByTestId(
|
||||
'panel-editor-v2-stacked-bar-chart',
|
||||
{},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
|
||||
await userEvent.click(stack);
|
||||
await waitFor(() => expect(stack).toHaveAttribute('aria-checked', 'true'));
|
||||
},
|
||||
};
|
||||
|
||||
/** A table's formatting: a unit per value column. */
|
||||
export const TableColumnUnits: Story = {
|
||||
args: { panel: 'top-endpoints' },
|
||||
play: async () => {
|
||||
await expandSection('Formatting & Units');
|
||||
await screen.findByTestId('panel-editor-v2-decimals');
|
||||
},
|
||||
};
|
||||
|
||||
/** A pie chart's legend, colored per slice. */
|
||||
export const PieLegend: Story = {
|
||||
args: { panel: 'traffic-share' },
|
||||
play: async () => {
|
||||
await expandSection('Legend');
|
||||
await screen.findByTestId('panel-editor-v2-legend-position');
|
||||
},
|
||||
};
|
||||
|
||||
/** A histogram's buckets: how many, how wide, and whether queries merge. */
|
||||
export const HistogramBuckets: Story = {
|
||||
args: { panel: 'new', newPanelKind: 'histogram' },
|
||||
play: async () => {
|
||||
await expandSection('Histogram / Buckets');
|
||||
await screen.findByTestId('panel-editor-v2-bucket-count');
|
||||
},
|
||||
};
|
||||
|
||||
/** A text panel's appearance: alignment and background. */
|
||||
export const TextAppearance: Story = {
|
||||
args: { panel: 'new', newPanelKind: 'text' },
|
||||
play: async () => {
|
||||
await expandSection('Panel appearance');
|
||||
await screen.findByTestId('text-layout-horizontal-align');
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Route } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { expect, screen, userEvent, waitFor } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { panelEditorMocks } from '../PanelEditorPage.stories.mocks';
|
||||
import { addThreshold, openLinkDialog } from './panelEditorPlay';
|
||||
|
||||
import PanelEditorPage from '../../PanelEditorPage';
|
||||
|
||||
type PanelEditorArgs = PageStoryArgs<typeof panelEditorMocks>;
|
||||
|
||||
const pageStory = storyMocks(panelEditorMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* The panel editor's Thresholds and Context Links sections. A threshold row
|
||||
* takes the shape of its panel kind: a labelled line on a chart, a comparison
|
||||
* on a number, a column rule on a table. A context link opens a dialog whose
|
||||
* URL can carry the dashboard's variables.
|
||||
*
|
||||
* Route: `/dashboard/:dashboardId/panel/:panelId`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Dashboards/Panel Editor/Thresholds and Links',
|
||||
tags: ['authz', 'play'],
|
||||
component: PanelEditorPage as ComponentType<PanelEditorArgs>,
|
||||
render: (): JSX.Element => (
|
||||
<Route path={ROUTES.DASHBOARD_PANEL_EDITOR} component={PanelEditorPage} />
|
||||
),
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<PanelEditorArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<PanelEditorArgs>;
|
||||
|
||||
/** A time series threshold: a value, a color and a label drawn on the chart. */
|
||||
export const Threshold: Story = {
|
||||
play: async () => {
|
||||
await addThreshold();
|
||||
await screen.findByTestId('threshold-value-0');
|
||||
},
|
||||
};
|
||||
|
||||
/** The threshold's color picker, open on the palette presets. */
|
||||
export const ThresholdColorOpen: Story = {
|
||||
play: async (context) => {
|
||||
await Threshold.play?.(context);
|
||||
await userEvent.click(screen.getByTestId('threshold-color-0'));
|
||||
await screen.findByText('Defaults');
|
||||
},
|
||||
};
|
||||
|
||||
/** A number panel's threshold, which compares the value and recolors it. */
|
||||
export const NumberThreshold: Story = {
|
||||
args: { panel: 'p99-latency' },
|
||||
play: async () => {
|
||||
await addThreshold('panel-editor-v2-add-comparison-threshold');
|
||||
await screen.findByTestId('comparison-threshold-operator-0');
|
||||
},
|
||||
};
|
||||
|
||||
/** A table's threshold, which picks the column it colors. */
|
||||
export const TableThreshold: Story = {
|
||||
args: { panel: 'top-endpoints' },
|
||||
play: async () => {
|
||||
await addThreshold('panel-editor-v2-add-table-threshold');
|
||||
await screen.findByTestId('table-threshold-column-0');
|
||||
},
|
||||
};
|
||||
|
||||
/** The add-link dialog, empty, with Save waiting for a URL. */
|
||||
export const ContextLinkDialog: Story = {
|
||||
play: async () => {
|
||||
await openLinkDialog();
|
||||
},
|
||||
};
|
||||
|
||||
/** The URL field focused, offering the variables it can interpolate. */
|
||||
export const ContextLinkVariables: Story = {
|
||||
play: async () => {
|
||||
await openLinkDialog();
|
||||
await userEvent.click(screen.getByTestId('context-link-url'));
|
||||
await screen.findByTestId('context-link-variable-service');
|
||||
},
|
||||
};
|
||||
|
||||
/** A URL with query parameters, split into rows that can be edited one by one. */
|
||||
export const ContextLinkParams: Story = {
|
||||
play: async () => {
|
||||
await openLinkDialog();
|
||||
await userEvent.type(
|
||||
screen.getByTestId('context-link-label'),
|
||||
'Open service traces',
|
||||
);
|
||||
await userEvent.click(screen.getByTestId('context-link-url'));
|
||||
await userEvent.paste(
|
||||
'/traces-explorer?service={{service}}&env={{environment}}',
|
||||
);
|
||||
// Leaving the URL closes the variables list that covers the rows.
|
||||
await userEvent.click(screen.getByTestId('context-link-label'));
|
||||
await screen.findByTestId('context-link-param-key-1');
|
||||
},
|
||||
};
|
||||
|
||||
/** A URL the link cannot open, which the field flags and Save refuses. */
|
||||
export const ContextLinkInvalidUrl: Story = {
|
||||
play: async () => {
|
||||
await openLinkDialog();
|
||||
await userEvent.click(screen.getByTestId('context-link-url'));
|
||||
await userEvent.paste('traces for {{service}}');
|
||||
await screen.findByTestId('context-link-url-error');
|
||||
},
|
||||
};
|
||||
|
||||
/** A saved link, listed in the section with its edit and remove actions. */
|
||||
export const ContextLinkSaved: Story = {
|
||||
play: async (context) => {
|
||||
await ContextLinkParams.play?.(context);
|
||||
await userEvent.click(screen.getByTestId('context-link-save'));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId('context-link-dialog')).toBeNull(),
|
||||
);
|
||||
await screen.findByTestId('context-link-item-0');
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
// The editor resolves its panel from the dashboard document before it renders.
|
||||
const PAGE_LOAD = { timeout: 10000 };
|
||||
|
||||
/**
|
||||
* Opens a display-options section and scrolls it to the top of the pane, where
|
||||
* a screenshot of the viewport can see it. Returns the section to query in.
|
||||
*/
|
||||
export const expandSection = async (title: string): Promise<HTMLElement> => {
|
||||
// The toggle's test id is the title, lowercased, with whitespace as dashes.
|
||||
const toggle = await screen.findByTestId(
|
||||
`config-section-${title.toLowerCase().replace(/\s+/g, '-')}`,
|
||||
{},
|
||||
PAGE_LOAD,
|
||||
);
|
||||
|
||||
if (toggle.getAttribute('aria-expanded') !== 'true') {
|
||||
await userEvent.click(toggle);
|
||||
}
|
||||
toggle.scrollIntoView({ block: 'start' });
|
||||
|
||||
return toggle.closest('section') ?? document.body;
|
||||
};
|
||||
|
||||
/** Picks one option of a segmented control by the label it shows. */
|
||||
export const pickSegment = async (
|
||||
testId: string,
|
||||
label: string,
|
||||
): Promise<void> => {
|
||||
await userEvent.click(within(screen.getByTestId(testId)).getByText(label));
|
||||
};
|
||||
|
||||
/**
|
||||
* The pane's pickers are antd Selects: the element carrying the test id does
|
||||
* nothing on click, the combobox inside it is what opens the list.
|
||||
*/
|
||||
export const openConfigSelect = async (testId: string): Promise<void> => {
|
||||
const select = await screen.findByTestId(testId, {}, PAGE_LOAD);
|
||||
|
||||
await userEvent.click(within(select).getByRole('combobox'));
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens Thresholds and adds one row through the section's own button, whose
|
||||
* test id depends on the panel kind.
|
||||
*/
|
||||
export const addThreshold = async (
|
||||
testId = 'panel-editor-v2-add-threshold',
|
||||
): Promise<void> => {
|
||||
const section = await expandSection('Thresholds');
|
||||
|
||||
await userEvent.click(within(section).getByTestId(testId));
|
||||
};
|
||||
|
||||
export const openLinkDialog = async (): Promise<void> => {
|
||||
const section = await expandSection('Context Links');
|
||||
|
||||
// The header's quick add carries the same name, so the body button goes by id.
|
||||
await userEvent.click(within(section).getByTestId('panel-editor-v2-add-link'));
|
||||
await screen.findByTestId('context-link-dialog');
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import { rest } from 'msw';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import type { GetPublicDashboard200 } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useDashboardPreferencesStore } from 'hooks/dashboard/useDashboardPreference';
|
||||
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
|
||||
|
||||
import {
|
||||
@@ -15,11 +16,15 @@ import {
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { fieldValuesResponse } from '@/storybook/msw/__story_mockdata__/fields';
|
||||
import {
|
||||
fieldKeysResponse,
|
||||
fieldValuesResponse,
|
||||
} from '@/storybook/msw/__story_mockdata__/fields';
|
||||
import { queryRangeV5ScalarResponse } from '@/storybook/msw/__story_mockdata__/queryRange';
|
||||
|
||||
import {
|
||||
currentDashboardDocument,
|
||||
cyclicVariablesDashboardResponse,
|
||||
patchDashboardDocument,
|
||||
PANEL_IDS,
|
||||
seedDashboardDocument,
|
||||
@@ -29,8 +34,9 @@ import {
|
||||
type VariableKind,
|
||||
} from './__story_mockdata__/dashboard';
|
||||
import {
|
||||
attributeValues,
|
||||
emptyPanelResponse,
|
||||
NAMESPACE_VALUES,
|
||||
VARIABLE_ATTRIBUTES,
|
||||
panelResponse,
|
||||
serviceVariableValues,
|
||||
} from './__story_mockdata__/panelData';
|
||||
@@ -65,6 +71,14 @@ const NOT_FOUND = {
|
||||
|
||||
const ok = { status: 'success', data: null };
|
||||
|
||||
// The editor rewrites any `Syntax error:` into its own hint, so the ClickHouse
|
||||
// wording only shows through the variable bar.
|
||||
const VARIABLE_QUERY_ERROR = {
|
||||
status: 'error',
|
||||
error:
|
||||
"Code: 62. DB::Exception: Syntax error: failed at position 58 ('$environment'). (SYNTAX_ERROR)",
|
||||
};
|
||||
|
||||
const publicMeta = (): GetPublicDashboard200 => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
@@ -137,6 +151,12 @@ export const dashboardMocks = defineStoryMocks({
|
||||
value: 4,
|
||||
max: 12,
|
||||
}),
|
||||
variableQueryFails: toggleControl('Variable query fails', {
|
||||
group: DATA,
|
||||
description:
|
||||
"The query-backed `service` variable answers with a ClickHouse syntax error, which the variable bar and the editor's Test Run both report.",
|
||||
value: false,
|
||||
}),
|
||||
noData: toggleControl('Panels return nothing', {
|
||||
group: DATA,
|
||||
description:
|
||||
@@ -228,18 +248,35 @@ export const dashboardMocks = defineStoryMocks({
|
||||
// The variable bar resolves before the panels and stays laid out while
|
||||
// they load or fail, so its two endpoints answer on their own rather
|
||||
// than through the Data control.
|
||||
rest.post('http://localhost/api/v2/variables/query', (_req, res, ctx) =>
|
||||
values.variableQueryFails
|
||||
? rest.post('http://localhost/api/v2/variables/query', (_req, res, ctx) =>
|
||||
res(ctx.status(400), ctx.json(VARIABLE_QUERY_ERROR)),
|
||||
)
|
||||
: rest.post('http://localhost/api/v2/variables/query', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
variableValues: serviceVariableValues(values.variableValues),
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: { variableValues: serviceVariableValues(values.variableValues) },
|
||||
}),
|
||||
ctx.json(
|
||||
fieldValuesResponse(attributeValues(req.url.searchParams.get('name'))),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get('http://localhost/api/v1/fields/values', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(fieldValuesResponse(NAMESPACE_VALUES))),
|
||||
// The dynamic variable editor lists the attributes a variable can read.
|
||||
rest.get(
|
||||
'http://localhost/api/v1/fields/keys',
|
||||
response.json(() => fieldKeysResponse(VARIABLE_ATTRIBUTES)),
|
||||
),
|
||||
|
||||
// The header reads the public link on every load, so it answers even while
|
||||
@@ -273,6 +310,9 @@ export const dashboardMocks = defineStoryMocks({
|
||||
variables: values.variables,
|
||||
locked: values.locked,
|
||||
});
|
||||
// The sync mode persists per dashboard, so one story's pick would open the
|
||||
// next one on it.
|
||||
useDashboardPreferencesStore.setState({ preferences: {} });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -331,3 +371,9 @@ export const metricsListHandler = rest.get(
|
||||
(_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: { metrics: [] } })),
|
||||
);
|
||||
|
||||
export const cyclicVariablesDashboardHandler = rest.get(
|
||||
'http://localhost/api/v2/dashboards/:id',
|
||||
(_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(cyclicVariablesDashboardResponse())),
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ComponentType } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Route } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
@@ -30,8 +30,8 @@ const pageStory = storyMocks(dashboardMocks, { layout: 'app' });
|
||||
* Route: `/dashboard/:dashboardId`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Dashboards/Detail',
|
||||
tags: ['role-gated', 'play'],
|
||||
title: 'Pages/Dashboards/Detail/Overview',
|
||||
tags: ['authz', 'play'],
|
||||
// The page is wrapped in `withAuthZPage`, which types its props as an index
|
||||
// signature; the story's args are what the controls resolve to.
|
||||
component: DashboardPage as ComponentType<DashboardArgs>,
|
||||
@@ -159,53 +159,6 @@ export const TooltipsInJsonDrawer: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The Overview tab of dashboard settings, where Cross-Panel Sync explains what
|
||||
* syncing the crosshair does and links out to the docs.
|
||||
*/
|
||||
export const TooltipsInSettings: Story = {
|
||||
args: { tooltipsOpen: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.click(
|
||||
await canvas.findByTestId('show-drawer', {}, { timeout: 10000 }),
|
||||
);
|
||||
await screen.findByText('Sync Mode');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The Variables tab of dashboard settings, where a dynamic variable's Apply to
|
||||
* all says whether it is already a filter on every panel. The row keeps its
|
||||
* actions invisible until it is hovered, which the story does first.
|
||||
*/
|
||||
export const TooltipsInVariableSettings: Story = {
|
||||
args: { tooltipsOpen: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.click(
|
||||
await canvas.findByTestId('show-drawer', {}, { timeout: 10000 }),
|
||||
);
|
||||
await userEvent.click(await screen.findByRole('tab', { name: 'Variables' }));
|
||||
|
||||
// The tooltip trigger's Slot merge drops the button's own test id.
|
||||
await userEvent.hover(
|
||||
await screen.findByRole(
|
||||
'button',
|
||||
{ name: 'Apply to all' },
|
||||
{ timeout: 10000 },
|
||||
),
|
||||
);
|
||||
await screen.findByText(
|
||||
'Add this variable as a filter to every panel',
|
||||
undefined,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A panel expanded into view mode, whose header carries the full panel name its
|
||||
* title truncates, over the dashboard's own tooltips behind the dialog.
|
||||
@@ -285,6 +238,18 @@ export const SectionActionsMenu: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** The panel menu's move-to-section submenu, open on the sections it can go to. */
|
||||
export const PanelMoveToSectionSubmenu: Story = {
|
||||
play: async (context) => {
|
||||
await PanelActionsMenu.play?.(context);
|
||||
await userEvent.hover(await screen.findByText('Move to section'));
|
||||
// The submenu lists the sections the panel is not already in.
|
||||
await waitFor(() => expect(screen.getAllByRole('menu')).toHaveLength(2), {
|
||||
timeout: 10000,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A dashboard id nobody has, which is what a deleted or mistyped link opens on.
|
||||
*
|
||||
|
||||
@@ -336,6 +336,28 @@ const VARIABLES: Record<VariableKind, DashboardtypesVariableDTO> = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* `service` already filters on `$environment`, so an `environment` that reads
|
||||
* `$service` back closes the loop. An imported dashboard can arrive like this;
|
||||
* the editor only refuses it on save.
|
||||
*/
|
||||
const CYCLIC_ENVIRONMENT: DashboardtypesVariableDTO = {
|
||||
kind: ListVariableKind.ListVariable,
|
||||
spec: {
|
||||
name: 'environment',
|
||||
display: { name: 'environment' },
|
||||
allowMultiple: false,
|
||||
allowAllValue: false,
|
||||
plugin: {
|
||||
kind: QueryVariableKind['signoz/QueryVariable'],
|
||||
spec: {
|
||||
queryValue:
|
||||
'SELECT DISTINCT env FROM signoz_metrics WHERE service_name IN $service',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export interface PanelQueryShape {
|
||||
requestType: string;
|
||||
metricName?: string;
|
||||
@@ -451,3 +473,18 @@ export const patchDashboardDocument = (
|
||||
|
||||
return envelope(document);
|
||||
};
|
||||
|
||||
export const cyclicVariablesDashboardResponse = (): GetDashboardV2200 => {
|
||||
const response = dashboardResponse({
|
||||
panels: PANEL_IDS.length,
|
||||
sectioned: true,
|
||||
variables: VARIABLE_KINDS,
|
||||
locked: false,
|
||||
});
|
||||
|
||||
response.data.spec.variables = response.data.spec.variables.map((variable) =>
|
||||
variable === VARIABLES.custom ? CYCLIC_ENVIRONMENT : variable,
|
||||
);
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
@@ -173,3 +173,20 @@ export const NAMESPACE_VALUES = [
|
||||
'payments-prod',
|
||||
'platform-prod',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* The attributes the dynamic variable editor offers a variable to read, with
|
||||
* the values each one resolves to.
|
||||
*/
|
||||
const ATTRIBUTE_VALUES: Record<string, readonly string[]> = {
|
||||
'k8s.namespace.name': NAMESPACE_VALUES,
|
||||
'k8s.cluster.name': ['prod-us-east-1', 'prod-eu-west-1'],
|
||||
'service.name': SERVICES,
|
||||
'deployment.environment': ['production', 'staging', 'development'],
|
||||
'host.name': ['ip-10-0-1-12', 'ip-10-0-1-37', 'ip-10-0-2-4'],
|
||||
};
|
||||
|
||||
export const VARIABLE_ATTRIBUTES = Object.keys(ATTRIBUTE_VALUES);
|
||||
|
||||
export const attributeValues = (name: string | null): readonly string[] =>
|
||||
ATTRIBUTE_VALUES[name ?? ''] ?? NAMESPACE_VALUES;
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Route } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { dashboardMocks } from '../DashboardPage.stories.mocks';
|
||||
import {
|
||||
expectDisabled,
|
||||
menuItem,
|
||||
openActionsMenu,
|
||||
toolbarButton,
|
||||
} from './authzPlay';
|
||||
|
||||
import DashboardPage from '../../DashboardPage';
|
||||
|
||||
type DashboardArgs = PageStoryArgs<typeof dashboardMocks>;
|
||||
|
||||
const pageStory = storyMocks(dashboardMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* The dashboard with one permission missing at a time. Every story here is an
|
||||
* admin denied exactly what its name says. `read` decides whether the page
|
||||
* renders at all; `update` is every edit, the settings drawer included, so
|
||||
* Configure is as far as a user without it gets.
|
||||
*
|
||||
* Route: `/dashboard/:dashboardId`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Dashboards/Detail/Authz',
|
||||
tags: ['authz', 'play'],
|
||||
component: DashboardPage as ComponentType<DashboardArgs>,
|
||||
render: (): JSX.Element => (
|
||||
<Route path={ROUTES.DASHBOARD} component={DashboardPage} />
|
||||
),
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<DashboardArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<DashboardArgs>;
|
||||
|
||||
const READ = 'read:dashboard';
|
||||
const UPDATE = 'update:dashboard';
|
||||
const DELETE = 'delete:dashboard';
|
||||
const CREATE = 'create:dashboard';
|
||||
|
||||
/** The page refuses to render: `read` is the one check it mounts on. */
|
||||
export const NoRead: Story = {
|
||||
args: { revoked: [READ] },
|
||||
play: async () => {
|
||||
await screen.findByText('Uh-oh! You are not authorized', undefined, {
|
||||
timeout: 10000,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The panels still render, and Configure and New Panel stay in the toolbar,
|
||||
* disabled.
|
||||
*/
|
||||
export const NoUpdate: Story = {
|
||||
args: { revoked: [UPDATE] },
|
||||
play: async ({ canvasElement }) => {
|
||||
await expectDisabled(await toolbarButton(canvasElement, 'Configure'));
|
||||
await expectDisabled(await toolbarButton(canvasElement, 'New Panel'));
|
||||
},
|
||||
};
|
||||
|
||||
/** The same toolbar with its tooltips open, each naming what is missing. */
|
||||
export const TooltipsWithoutUpdate: Story = {
|
||||
args: { revoked: [UPDATE], tooltipsOpen: true },
|
||||
play: NoUpdate.play,
|
||||
};
|
||||
|
||||
/**
|
||||
* A locked dashboard the user could not have edited anyway: the tooltips name
|
||||
* the permission rather than the lock.
|
||||
*/
|
||||
export const TooltipsWithoutUpdateWhenLocked: Story = {
|
||||
args: { revoked: [UPDATE], locked: true, tooltipsOpen: true },
|
||||
play: NoUpdate.play,
|
||||
};
|
||||
|
||||
/** The Actions menu: rename, lock and new section locked; clone and delete open. */
|
||||
export const NoUpdateActionsMenu: Story = {
|
||||
args: { revoked: [UPDATE] },
|
||||
play: async ({ canvasElement }) => {
|
||||
await openActionsMenu(canvasElement);
|
||||
await expectDisabled(menuItem('Rename'));
|
||||
await expectDisabled(menuItem('Lock dashboard'));
|
||||
await expectDisabled(menuItem('New section'));
|
||||
await expectDisabled(menuItem('Clone dashboard'), false);
|
||||
await expectDisabled(menuItem('Delete dashboard'), false);
|
||||
},
|
||||
};
|
||||
|
||||
/** A panel's menu: view and download stay, every edit is locked. */
|
||||
export const NoUpdatePanelMenu: Story = {
|
||||
args: { revoked: [UPDATE] },
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'panel-actions-p99-latency',
|
||||
{},
|
||||
{ timeout: 10000 },
|
||||
),
|
||||
);
|
||||
const menu = await screen.findByRole('menu');
|
||||
|
||||
await expectDisabled(
|
||||
within(menu).getByRole('menuitem', { name: /Edit panel/ }),
|
||||
);
|
||||
await expectDisabled(
|
||||
within(menu).getByRole('menuitem', { name: /Delete panel/ }),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** The JSON editor opens read-only, and Apply says why it is locked. */
|
||||
export const NoUpdateJsonEditor: Story = {
|
||||
args: { revoked: [UPDATE], tooltipsOpen: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'edit-json',
|
||||
{},
|
||||
{ timeout: 10000 },
|
||||
),
|
||||
);
|
||||
await expectDisabled(await screen.findByTestId('json-editor-apply'));
|
||||
},
|
||||
};
|
||||
|
||||
/** A blank dashboard whose add-panel prompts are locked. */
|
||||
export const NoUpdateEmpty: Story = {
|
||||
args: { revoked: [UPDATE], panels: 0, sectioned: false },
|
||||
play: async () => {
|
||||
await expectDisabled(
|
||||
await screen.findByTestId('add-panel', {}, { timeout: 10000 }),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** Delete dashboard locked in the Actions menu; every edit still works. */
|
||||
export const NoDelete: Story = {
|
||||
args: { revoked: [DELETE] },
|
||||
play: async ({ canvasElement }) => {
|
||||
await openActionsMenu(canvasElement);
|
||||
await expectDisabled(menuItem('Delete dashboard'));
|
||||
await expectDisabled(menuItem('Rename'), false);
|
||||
},
|
||||
};
|
||||
|
||||
/** Cloning is a create, so the copy is locked while the original stays editable. */
|
||||
export const NoCreate: Story = {
|
||||
args: { revoked: [CREATE] },
|
||||
play: async ({ canvasElement }) => {
|
||||
await openActionsMenu(canvasElement);
|
||||
await expectDisabled(menuItem('Clone dashboard'));
|
||||
await expectDisabled(menuItem('Rename'), false);
|
||||
},
|
||||
};
|
||||
|
||||
/** Everything but reading: the Actions menu with only full screen left. */
|
||||
export const ReadOnly: Story = {
|
||||
args: { revoked: [UPDATE, DELETE, CREATE] },
|
||||
play: async ({ canvasElement }) => {
|
||||
await openActionsMenu(canvasElement);
|
||||
await expectDisabled(menuItem('Clone dashboard'));
|
||||
await expectDisabled(menuItem('Delete dashboard'));
|
||||
await expectDisabled(menuItem('Full screen'), false);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The page holds on its spinner until the permission check answers. The
|
||||
* spinner's `Loading dashboard...` tip never shows: antd only renders a tip
|
||||
* around nested content.
|
||||
*/
|
||||
export const CheckLoading: Story = {
|
||||
args: { authzState: 'loading' },
|
||||
play: async ({ canvasElement }) => {
|
||||
await waitFor(() =>
|
||||
expect(canvasElement.querySelector('.ant-spin')).not.toBeNull(),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The permission check failing rather than denying: the page renders, and
|
||||
* every edit stays locked as if `update` were denied.
|
||||
*/
|
||||
export const CheckFailed: Story = {
|
||||
args: { authzState: 'error' },
|
||||
// The mocked check intentionally fails; the resulting console error is the
|
||||
// point of the story, not a regression.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
await expectDisabled(await toolbarButton(canvasElement, 'Configure'));
|
||||
},
|
||||
};
|
||||
38
frontend/src/pages/DashboardPage/stories/authz/authzPlay.ts
Normal file
38
frontend/src/pages/DashboardPage/stories/authz/authzPlay.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
// The permission check resolves after the dashboard document loads.
|
||||
const PAGE_LOAD = { timeout: 10000 };
|
||||
|
||||
/**
|
||||
* A denied control stays on screen, disabled. Buttons say so through
|
||||
* `disabled` or `aria-disabled` depending on the Button, menu rows through
|
||||
* `data-disabled`.
|
||||
*/
|
||||
const isDisabled = (element: HTMLElement): boolean =>
|
||||
element.hasAttribute('disabled') ||
|
||||
element.getAttribute('aria-disabled') === 'true' ||
|
||||
element.hasAttribute('data-disabled');
|
||||
|
||||
export const expectDisabled = (
|
||||
element: HTMLElement,
|
||||
disabled = true,
|
||||
): Promise<void> =>
|
||||
waitFor(() => expect(isDisabled(element)).toBe(disabled), PAGE_LOAD);
|
||||
|
||||
export const toolbarButton = (
|
||||
canvasElement: HTMLElement,
|
||||
name: string,
|
||||
): Promise<HTMLElement> =>
|
||||
within(canvasElement).findByRole('button', { name }, PAGE_LOAD);
|
||||
|
||||
export const openActionsMenu = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<void> => {
|
||||
// The dropdown trigger's Slot merge drops the button's own test id.
|
||||
await userEvent.click(await toolbarButton(canvasElement, 'Actions'));
|
||||
await screen.findByText('Clone dashboard');
|
||||
};
|
||||
|
||||
// Not by test id: the Actions menu only carries them on some Dropdown versions.
|
||||
export const menuItem = (name: string): HTMLElement =>
|
||||
screen.getByRole('menuitem', { name });
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Route } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { expect, screen, userEvent, waitFor } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
cyclicVariablesDashboardHandler,
|
||||
dashboardMocks,
|
||||
} from '../DashboardPage.stories.mocks';
|
||||
import {
|
||||
findPreviewValue,
|
||||
openVariableEditor,
|
||||
openVariableSelect,
|
||||
} from './settingsPlay';
|
||||
|
||||
import DashboardPage from '../../DashboardPage';
|
||||
|
||||
type DashboardArgs = PageStoryArgs<typeof dashboardMocks>;
|
||||
|
||||
const pageStory = storyMocks(dashboardMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* The variable editor on each of the dashboard's variables, reached from a
|
||||
* row's edit action in the Variables tab: what every type saved, the values it
|
||||
* resolves to, and the query failures and dependency loops it reports.
|
||||
*
|
||||
* Route: `/dashboard/:dashboardId`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Dashboards/Detail/Edit Variable',
|
||||
tags: ['authz', 'play'],
|
||||
component: DashboardPage as ComponentType<DashboardArgs>,
|
||||
render: (): JSX.Element => (
|
||||
<Route path={ROUTES.DASHBOARD} component={DashboardPage} />
|
||||
),
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<DashboardArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<DashboardArgs>;
|
||||
|
||||
/**
|
||||
* `$environment`: a comma-separated list with one value by default. The preview
|
||||
* stays empty until the options are edited.
|
||||
*/
|
||||
export const Custom: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openVariableEditor(canvasElement, 'environment');
|
||||
await screen.findByDisplayValue('production,staging,development');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* `$service`: a ClickHouse query that reads `$environment`, run once on open so
|
||||
* the preview and default value have its values.
|
||||
*/
|
||||
export const Query: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openVariableEditor(canvasElement, 'service');
|
||||
await findPreviewValue('inventory');
|
||||
},
|
||||
};
|
||||
|
||||
/** `$service` with its query failing, which the preview reports in its place. */
|
||||
export const QueryFailed: Story = {
|
||||
args: { variableQueryFails: true },
|
||||
// The failed variable query is the state under test.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
await openVariableEditor(canvasElement, 'service');
|
||||
await screen.findByText(
|
||||
'Please make sure query is valid and dependent variables are selected',
|
||||
undefined,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** The sort picker, open over the previewed values it reorders. */
|
||||
export const SortOpen: Story = {
|
||||
play: async (context) => {
|
||||
await Query.play?.(context);
|
||||
await openVariableSelect('variable-sort-select');
|
||||
await screen.findByTitle('Alphabetical (descending)');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* `$namespace`: a dynamic variable reading `k8s.namespace.name` from metrics,
|
||||
* with the panels it could be applied to.
|
||||
*/
|
||||
export const Dynamic: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openVariableEditor(canvasElement, 'namespace');
|
||||
await findPreviewValue('payments-prod');
|
||||
},
|
||||
};
|
||||
|
||||
/** `$owner`: a Textbox variable with its default value. */
|
||||
export const Textbox: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openVariableEditor(canvasElement, 'owner');
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('variable-text-input')).toHaveValue(
|
||||
'platform-team',
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* `$environment` and `$service` reading each other, as an imported dashboard
|
||||
* can arrive: saving either one is refused and names the loop.
|
||||
*
|
||||
* The document is answered by the story, so the Panels, Sections, Variables and
|
||||
* Locked controls do not reach it.
|
||||
*/
|
||||
export const CircularDependency: Story = {
|
||||
parameters: { msw: { handlers: [cyclicVariablesDashboardHandler] } },
|
||||
play: async ({ canvasElement }) => {
|
||||
await openVariableEditor(canvasElement, 'service');
|
||||
await userEvent.click(screen.getByTestId('variable-save'));
|
||||
await screen.findByText(/circular dependency detected/);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,184 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Route } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { screen, userEvent } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { dashboardMocks } from '../DashboardPage.stories.mocks';
|
||||
import {
|
||||
findPreviewValue,
|
||||
openNewVariable,
|
||||
openVariableSelect,
|
||||
pickVariableType,
|
||||
typeVariableName,
|
||||
} from './settingsPlay';
|
||||
|
||||
import DashboardPage from '../../DashboardPage';
|
||||
|
||||
type DashboardArgs = PageStoryArgs<typeof dashboardMocks>;
|
||||
|
||||
const pageStory = storyMocks(dashboardMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* The variable editor on a new variable, reached from Add variable in the
|
||||
* Variables tab: each type's own fields, the pickers they open, and the name
|
||||
* and attribute checks that keep Save disabled.
|
||||
*
|
||||
* Route: `/dashboard/:dashboardId`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Dashboards/Detail/New Variable',
|
||||
tags: ['authz', 'play'],
|
||||
component: DashboardPage as ComponentType<DashboardArgs>,
|
||||
render: (): JSX.Element => (
|
||||
<Route path={ROUTES.DASHBOARD} component={DashboardPage} />
|
||||
),
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<DashboardArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<DashboardArgs>;
|
||||
|
||||
/** The editor as it opens, on the Dynamic type with no field picked. */
|
||||
export const Default: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openNewVariable(canvasElement);
|
||||
},
|
||||
};
|
||||
|
||||
/** The dynamic variable's field picker, open on the attributes it can read. */
|
||||
export const FieldPickerOpen: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openNewVariable(canvasElement);
|
||||
await openVariableSelect('variable-field-select');
|
||||
await screen.findByRole(
|
||||
'option',
|
||||
{ name: 'k8s.cluster.name' },
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** The telemetry source the field is read from, open. */
|
||||
export const SignalPickerOpen: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openNewVariable(canvasElement);
|
||||
await openVariableSelect('variable-signal-select');
|
||||
await screen.findByTitle('Traces');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A field picked: the name follows it until typed over, and the values it
|
||||
* resolves to fill the preview and the default value.
|
||||
*/
|
||||
export const DynamicFieldPicked: Story = {
|
||||
play: async (context) => {
|
||||
await FieldPickerOpen.play?.(context);
|
||||
await userEvent.click(
|
||||
screen.getByRole('option', { name: 'k8s.cluster.name' }),
|
||||
);
|
||||
await findPreviewValue('prod-us-east-1');
|
||||
},
|
||||
};
|
||||
|
||||
/** A field another dynamic variable already reads, which Save refuses. */
|
||||
export const AttributeTaken: Story = {
|
||||
play: async (context) => {
|
||||
await FieldPickerOpen.play?.(context);
|
||||
await userEvent.click(
|
||||
screen.getByRole('option', { name: 'k8s.namespace.name' }),
|
||||
);
|
||||
await screen.findByText('A variable with this attribute key already exists');
|
||||
},
|
||||
};
|
||||
|
||||
/** The panels the new filter can be added to, open. */
|
||||
export const ApplyToPanelsOpen: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openNewVariable(canvasElement);
|
||||
await openVariableSelect('variable-apply-panels');
|
||||
await screen.findByRole('listbox');
|
||||
},
|
||||
};
|
||||
|
||||
/** A name typed and cleared again. */
|
||||
export const NameRequired: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openNewVariable(canvasElement);
|
||||
await userEvent.type(screen.getByTestId('variable-name'), 'x{Backspace}');
|
||||
await screen.findByText('Variable name is required');
|
||||
},
|
||||
};
|
||||
|
||||
/** A name another variable on the dashboard has. */
|
||||
export const NameTaken: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openNewVariable(canvasElement);
|
||||
await typeVariableName('environment');
|
||||
await screen.findByText('Variable name already exists');
|
||||
},
|
||||
};
|
||||
|
||||
/** A name with a space in it, which a `$name` reference could not spell. */
|
||||
export const NameWithSpaces: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openNewVariable(canvasElement);
|
||||
await typeVariableName('build id');
|
||||
await screen.findByText('Variable name cannot contain whitespaces');
|
||||
},
|
||||
};
|
||||
|
||||
/** A Textbox variable: a free-text value with an optional default. */
|
||||
export const Textbox: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openNewVariable(canvasElement);
|
||||
await typeVariableName('region');
|
||||
await pickVariableType('textbox');
|
||||
await userEvent.type(
|
||||
await screen.findByTestId('variable-text-input'),
|
||||
'us-east-1',
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A Custom variable with its options typed in, previewed as they are parsed,
|
||||
* and multiple values on, which adds the ALL option toggle.
|
||||
*/
|
||||
export const Custom: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openNewVariable(canvasElement);
|
||||
await typeVariableName('region');
|
||||
await pickVariableType('custom');
|
||||
await userEvent.type(
|
||||
await screen.findByTestId('variable-custom-input'),
|
||||
'us-east-1,eu-west-1,ap-south-1',
|
||||
);
|
||||
await userEvent.click(screen.getByTestId('variable-multi-switch'));
|
||||
await screen.findByText('Include an option for ALL values');
|
||||
},
|
||||
};
|
||||
|
||||
/** The Custom variable's default value picker, open on the parsed options. */
|
||||
export const CustomDefaultOpen: Story = {
|
||||
play: async (context) => {
|
||||
await Custom.play?.(context);
|
||||
await openVariableSelect('variable-default-select');
|
||||
await screen.findByRole('option', { name: 'eu-west-1' });
|
||||
},
|
||||
};
|
||||
|
||||
/** A Query variable before its query is written: Test Run waits for one. */
|
||||
export const Query: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openNewVariable(canvasElement);
|
||||
await pickVariableType('query');
|
||||
await screen.findByText('Test Run Query');
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Route } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { dashboardMocks } from '../DashboardPage.stories.mocks';
|
||||
import { openSettings } from './settingsPlay';
|
||||
|
||||
import DashboardPage from '../../DashboardPage';
|
||||
|
||||
type DashboardArgs = PageStoryArgs<typeof dashboardMocks>;
|
||||
|
||||
const pageStory = storyMocks(dashboardMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* The dashboard's settings drawer on its Overview and Publish tabs: the name,
|
||||
* icon, description and tags, the cross-panel sync mode, and the public link.
|
||||
* The Publish tab only exists on cloud and enterprise licenses.
|
||||
*
|
||||
* Route: `/dashboard/:dashboardId`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Dashboards/Detail/Settings',
|
||||
tags: ['authz', 'play'],
|
||||
component: DashboardPage as ComponentType<DashboardArgs>,
|
||||
render: (): JSX.Element => (
|
||||
<Route path={ROUTES.DASHBOARD} component={DashboardPage} />
|
||||
),
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<DashboardArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<DashboardArgs>;
|
||||
|
||||
/** The drawer as Configure opens it, on the Overview tab. */
|
||||
export const Overview: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openSettings(canvasElement);
|
||||
await screen.findByText('Sync Mode');
|
||||
},
|
||||
};
|
||||
|
||||
/** Two fields edited, so the footer counts them and offers to save or discard. */
|
||||
export const OverviewUnsavedChanges: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openSettings(canvasElement);
|
||||
await userEvent.type(await screen.findByTestId('dashboard-name'), ' (v2)');
|
||||
await userEvent.type(
|
||||
screen.getByTestId('dashboard-desc'),
|
||||
' Owned by the platform team.',
|
||||
);
|
||||
await screen.findByText('2 unsaved changes');
|
||||
},
|
||||
};
|
||||
|
||||
/** The icon picker beside the name, open on the system icons. */
|
||||
export const OverviewIconPicker: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const panel = await openSettings(canvasElement);
|
||||
|
||||
await userEvent.click(within(panel).getAllByRole('combobox')[0]);
|
||||
await screen.findByRole('listbox');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Sync set to Tooltip, which adds the choice between every series and only the
|
||||
* ones that share the group-by.
|
||||
*/
|
||||
export const OverviewTooltipSync: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openSettings(canvasElement);
|
||||
await userEvent.click(await screen.findByRole('radio', { name: 'Tooltip' }));
|
||||
await screen.findByText('Synced Tooltip Series');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The Overview tab's own tooltip: Cross-Panel Sync explains what syncing does
|
||||
* and links out to the docs.
|
||||
*/
|
||||
export const TooltipsInOverview: Story = {
|
||||
args: { tooltipsOpen: true },
|
||||
play: Overview.play,
|
||||
};
|
||||
|
||||
/** The Publish tab of a dashboard with a public link. */
|
||||
export const Publish: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openSettings(canvasElement, 'Publish');
|
||||
await screen.findByText('Default time range');
|
||||
},
|
||||
};
|
||||
|
||||
/** The Publish tab with its default time range select open. */
|
||||
export const PublishTimeRangeOpen: Story = {
|
||||
play: async (context) => {
|
||||
await Publish.play?.(context);
|
||||
await userEvent.click(
|
||||
within(screen.getByRole('tabpanel')).getByRole('combobox'),
|
||||
);
|
||||
await screen.findByRole('listbox');
|
||||
},
|
||||
};
|
||||
|
||||
/** The Publish tab of a dashboard nobody published: no link, one Publish button. */
|
||||
export const PublishPrivate: Story = {
|
||||
args: { published: false },
|
||||
// The 404 an unpublished dashboard answers with is the state under test.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
await openSettings(canvasElement, 'Publish');
|
||||
await screen.findByText('This dashboard is private');
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Route } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { screen, userEvent } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { dashboardMocks } from '../DashboardPage.stories.mocks';
|
||||
import {
|
||||
clickRowAction,
|
||||
openSettings,
|
||||
openVariableEditor,
|
||||
typeVariableName,
|
||||
variableRow,
|
||||
} from './settingsPlay';
|
||||
|
||||
import DashboardPage from '../../DashboardPage';
|
||||
|
||||
type DashboardArgs = PageStoryArgs<typeof dashboardMocks>;
|
||||
|
||||
const pageStory = storyMocks(dashboardMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* The Variables tab of dashboard settings: the variables in the order the bar
|
||||
* shows them, and what deleting, renaming or applying one does to the panels
|
||||
* that read it. Every panel filters on `$environment`, and `$service` reads it
|
||||
* too; nothing reads `$namespace` or `$owner`.
|
||||
*
|
||||
* Route: `/dashboard/:dashboardId`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Dashboards/Detail/Variables',
|
||||
tags: ['authz', 'play'],
|
||||
component: DashboardPage as ComponentType<DashboardArgs>,
|
||||
render: (): JSX.Element => (
|
||||
<Route path={ROUTES.DASHBOARD} component={DashboardPage} />
|
||||
),
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<DashboardArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<DashboardArgs>;
|
||||
|
||||
/** One variable of each type, listed by name and description. */
|
||||
export const Default: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openSettings(canvasElement, 'Variables');
|
||||
await variableRow('owner');
|
||||
},
|
||||
};
|
||||
|
||||
/** A dashboard without variables, where the tab is its add-variable prompt. */
|
||||
export const Empty: Story = {
|
||||
args: { variables: [] },
|
||||
play: async ({ canvasElement }) => {
|
||||
await openSettings(canvasElement, 'Variables');
|
||||
await screen.findByText('No variables yet');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A dynamic variable's Apply to all says whether it is already a filter on
|
||||
* every panel. The row keeps its actions invisible until it is hovered, which
|
||||
* the story does first.
|
||||
*/
|
||||
export const TooltipsInVariables: Story = {
|
||||
args: { tooltipsOpen: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
await openSettings(canvasElement, 'Variables');
|
||||
|
||||
// The tooltip trigger's Slot merge drops the button's own test id.
|
||||
await userEvent.hover(
|
||||
await screen.findByRole(
|
||||
'button',
|
||||
{ name: 'Apply to all' },
|
||||
{ timeout: 10000 },
|
||||
),
|
||||
);
|
||||
await screen.findByText(
|
||||
'Add this variable as a filter to every panel',
|
||||
undefined,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** Deleting a variable nothing reads, which the row confirms inline. */
|
||||
export const DeleteConfirm: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openSettings(canvasElement, 'Variables');
|
||||
await clickRowAction('owner', 'delete');
|
||||
await screen.findByText('Delete?');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Deleting `$environment`, which the panels and `$service` read, so each usage
|
||||
* is listed for review before anything is removed.
|
||||
*/
|
||||
export const DeleteReferenced: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openSettings(canvasElement, 'Variables');
|
||||
await clickRowAction('environment', 'delete');
|
||||
await screen.findByText('Delete $environment');
|
||||
},
|
||||
};
|
||||
|
||||
/** Apply to all on `$namespace`: the filter it would add to every panel query. */
|
||||
export const ApplyToAll: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openSettings(canvasElement, 'Variables');
|
||||
await clickRowAction('namespace', 'apply-all');
|
||||
await screen.findByText('Apply $namespace to panels');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Renaming `$environment` to `$env`: saving the editor holds the rename until
|
||||
* the rewritten queries are reviewed.
|
||||
*/
|
||||
export const RenameReferenced: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openVariableEditor(canvasElement, 'environment');
|
||||
await typeVariableName('env');
|
||||
await userEvent.click(screen.getByTestId('variable-save'));
|
||||
await screen.findByText('Rename $environment');
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
// The dashboard document loads before the toolbar renders.
|
||||
const PAGE_LOAD = { timeout: 10000 };
|
||||
|
||||
export type SettingsTab = 'Overview' | 'Variables' | 'Publish';
|
||||
|
||||
export type VariableType = 'dynamic' | 'textbox' | 'custom' | 'query';
|
||||
|
||||
type RowAction = 'edit' | 'delete' | 'apply-all';
|
||||
|
||||
/** Opens the settings drawer from the toolbar and returns the tab's panel. */
|
||||
export const openSettings = async (
|
||||
canvasElement: HTMLElement,
|
||||
tab: SettingsTab = 'Overview',
|
||||
): Promise<HTMLElement> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId('show-drawer', {}, PAGE_LOAD),
|
||||
);
|
||||
|
||||
if (tab !== 'Overview') {
|
||||
await userEvent.click(await screen.findByRole('tab', { name: tab }));
|
||||
}
|
||||
|
||||
return screen.findByRole('tabpanel');
|
||||
};
|
||||
|
||||
export const variableRow = (name: string): Promise<HTMLElement> =>
|
||||
screen.findByTestId(`variable-row-${name}`, {}, PAGE_LOAD);
|
||||
|
||||
/** Row actions render once the edit permission resolves. */
|
||||
export const clickRowAction = async (
|
||||
name: string,
|
||||
action: RowAction,
|
||||
): Promise<void> => {
|
||||
const row = await variableRow(name);
|
||||
|
||||
await userEvent.click(
|
||||
action === 'apply-all'
|
||||
? // The tooltip trigger's Slot merge drops this button's test id.
|
||||
await within(row).findByRole('button', { name: 'Apply to all' }, PAGE_LOAD)
|
||||
: await within(row).findByTestId(
|
||||
`variable-${action}-${name}`,
|
||||
{},
|
||||
PAGE_LOAD,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
/** A new variable's editor, which opens on the Dynamic type. */
|
||||
export const openNewVariable = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<void> => {
|
||||
const panel = await openSettings(canvasElement, 'Variables');
|
||||
const add = await within(panel).findByTestId('add-variable', {}, PAGE_LOAD);
|
||||
|
||||
// Disabled until its permission check resolves: through `disabled` or
|
||||
// `aria-disabled`, depending on the Button.
|
||||
await waitFor(() => expect(add).toBeEnabled(), PAGE_LOAD);
|
||||
await waitFor(
|
||||
() => expect(add).not.toHaveAttribute('aria-disabled', 'true'),
|
||||
PAGE_LOAD,
|
||||
);
|
||||
await userEvent.click(add);
|
||||
await screen.findByText('Variable Type');
|
||||
};
|
||||
|
||||
export const openVariableEditor = async (
|
||||
canvasElement: HTMLElement,
|
||||
name: string,
|
||||
): Promise<void> => {
|
||||
await openSettings(canvasElement, 'Variables');
|
||||
await clickRowAction(name, 'edit');
|
||||
await screen.findByText('Variable Type');
|
||||
};
|
||||
|
||||
/**
|
||||
* A value in the editor's preview. The toolbar's selector for the same variable
|
||||
* resolves to its first value too, whenever its own query returns.
|
||||
*/
|
||||
export const findPreviewValue = async (value: string): Promise<HTMLElement> => {
|
||||
const label = await screen.findByText('Preview of Values');
|
||||
|
||||
return within(label.parentElement as HTMLElement).findByText(
|
||||
value,
|
||||
undefined,
|
||||
PAGE_LOAD,
|
||||
);
|
||||
};
|
||||
|
||||
export const pickVariableType = async (type: VariableType): Promise<void> => {
|
||||
await userEvent.click(await screen.findByTestId(`variable-type-${type}`));
|
||||
};
|
||||
|
||||
export const typeVariableName = async (name: string): Promise<void> => {
|
||||
const input = await screen.findByTestId('variable-name');
|
||||
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, name);
|
||||
};
|
||||
|
||||
/**
|
||||
* The editor's pickers are antd Selects: the element carrying the test id does
|
||||
* nothing on click, the combobox inside it is what opens the list.
|
||||
*/
|
||||
export const openVariableSelect = async (testId: string): Promise<void> => {
|
||||
const select = await screen.findByTestId(testId);
|
||||
|
||||
await userEvent.click(within(select).getByRole('combobox'));
|
||||
};
|
||||
@@ -6,6 +6,7 @@
|
||||
import { rest } from 'msw';
|
||||
import type { GetDashboardV2200 } from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import {
|
||||
choiceControl,
|
||||
@@ -342,3 +343,30 @@ export const dashboardsListMocks = defineStoryMocks({
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/** Opens the actions menu of the row at `index`. */
|
||||
export const openRowActions = async (
|
||||
canvasElement: HTMLElement,
|
||||
index: number,
|
||||
): Promise<void> => {
|
||||
// The icon-only trigger carries no accessible name.
|
||||
const triggers = await within(canvasElement).findAllByTestId(
|
||||
'dashboard-action-icon',
|
||||
{},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
|
||||
await userEvent.click(triggers[index]);
|
||||
await screen.findByText('Rename');
|
||||
};
|
||||
|
||||
/** Picks a row action, retrying while its permission check still disables it. */
|
||||
export const pickRowAction = async (label: string | RegExp): Promise<void> => {
|
||||
await waitFor(
|
||||
async () => {
|
||||
await userEvent.click(screen.getByText(label));
|
||||
await screen.findByRole('dialog', {}, { timeout: 500 });
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,7 +6,9 @@ import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
dashboardsListMocks,
|
||||
openRowActions,
|
||||
overflowingRows,
|
||||
pickRowAction,
|
||||
} from './DashboardsListPage.stories.mocks';
|
||||
import { BuiltinViewId } from '../types';
|
||||
|
||||
@@ -114,6 +116,45 @@ export const Tooltips: Story = {
|
||||
parameters: { msw: { handlers: [overflowingRows] } },
|
||||
};
|
||||
|
||||
/** The first row's actions menu, open over the list. */
|
||||
export const RowActionsMenu: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openRowActions(canvasElement, 0);
|
||||
},
|
||||
};
|
||||
|
||||
/** The rename dialog, opened from the menu of the second row (the first is locked). */
|
||||
export const RenameDashboardDialog: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openRowActions(canvasElement, 1);
|
||||
await pickRowAction('Rename');
|
||||
await screen.findByRole('dialog', { name: 'Rename dashboard' });
|
||||
},
|
||||
};
|
||||
|
||||
/** The tags dialog, opened from the menu of the second row (the first is locked). */
|
||||
export const EditTagsDialog: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openRowActions(canvasElement, 1);
|
||||
await pickRowAction(/^(Edit|Add) Tags$/);
|
||||
await screen.findByRole('dialog', { name: /^(Edit|Add) tags$/ });
|
||||
},
|
||||
};
|
||||
|
||||
/** The popover that names the current filters as a new saved view. */
|
||||
export const SaveViewPopover: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole(
|
||||
'button',
|
||||
{ name: 'Save current filters as a view' },
|
||||
{ timeout: 10000 },
|
||||
),
|
||||
);
|
||||
await screen.findByText('Save as view');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The query the backend refused: the parse error it returned replaces the
|
||||
* generic failure copy, and there is nothing to retry.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
@@ -19,6 +20,7 @@ const pageStory = storyMocks(editRulesMocks, { layout: 'app' });
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Alerts/Edit',
|
||||
tags: ['play'],
|
||||
component: EditRules,
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
@@ -53,3 +55,112 @@ export const RuleNotFound: Story = {
|
||||
export const Tooltips: Story = {
|
||||
args: { tooltipsOpen: true, previewSeries: 6 },
|
||||
};
|
||||
|
||||
/** The page fetches the rule before it renders the form, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
const LABEL_INPUT = 'alert-labels-input-v1';
|
||||
|
||||
/** The labels field, scrolled into view once the rule has loaded. */
|
||||
const findLabelInput = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<HTMLElement> => {
|
||||
const input = await within(canvasElement).findByTestId(
|
||||
LABEL_INPUT,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
input.scrollIntoView({ block: 'center' });
|
||||
return input;
|
||||
};
|
||||
|
||||
const removeLabel = async (
|
||||
canvasElement: HTMLElement,
|
||||
label: string,
|
||||
): Promise<void> => {
|
||||
const chip = within(canvasElement)
|
||||
.getByText(label)
|
||||
.closest<HTMLElement>('[data-slot="badge"]');
|
||||
|
||||
if (!chip) {
|
||||
throw new Error(`Label ${label} did not render`);
|
||||
}
|
||||
|
||||
await userEvent.click(within(chip).getByRole('button'));
|
||||
};
|
||||
|
||||
/** The rule's own labels, each with its remove button, above the empty input. */
|
||||
export const Labels: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await findLabelInput(canvasElement);
|
||||
const canvas = within(canvasElement);
|
||||
await canvas.findByText('team: platform');
|
||||
await canvas.findByText('env: prod');
|
||||
},
|
||||
};
|
||||
|
||||
/** A key typed and confirmed: it waits as its own chip while the input asks for its value. */
|
||||
export const LabelKeyEntered: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const input = await findLabelInput(canvasElement);
|
||||
await userEvent.type(input, 'region{enter}');
|
||||
await within(canvasElement).findByPlaceholderText(
|
||||
'Enter a value for label key(region) then press ENTER.',
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** A key and its value confirmed, added after the rule's own labels. */
|
||||
export const LabelAdded: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const input = await findLabelInput(canvasElement);
|
||||
await userEvent.type(input, 'region{enter}');
|
||||
await userEvent.type(input, 'us-east-1{enter}');
|
||||
await within(canvasElement).findByText('region: us-east-1');
|
||||
},
|
||||
};
|
||||
|
||||
/** One of the rule's labels removed, the other left in place. */
|
||||
export const LabelRemoved: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await findLabelInput(canvasElement);
|
||||
await within(canvasElement).findByText('team: platform');
|
||||
await removeLabel(canvasElement, 'team: platform');
|
||||
await waitFor(() =>
|
||||
expect(within(canvasElement).queryByText('team: platform')).toBeNull(),
|
||||
);
|
||||
await expect(within(canvasElement).getByText('env: prod')).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
/** Every label removed: the input is back to its prompt and the clear-all button is gone. */
|
||||
export const NoLabels: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const input = await findLabelInput(canvasElement);
|
||||
await within(canvasElement).findByText('team: platform');
|
||||
await removeLabel(canvasElement, 'team: platform');
|
||||
await removeLabel(canvasElement, 'env: prod');
|
||||
await within(canvasElement).findByPlaceholderText(
|
||||
'Click here to enter a label (key value pairs)',
|
||||
);
|
||||
await expect(input.parentElement?.querySelector('button')).toBeNull();
|
||||
},
|
||||
};
|
||||
|
||||
/** The confirmation the clear-all button opens before it drops every label. */
|
||||
export const ClearLabelsConfirm: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const input = await findLabelInput(canvasElement);
|
||||
await within(canvasElement).findByText('team: platform');
|
||||
const clearAll = input.parentElement?.querySelector('button');
|
||||
|
||||
if (!clearAll) {
|
||||
throw new Error('Clear-all labels button did not render');
|
||||
}
|
||||
|
||||
await userEvent.click(clearAll);
|
||||
await screen.findByText(
|
||||
'This action will remove all the labels. Do you want to proceed?',
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -97,11 +97,3 @@ export const NavSettingsMenu: Story = {
|
||||
await screen.findByRole('menu');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The firing alert count in the bottom strip, in place of the build version. It
|
||||
* counts firing instances, so it does not match the alert rules widget above.
|
||||
*/
|
||||
export const BottomStrip: Story = {
|
||||
args: { bottomStrip: true },
|
||||
};
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import {
|
||||
findSuggestion,
|
||||
openKeySuggestions,
|
||||
typeFilter,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
import { VIEWS } from 'container/InfraMonitoringK8sV2/constants';
|
||||
|
||||
@@ -41,6 +46,21 @@ export const PodDetailsEvents: StoryObj<PodsArgs> = {
|
||||
args: { drawer: true, drawerTab: VIEWS.EVENTS },
|
||||
};
|
||||
|
||||
/** The selected pod's details drawer, switched to its logs tab. */
|
||||
export const DetailsDrawerLogsTab: StoryObj<PodsArgs> = {
|
||||
args: { drawer: true },
|
||||
play: async () => {
|
||||
const drawer = within(
|
||||
await screen.findByRole('dialog', {}, { timeout: 10000 }),
|
||||
);
|
||||
|
||||
await userEvent.click(
|
||||
await drawer.findByText('Logs', {}, { timeout: 10000 }),
|
||||
);
|
||||
await drawer.findAllByText(/handled request in/, {}, { timeout: 10000 });
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Every tooltip the pod list carries, held open: Collapse Filters beside the
|
||||
* quick filters, Options above the table, the Pod Name, Status, Age and Restarts
|
||||
@@ -86,3 +106,27 @@ export const TooltipsInOptionsPanel: StoryObj<PodsArgs> = {
|
||||
await screen.findByText('Columns');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The pod list re-renders the filter when the viewport grows, and each render
|
||||
* reconfigures the editor, which closes its suggestions. Shot at the height
|
||||
* the page opened at.
|
||||
*/
|
||||
const heldViewport = { sbshot: { viewport: { height: 1200 } } };
|
||||
|
||||
/** The pod filter focused: the Kubernetes keys pods can be narrowed by. */
|
||||
export const FilterKeySuggestions: StoryObj<PodsArgs> = {
|
||||
parameters: heldViewport,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openKeySuggestions(canvasElement, 'k8s.node.name');
|
||||
},
|
||||
};
|
||||
|
||||
/** The pod filter on a namespace: the namespaces the pods run in. */
|
||||
export const FilterValueSuggestions: StoryObj<PodsArgs> = {
|
||||
parameters: heldViewport,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'k8s.namespace.name = ');
|
||||
await findSuggestion(canvasElement, 'kube-system');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -69,3 +69,12 @@ export const GroupActionsMenu: Story = {
|
||||
await screen.findByRole('menu');
|
||||
},
|
||||
};
|
||||
|
||||
/** The first mapping group's edit drawer, opened from its menu. */
|
||||
export const GroupFormDrawer: Story = {
|
||||
play: async (context): Promise<void> => {
|
||||
await GroupActionsMenu.play?.(context);
|
||||
await userEvent.click(await screen.findByText('Edit'));
|
||||
await screen.findByText('Edit group');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -54,3 +54,24 @@ export const ModelCostActionsMenu: Story = {
|
||||
await screen.findByRole('menu');
|
||||
},
|
||||
};
|
||||
|
||||
/** The first pricing rule's drawer, opened from its row menu. */
|
||||
export const ModelCostDrawer: Story = {
|
||||
play: async (context): Promise<void> => {
|
||||
await ModelCostActionsMenu.play?.(context);
|
||||
await userEvent.click(await screen.findByText('Edit'));
|
||||
await screen.findByText('Edit model cost');
|
||||
},
|
||||
};
|
||||
|
||||
/** The drawer with its cache mode select open. */
|
||||
export const ModelCostDrawerCacheModeOpen: Story = {
|
||||
play: async (context): Promise<void> => {
|
||||
await ModelCostDrawer.play?.(context);
|
||||
|
||||
await userEvent.click(
|
||||
await screen.findByRole('combobox', { name: 'Cache mode' }),
|
||||
);
|
||||
await screen.findByRole('listbox');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -25,6 +25,11 @@ import {
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import {
|
||||
RESPONSE_STATES,
|
||||
type ResponseState,
|
||||
respondWith,
|
||||
} from '@/storybook/runtime/responseState';
|
||||
|
||||
import {
|
||||
logsSavedViewsResponse,
|
||||
@@ -44,6 +49,8 @@ import {
|
||||
logRowsResponse,
|
||||
QUICK_FILTER_MAX,
|
||||
logsQuickFiltersResponse,
|
||||
RECENT_FILTER_MAX,
|
||||
recentFiltersStorage,
|
||||
RELATIVE_TIME,
|
||||
timeRangeState,
|
||||
} from './__story_mockdata__/logs';
|
||||
@@ -158,6 +165,20 @@ export const logsMocks = defineStoryMocks({
|
||||
value: QUICK_FILTER_MAX,
|
||||
max: QUICK_FILTER_MAX,
|
||||
}),
|
||||
filterValues: choiceControl<ResponseState>('Filter values', {
|
||||
group: FILTERS,
|
||||
description:
|
||||
'How `/fields/values` answers once a key and an operator are typed in the filter, apart from the page-wide Data control.',
|
||||
options: RESPONSE_STATES,
|
||||
value: 'loaded',
|
||||
}),
|
||||
recentFilters: countControl('Recent filters', {
|
||||
group: FILTERS,
|
||||
description:
|
||||
'Filters run before in this browser, which the filter offers above its key suggestions.',
|
||||
value: 0,
|
||||
max: RECENT_FILTER_MAX,
|
||||
}),
|
||||
savedViews: countControl('Saved views', {
|
||||
group: FILTERS,
|
||||
description: 'The views the view picker above the query builder lists.',
|
||||
@@ -217,7 +238,7 @@ export const logsMocks = defineStoryMocks({
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/fields/values',
|
||||
response.json((req) =>
|
||||
respondWith(values.filterValues, (req) =>
|
||||
logFieldValuesResponse(
|
||||
req.url.searchParams.get('name') ?? '',
|
||||
req.url.searchParams.get('searchText') ?? '',
|
||||
@@ -264,8 +285,16 @@ export const logsMocks = defineStoryMocks({
|
||||
}
|
||||
: {},
|
||||
}),
|
||||
effect: ({ frequencyChart, filtersPanel, format, maxLines, fontSize }) => {
|
||||
effect: ({
|
||||
frequencyChart,
|
||||
filtersPanel,
|
||||
format,
|
||||
maxLines,
|
||||
fontSize,
|
||||
recentFilters,
|
||||
}) => {
|
||||
setLocalStorage(LOCALSTORAGE.SHOW_FREQUENCY_CHART, String(frequencyChart));
|
||||
setLocalStorage(...recentFiltersStorage(recentFilters));
|
||||
setLocalStorage(LOCALSTORAGE.SHOW_LOGS_QUICK_FILTERS, String(filtersPanel));
|
||||
|
||||
// The preferences loader reads localStorage ahead of the URL, so this is
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import {
|
||||
blurFilter,
|
||||
openKeySuggestions,
|
||||
showFilterErrors,
|
||||
typeFilter,
|
||||
typeFilterWithCaretBack,
|
||||
findSuggestion,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
@@ -209,3 +217,138 @@ export const Tooltips: Story = {
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** The filter focused before anything is typed: every key the logs carry. */
|
||||
export const FilterKeySuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openKeySuggestions(canvasElement, 'severity_text');
|
||||
},
|
||||
};
|
||||
|
||||
/** A partial key, with the keys that still match and the typed part marked. */
|
||||
export const FilterPartialKey: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'serv');
|
||||
await findSuggestion(canvasElement, 'service.name');
|
||||
},
|
||||
};
|
||||
|
||||
/** A string key followed by a space: the operators a string compares with. */
|
||||
export const FilterOperatorSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'service.name ');
|
||||
await findSuggestion(canvasElement, 'CONTAINS');
|
||||
},
|
||||
};
|
||||
|
||||
/** A number key puts the range comparisons first. */
|
||||
export const FilterNumberOperatorSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'http.status_code ');
|
||||
await findSuggestion(canvasElement, 'BETWEEN');
|
||||
},
|
||||
};
|
||||
|
||||
/** `NOT` after a key narrows the list to the operators it can negate. */
|
||||
export const FilterNegatedOperatorSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'service.name NOT ');
|
||||
await findSuggestion(canvasElement, 'IN');
|
||||
},
|
||||
};
|
||||
|
||||
/** A key and an operator: the values the key holds, fetched for it. */
|
||||
export const FilterValueSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'service.name = ');
|
||||
await findSuggestion(canvasElement, 'checkout');
|
||||
},
|
||||
};
|
||||
|
||||
/** Values still being fetched for the key. */
|
||||
export const FilterValuesLoading: Story = {
|
||||
args: { filterValues: 'loading' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'service.name = ');
|
||||
await findSuggestion(canvasElement, 'Loading suggestions');
|
||||
},
|
||||
};
|
||||
|
||||
/** A key the backend holds no values for, such as the free-text body. */
|
||||
export const FilterNoValueSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'body = ');
|
||||
await findSuggestion(canvasElement, 'No suggestions available');
|
||||
},
|
||||
};
|
||||
|
||||
/** The values request failed. */
|
||||
export const FilterValuesError: Story = {
|
||||
args: { filterValues: 'error' },
|
||||
// The values request deliberately fails.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'service.name = ');
|
||||
await findSuggestion(canvasElement, 'Error loading suggestions');
|
||||
},
|
||||
};
|
||||
|
||||
/** Inside an `IN` list, after the first value: the rest of the values. */
|
||||
export const FilterInList: Story = {
|
||||
// The editor logs a TypeError while the list is open, which it survives.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilterWithCaretBack(
|
||||
canvasElement,
|
||||
"service.name IN ['auth', ]",
|
||||
1,
|
||||
'checkout',
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** A complete condition: the conjunctions that start the next one. */
|
||||
export const FilterConjunctionSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, "service.name = 'checkout' ");
|
||||
await findSuggestion(canvasElement, 'OR');
|
||||
},
|
||||
};
|
||||
|
||||
/** Inside an opened group: keys, another group and `NOT`. */
|
||||
export const FilterNestedGroup: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilterWithCaretBack(canvasElement, '()', 1, 'NOT');
|
||||
},
|
||||
};
|
||||
|
||||
/** A long valid expression mixing operators, left for the next run. */
|
||||
export const FilterComplete: Story = {
|
||||
// The editor logs a TypeError while the `IN` list is typed, which it survives.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(
|
||||
canvasElement,
|
||||
"service.name IN ['checkout', 'payments'] AND severity_text = 'ERROR' AND http.status_code >= 500 AND body CONTAINS 'timeout'",
|
||||
);
|
||||
await blurFilter(canvasElement);
|
||||
},
|
||||
};
|
||||
|
||||
/** An incomplete expression after focus left: the marker and its errors. */
|
||||
export const FilterSyntaxError: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await showFilterErrors(canvasElement, 'service.name = ');
|
||||
},
|
||||
};
|
||||
|
||||
/** Filters run before, offered above the key suggestions. */
|
||||
export const FilterRecentSearches: Story = {
|
||||
args: { recentFilters: 3 },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openKeySuggestions(
|
||||
canvasElement,
|
||||
"k8s.namespace.name = 'observability'",
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { defaultLogsSelectedColumns } from 'container/OptionsMenu/constants';
|
||||
import type { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import { STORAGE_VERSION } from 'lib/recentQueries/constants';
|
||||
import type { RecentQueriesStoreShape } from 'lib/recentQueries/types';
|
||||
import { makeId, storageKeyFor } from 'lib/recentQueries/utils';
|
||||
import type { Time } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { quickFiltersListResponse } from 'mocks-server/__mockdata__/customQuickFilters';
|
||||
import type { AppState } from 'store/reducers';
|
||||
@@ -470,3 +473,34 @@ const DASHBOARD_NAMES = [
|
||||
|
||||
export const dashboardsResponse = (): ListDashboardsForUserV2200 =>
|
||||
dashboardsForUserResponse(DASHBOARD_NAMES);
|
||||
|
||||
const RECENT_FILTERS = [
|
||||
"service.name = 'checkout' AND severity_text = 'ERROR'",
|
||||
"k8s.namespace.name = 'observability'",
|
||||
"body CONTAINS 'timeout'",
|
||||
'http.status_code >= 500',
|
||||
"deployment.environment IN ['production', 'staging']",
|
||||
];
|
||||
|
||||
/** The filter lists this many recent entries at most. */
|
||||
export const RECENT_FILTER_MAX = RECENT_FILTERS.length;
|
||||
|
||||
/**
|
||||
* The explorer's recent filters as the store keeps them in localStorage,
|
||||
* newest first, five minutes apart. Dated off `new Date()`, which the story
|
||||
* clock freezes, so the "5 minutes ago" labels hold still.
|
||||
*/
|
||||
export const recentFiltersStorage = (count: number): [string, string] => {
|
||||
const store: RecentQueriesStoreShape = {
|
||||
version: STORAGE_VERSION,
|
||||
entries: RECENT_FILTERS.slice(0, count).map((expression, index) => ({
|
||||
id: makeId('logs', '', expression),
|
||||
signal: 'logs',
|
||||
source: '',
|
||||
filter: { expression },
|
||||
lastUsedAt: new Date().getTime() - (index + 1) * 5 * 60_000,
|
||||
})),
|
||||
};
|
||||
|
||||
return [storageKeyFor('logs', ''), JSON.stringify(store)];
|
||||
};
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import {
|
||||
findSuggestion,
|
||||
openKeySuggestions,
|
||||
typeFilter,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
|
||||
import { screen, userEvent } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
@@ -114,3 +119,18 @@ export const MetricDetailsDashboardsMenu: Story = {
|
||||
await screen.findByRole('menu');
|
||||
},
|
||||
};
|
||||
|
||||
/** The summary's metric search focused: the attributes metrics can be found by. */
|
||||
export const FilterKeySuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openKeySuggestions(canvasElement, 'k8s.cluster.name');
|
||||
},
|
||||
};
|
||||
|
||||
/** The metric search on an attribute and an operator: the values it holds. */
|
||||
export const FilterValueSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'service.name = ');
|
||||
await findSuggestion(canvasElement, 'checkout');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -61,13 +61,3 @@ export const Pagination: Story = {
|
||||
export const OverTrialLimit: Story = {
|
||||
args: { traffic: 'over-trial-limit', banner: 'trial-expiry' },
|
||||
};
|
||||
|
||||
/** The service count in the bottom strip, in place of the build version. */
|
||||
export const BottomStrip: Story = {
|
||||
args: { bottomStrip: true },
|
||||
};
|
||||
|
||||
/** The same count on the span metrics table, which is the page's other path. */
|
||||
export const BottomStripSpanMetrics: Story = {
|
||||
args: { bottomStrip: true, mode: 'span-metrics' },
|
||||
};
|
||||
|
||||
@@ -15,10 +15,14 @@ import {
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { MockResolver } from '@/storybook/msw/types';
|
||||
|
||||
import {
|
||||
CREATE_OUTCOMES,
|
||||
type CreateOutcome,
|
||||
EXPIRIES,
|
||||
type Expiry,
|
||||
ingestionKeyCreateError,
|
||||
ingestionKeysResponse,
|
||||
KEYS_PER_PAGE,
|
||||
legacyIngestionResponse,
|
||||
@@ -29,6 +33,11 @@ import {
|
||||
const KEYS = 'Ingestion · keys';
|
||||
const LIMITS = 'Ingestion · limits';
|
||||
|
||||
const rejectCreate: MockResolver = (_req, res, ctx) =>
|
||||
res(ctx.status(409), ctx.json(ingestionKeyCreateError()));
|
||||
|
||||
const holdCreate: MockResolver = (_req, res, ctx) => res(ctx.delay('infinite'));
|
||||
|
||||
export const ingestionMocks = defineStoryMocks({
|
||||
controls: {
|
||||
gateway: toggleControl('Gateway', {
|
||||
@@ -50,6 +59,13 @@ export const ingestionMocks = defineStoryMocks({
|
||||
options: EXPIRIES,
|
||||
value: 'none',
|
||||
}),
|
||||
create: choiceControl<CreateOutcome>('Creating a key', {
|
||||
group: KEYS,
|
||||
description:
|
||||
'What the create behind the new key form answers. `hangs` holds the submit button in its loading state.',
|
||||
options: CREATE_OUTCOMES,
|
||||
value: 'succeeds',
|
||||
}),
|
||||
limits: multiChoiceControl<LimitSignal>('Signals with a limit', {
|
||||
group: LIMITS,
|
||||
description:
|
||||
@@ -85,10 +101,14 @@ export const ingestionMocks = defineStoryMocks({
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v2/gateway/ingestion_keys',
|
||||
response.json(() => ({
|
||||
status: 'success',
|
||||
data: { id: 'ingestion-key-new', value: 'sk_new' },
|
||||
})),
|
||||
{
|
||||
succeeds: response.json(() => ({
|
||||
status: 'success',
|
||||
data: { id: 'ingestion-key-new', value: 'sk_new' },
|
||||
})),
|
||||
fails: rejectCreate,
|
||||
hangs: holdCreate,
|
||||
}[values.create],
|
||||
),
|
||||
|
||||
rest.patch(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
import dayjs from 'dayjs';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
@@ -76,16 +77,149 @@ export const KeyLimits: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
async function openCreateKey(
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<ReturnType<typeof within>> {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByText(
|
||||
'New Ingestion key',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
return within(
|
||||
await screen.findByRole(
|
||||
'dialog',
|
||||
{ name: 'Create new ingestion key' },
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function addTag(
|
||||
dialog: ReturnType<typeof within>,
|
||||
tag: string,
|
||||
): Promise<void> {
|
||||
await userEvent.click(await dialog.findByRole('button', { name: /New Tag/ }));
|
||||
await userEvent.keyboard(`${tag}{Enter}`);
|
||||
}
|
||||
|
||||
async function submitCreateKey(
|
||||
dialog: ReturnType<typeof within>,
|
||||
): Promise<void> {
|
||||
await userEvent.type(dialog.getByLabelText('Name'), 'otel-collectors');
|
||||
await userEvent.click(dialog.getByLabelText('Expiration'));
|
||||
await userEvent.click(
|
||||
await screen.findByTitle(dayjs().add(1, 'day').format('YYYY-MM-DD')),
|
||||
);
|
||||
await userEvent.click(
|
||||
dialog.getByRole('button', { name: 'Create new Ingestion key' }),
|
||||
);
|
||||
}
|
||||
|
||||
/** The form a new key is named and dated in. */
|
||||
export const CreateKey: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByText(
|
||||
'New Ingestion key',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByText('Create new ingestion key', undefined, untilLoaded);
|
||||
await openCreateKey(canvasElement);
|
||||
},
|
||||
};
|
||||
|
||||
/** A tag typed on the new key and not confirmed yet. */
|
||||
export const CreateKeyAddingTag: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const dialog = await openCreateKey(canvasElement);
|
||||
await userEvent.click(dialog.getByRole('button', { name: /New Tag/ }));
|
||||
await userEvent.keyboard('team-payments');
|
||||
},
|
||||
};
|
||||
|
||||
/** The new key with its tags confirmed, each one removable. */
|
||||
export const CreateKeyTagsAdded: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const dialog = await openCreateKey(canvasElement);
|
||||
for (const tag of ['team-payments', 'env:production']) {
|
||||
await addTag(dialog, tag);
|
||||
await dialog.findByText(tag);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A tag typed again while the key already has it: Enter keeps the input open
|
||||
* and adds nothing, with no message saying why.
|
||||
*/
|
||||
export const CreateKeyDuplicateTag: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const dialog = await openCreateKey(canvasElement);
|
||||
await addTag(dialog, 'team-payments');
|
||||
await addTag(dialog, 'team-payments');
|
||||
await dialog.findByDisplayValue('team-payments');
|
||||
},
|
||||
};
|
||||
|
||||
/** A tag longer than its pill, cut short with an ellipsis. */
|
||||
export const CreateKeyLongTag: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const dialog = await openCreateKey(canvasElement);
|
||||
const tag =
|
||||
'team-payments-platform-observability-production-us-east-1-canary-collectors';
|
||||
await addTag(dialog, tag);
|
||||
await dialog.findByText(tag);
|
||||
},
|
||||
};
|
||||
|
||||
/** A name with a space and no expiration, submitted: each field names its rule. */
|
||||
export const CreateKeyInvalid: Story = {
|
||||
// The page logs the rejected validation through `console.error`.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const dialog = await openCreateKey(canvasElement);
|
||||
await userEvent.type(dialog.getByLabelText('Name'), 'otel collectors');
|
||||
await userEvent.click(
|
||||
dialog.getByRole('button', { name: 'Create new Ingestion key' }),
|
||||
);
|
||||
await dialog.findByText(/should only contain letters/);
|
||||
},
|
||||
};
|
||||
|
||||
/** The expiration calendar, where today and every day before it are disabled. */
|
||||
export const CreateKeyExpirationOpen: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const dialog = await openCreateKey(canvasElement);
|
||||
await userEvent.click(dialog.getByLabelText('Expiration'));
|
||||
await screen.findByTitle(dayjs().format('YYYY-MM-DD'));
|
||||
},
|
||||
};
|
||||
|
||||
/** The new key sent, with the create still in flight. */
|
||||
export const CreateKeySubmitting: Story = {
|
||||
args: { create: 'hangs' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const dialog = await openCreateKey(canvasElement);
|
||||
await submitCreateKey(dialog);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
dialog.getByRole('button', { name: 'Create new Ingestion key' }),
|
||||
).toBeDisabled(),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A create the gateway rejects: the notification names the conflict and the
|
||||
* form keeps what was typed.
|
||||
*/
|
||||
export const CreateKeyFailed: Story = {
|
||||
args: { create: 'fails' },
|
||||
// The deliberate 409 is the state under test.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const dialog = await openCreateKey(canvasElement);
|
||||
await submitCreateKey(dialog);
|
||||
await screen.findByText(
|
||||
'An ingestion key with this name already exists.',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
GatewaytypesIngestionKeyDTO,
|
||||
GatewaytypesLimitDTO,
|
||||
GetIngestionKeys200,
|
||||
RenderErrorResponseDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { IngestionInfo } from 'types/api/settings/ingestion';
|
||||
|
||||
@@ -21,6 +22,10 @@ export const EXPIRIES = ['none', 'soon', 'expired'] as const;
|
||||
|
||||
export type Expiry = (typeof EXPIRIES)[number];
|
||||
|
||||
export const CREATE_OUTCOMES = ['succeeds', 'fails', 'hangs'] as const;
|
||||
|
||||
export type CreateOutcome = (typeof CREATE_OUTCOMES)[number];
|
||||
|
||||
const KEY_NAMES = [
|
||||
'production-us-east',
|
||||
'production-eu-west',
|
||||
@@ -117,6 +122,18 @@ export const ingestionKeysResponse = (
|
||||
},
|
||||
});
|
||||
|
||||
export const ingestionKeyCreateError = (): RenderErrorResponseDTO => ({
|
||||
status: 'error',
|
||||
error: {
|
||||
code: 'already_exists',
|
||||
type: 'already_exists',
|
||||
message: 'An ingestion key with this name already exists.',
|
||||
url: '',
|
||||
errors: [],
|
||||
suggestions: [],
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* The pre-gateway endpoint, which answers with one record and no limits: the tab
|
||||
* falls back to it when the gateway feature is off.
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import ROUTES from 'constants/routes';
|
||||
import { rest } from 'msw';
|
||||
import { userEvent, within } from 'storybook/test';
|
||||
import { RoleType } from 'types/roles';
|
||||
|
||||
import { choiceControl } from '@/storybook/controls/controls';
|
||||
@@ -84,3 +85,18 @@ export const roleEditorMocks = defineStoryMocks({
|
||||
],
|
||||
config: (values) => ({ route: routeFor(values.mode, values.editor) }),
|
||||
});
|
||||
|
||||
/** Opens the Logs card and returns it. */
|
||||
export const openLogsCard = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<HTMLElement> => {
|
||||
const header = await within(canvasElement).findByRole(
|
||||
'button',
|
||||
{ name: /^Logs:/ },
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
|
||||
await userEvent.click(header);
|
||||
|
||||
return header.parentElement as HTMLElement;
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { userEvent, within } from 'storybook/test';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { roleEditorMocks } from './RoleEditor.stories.mocks';
|
||||
import { openLogsCard, roleEditorMocks } from './RoleEditor.stories.mocks';
|
||||
|
||||
import SettingsPage from '../../../Settings';
|
||||
|
||||
@@ -75,3 +75,27 @@ export const Tooltips: Story = {
|
||||
export const TooltipsInJsonEditor: Story = {
|
||||
args: { tooltipsOpen: true, mode: 'edit', editor: 'json' },
|
||||
};
|
||||
|
||||
/** The Logs card's first verb granted over everything instead of nothing. */
|
||||
export const PermissionEditorScope: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const card = await openLogsCard(canvasElement);
|
||||
const [all] = await within(card).findAllByText('All');
|
||||
|
||||
await userEvent.click(all);
|
||||
},
|
||||
};
|
||||
|
||||
/** The selector wizard, opened from a Logs verb scoped to named objects. */
|
||||
export const TelemetrySelectorWizard: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const card = await openLogsCard(canvasElement);
|
||||
const [onlySelected] = await within(card).findAllByText('Only selected');
|
||||
|
||||
await userEvent.click(onlySelected);
|
||||
await userEvent.click(
|
||||
await within(card).findByRole('button', { name: 'Wizard' }),
|
||||
);
|
||||
await screen.findByRole('dialog', { name: 'Selector Wizard' });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,23 +5,18 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import styles from './EntityMetadataItem.module.scss';
|
||||
|
||||
interface EntityMetadataItemProps {
|
||||
tooltip: ReactNode;
|
||||
tooltipClassName?: string;
|
||||
tooltip: string;
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function EntityMetadataItem({
|
||||
tooltip,
|
||||
tooltipClassName,
|
||||
icon,
|
||||
children,
|
||||
}: EntityMetadataItemProps): JSX.Element {
|
||||
return (
|
||||
<TooltipSimple
|
||||
title={tooltip}
|
||||
tooltipContentProps={{ className: tooltipClassName }}
|
||||
>
|
||||
<TooltipSimple title={tooltip}>
|
||||
<span className={styles.item}>
|
||||
{icon}
|
||||
<Typography.Text as="span">{children}</Typography.Text>
|
||||
@@ -32,7 +27,6 @@ function EntityMetadataItem({
|
||||
|
||||
EntityMetadataItem.defaultProps = {
|
||||
icon: null,
|
||||
tooltipClassName: undefined,
|
||||
};
|
||||
|
||||
export default EntityMetadataItem;
|
||||
|
||||
@@ -8,7 +8,3 @@
|
||||
// Keep Typography.Text at the row's size (it reads this var, not inherited font-size).
|
||||
--typography-font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.tokenTooltip {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
import {
|
||||
CalendarClock,
|
||||
CircleDollarSign,
|
||||
Coins,
|
||||
Server,
|
||||
Timer,
|
||||
} from '@signozhq/icons';
|
||||
import { CalendarClock, Server, Timer } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import cx from 'classnames';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
|
||||
|
||||
import { formatCost, formatTokens } from '../SpanHoverCard/aiUsage';
|
||||
import { TraceTokenUsage } from '../TraceDetailsHeader/types';
|
||||
import EntityMetadataItem from './EntityMetadataItem';
|
||||
import TokenUsageTooltip from './TokenUsageTooltip';
|
||||
|
||||
import tooltipStyles from '../TraceTooltip/TraceTooltip.module.scss';
|
||||
import styles from './EntityMetadataRow.module.scss';
|
||||
|
||||
interface EntityMetadataRowProps {
|
||||
@@ -26,8 +16,6 @@ interface EntityMetadataRowProps {
|
||||
execTimePercent?: number;
|
||||
timestamp?: string;
|
||||
statusCode?: string | number;
|
||||
tokens?: TraceTokenUsage;
|
||||
cost?: number | null;
|
||||
}
|
||||
|
||||
const ICON_SIZE = 14;
|
||||
@@ -44,8 +32,6 @@ function EntityMetadataRow({
|
||||
execTimePercent,
|
||||
timestamp,
|
||||
statusCode,
|
||||
tokens,
|
||||
cost,
|
||||
}: EntityMetadataRowProps): JSX.Element {
|
||||
const entityLabel = entity === 'trace' ? 'Trace' : 'Span';
|
||||
const durationTooltip =
|
||||
@@ -105,25 +91,6 @@ function EntityMetadataRow({
|
||||
<HttpStatusBadge statusCode={statusCode} />
|
||||
</EntityMetadataItem>
|
||||
)}
|
||||
|
||||
{tokens && (
|
||||
<EntityMetadataItem
|
||||
tooltip={<TokenUsageTooltip tokens={tokens} />}
|
||||
tooltipClassName={tooltipStyles.tooltipContent}
|
||||
icon={<Coins size={ICON_SIZE} />}
|
||||
>
|
||||
Tokens: {formatTokens(tokens.input)} → {formatTokens(tokens.output)}
|
||||
</EntityMetadataItem>
|
||||
)}
|
||||
|
||||
{cost != null && (
|
||||
<EntityMetadataItem
|
||||
tooltip="Total cost"
|
||||
icon={<CircleDollarSign size={ICON_SIZE} />}
|
||||
>
|
||||
{formatCost(cost)}
|
||||
</EntityMetadataItem>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -135,8 +102,6 @@ EntityMetadataRow.defaultProps = {
|
||||
execTimePercent: undefined,
|
||||
timestamp: undefined,
|
||||
statusCode: undefined,
|
||||
tokens: undefined,
|
||||
cost: undefined,
|
||||
};
|
||||
|
||||
export default EntityMetadataRow;
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { formatTokens } from '../SpanHoverCard/aiUsage';
|
||||
import TooltipRow from '../TraceTooltip/TooltipRow';
|
||||
import TooltipSection from '../TraceTooltip/TooltipSection';
|
||||
import { TraceTokenUsage } from '../TraceDetailsHeader/types';
|
||||
|
||||
import tooltipStyles from '../TraceTooltip/TraceTooltip.module.scss';
|
||||
import styles from './EntityMetadataRow.module.scss';
|
||||
|
||||
interface TokenUsageTooltipProps {
|
||||
tokens: TraceTokenUsage;
|
||||
}
|
||||
|
||||
function TokenUsageTooltip({ tokens }: TokenUsageTooltipProps): JSX.Element {
|
||||
const { input, output, cacheRead, cacheWrite, reasoning } = tokens;
|
||||
|
||||
return (
|
||||
<div className={`${tooltipStyles.body} ${styles.tokenTooltip}`}>
|
||||
<TooltipSection>
|
||||
<TooltipRow label="Tokens" value={formatTokens(input + output)} isTotal />
|
||||
</TooltipSection>
|
||||
<TooltipSection>
|
||||
<TooltipRow label="Input" value={formatTokens(input)} />
|
||||
<TooltipRow label="Output" value={formatTokens(output)} />
|
||||
{cacheRead !== undefined && (
|
||||
<TooltipRow label="Cache read" value={formatTokens(cacheRead)} />
|
||||
)}
|
||||
{cacheWrite !== undefined && (
|
||||
<TooltipRow label="Cache write" value={formatTokens(cacheWrite)} />
|
||||
)}
|
||||
{reasoning !== undefined && (
|
||||
<TooltipRow label="Reasoning" value={formatTokens(reasoning)} />
|
||||
)}
|
||||
</TooltipSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TokenUsageTooltip;
|
||||
@@ -1,37 +0,0 @@
|
||||
import { CSSProperties } from 'react';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { getAiSpanKind } from '../utils/genAi';
|
||||
import { AI_SPAN_BADGES } from './constants';
|
||||
|
||||
interface SpanHintBadgeProps {
|
||||
span: SpanV3;
|
||||
}
|
||||
|
||||
function SpanHintBadge({ span }: SpanHintBadgeProps): JSX.Element | null {
|
||||
const aiSpanKind = getAiSpanKind(span);
|
||||
|
||||
if (aiSpanKind) {
|
||||
const { label, color } = AI_SPAN_BADGES[aiSpanKind];
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
// The outline variant derives text, fill and border from this color.
|
||||
style={{ '--badge-background': color } as CSSProperties}
|
||||
data-testid={`span-hint-badge-${aiSpanKind}`}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (!span.response_status_code) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <HttpStatusBadge statusCode={span.response_status_code} />;
|
||||
}
|
||||
|
||||
export default SpanHintBadge;
|
||||
@@ -1,39 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import SpanHintBadge from '../SpanHintBadge';
|
||||
|
||||
const spanWith = (
|
||||
attributes: Record<string, string | number>,
|
||||
responseStatusCode = '',
|
||||
): SpanV3 =>
|
||||
({
|
||||
attributes,
|
||||
resource: {},
|
||||
response_status_code: responseStatusCode,
|
||||
}) as unknown as SpanV3;
|
||||
|
||||
describe('SpanHintBadge', () => {
|
||||
it.each([
|
||||
[{ 'gen_ai.request.model': 'gpt-4o' }, 'LLM'],
|
||||
[{ 'gen_ai.tool.name': 'search' }, 'TOOL'],
|
||||
[{ 'gen_ai.agent.name': 'planner' }, 'AGENT'],
|
||||
])('shows only the AI badge for %p', (attributes, label) => {
|
||||
render(<SpanHintBadge span={spanWith(attributes, '200')} />);
|
||||
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
expect(screen.queryByText('200')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the HTTP status code for non-AI spans', () => {
|
||||
render(<SpanHintBadge span={spanWith({}, '503')} />);
|
||||
|
||||
expect(screen.getByText('503')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing for a non-AI span without a status code', () => {
|
||||
const { container } = render(<SpanHintBadge span={spanWith({})} />);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
import { AiSpanKind } from '../utils/genAi';
|
||||
|
||||
export const AI_SPAN_BADGES: Record<
|
||||
AiSpanKind,
|
||||
{ label: string; color: string }
|
||||
> = {
|
||||
[AiSpanKind.Llm]: { label: 'LLM', color: 'rgba(155, 255, 0, 1)' },
|
||||
[AiSpanKind.Tool]: { label: 'TOOL', color: 'rgba(255, 209, 0, 1)' },
|
||||
[AiSpanKind.Agent]: { label: 'AGENT', color: 'rgba(255, 10, 138, 1)' },
|
||||
};
|
||||
@@ -12,16 +12,37 @@
|
||||
// Hover card may be rendered while the SpanDetailsPanel is docked as
|
||||
// a FloatingPanel (z-index 999); bump above the default tooltip z-index.
|
||||
--tooltip-z-index: 1000;
|
||||
background-color: var(--l1-background);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--l2-border);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.content {
|
||||
min-width: 220px;
|
||||
max-width: 320px;
|
||||
font-size: 12px;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding-bottom: 8px;
|
||||
.name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.row {
|
||||
line-height: 1.5;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.preview {
|
||||
// container for additional preview rows
|
||||
}
|
||||
|
||||
.previewKey {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.previewValue {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import { convertTimeToRelevantUnit } from 'utils/traceUtils';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
|
||||
@@ -14,12 +12,6 @@ import { useMemo } from 'react';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
import { getSpanAiDetails, SpanAiDetails } from './aiUsage';
|
||||
import SpanUsageBreakdown from './SpanUsageBreakdown';
|
||||
import TooltipRow from '../TraceTooltip/TooltipRow';
|
||||
import TooltipSection from '../TraceTooltip/TooltipSection';
|
||||
|
||||
import tooltipStyles from '../TraceTooltip/TraceTooltip.module.scss';
|
||||
import styles from './SpanHoverCard.module.scss';
|
||||
|
||||
/**
|
||||
@@ -46,7 +38,6 @@ export interface SpanTooltipContentProps {
|
||||
relativeStartMs: number;
|
||||
durationMs: number;
|
||||
previewRows?: SpanPreviewRow[];
|
||||
ai?: SpanAiDetails;
|
||||
}
|
||||
|
||||
export function SpanTooltipContent({
|
||||
@@ -56,46 +47,33 @@ export function SpanTooltipContent({
|
||||
relativeStartMs,
|
||||
durationMs,
|
||||
previewRows,
|
||||
ai,
|
||||
}: SpanTooltipContentProps): JSX.Element {
|
||||
const { time: formattedDuration, timeUnitName } =
|
||||
convertTimeToRelevantUnit(durationMs);
|
||||
|
||||
return (
|
||||
<div className={cx(tooltipStyles.body, styles.content)}>
|
||||
<div className={styles.header}>
|
||||
<Typography.Text className={tooltipStyles.title} style={{ color }}>
|
||||
{spanName}
|
||||
</Typography.Text>
|
||||
{ai?.usage && (
|
||||
<Typography.Text size="small" weight="medium">
|
||||
Usage Breakdown
|
||||
</Typography.Text>
|
||||
)}
|
||||
<div className={styles.content}>
|
||||
<div className={styles.name} style={{ color }}>
|
||||
{spanName}
|
||||
</div>
|
||||
<div className={styles.row}>status: {hasError ? 'error' : 'ok'}</div>
|
||||
<div className={styles.row}>start: {toFixed(relativeStartMs, 2)} ms</div>
|
||||
<div className={styles.row}>
|
||||
duration: {toFixed(formattedDuration, 2)} {timeUnitName}
|
||||
</div>
|
||||
{ai?.usage && <SpanUsageBreakdown usage={ai.usage} />}
|
||||
<TooltipSection>
|
||||
{ai?.model && <TooltipRow label="model" value={ai.model} />}
|
||||
{ai?.toolName && <TooltipRow label="tool" value={ai.toolName} />}
|
||||
{ai?.agentName && <TooltipRow label="agent" value={ai.agentName} />}
|
||||
<TooltipRow label="status" value={hasError ? 'error' : 'ok'} />
|
||||
<TooltipRow label="start" value={`${toFixed(relativeStartMs, 2)} ms`} />
|
||||
<TooltipRow
|
||||
label="duration"
|
||||
value={`${toFixed(formattedDuration, 2)} ${timeUnitName}`}
|
||||
/>
|
||||
</TooltipSection>
|
||||
{previewRows && previewRows.length > 0 && (
|
||||
<TooltipSection>
|
||||
<div className={styles.preview}>
|
||||
{previewRows.map((row) => (
|
||||
<TooltipRow
|
||||
<div
|
||||
key={row.key}
|
||||
label={row.key}
|
||||
value={row.value}
|
||||
testId={`span-hover-card-preview-${row.key}`}
|
||||
/>
|
||||
className={styles.row}
|
||||
data-testid={`span-hover-card-preview-${row.key}`}
|
||||
>
|
||||
<span className={styles.previewKey}>{row.key}:</span>{' '}
|
||||
<span className={styles.previewValue}>{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</TooltipSection>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -159,7 +137,6 @@ export function SpanHoverCard({
|
||||
relativeStartMs: span.timestamp - traceStartTime,
|
||||
durationMs: span.duration_nano / 1e6,
|
||||
previewRows,
|
||||
ai: getSpanAiDetails(span),
|
||||
},
|
||||
};
|
||||
}, [
|
||||
@@ -189,7 +166,7 @@ export function SpanHoverCard({
|
||||
side="right"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
className={cx(tooltipStyles.tooltipContent, styles.popover)}
|
||||
className={styles.popover}
|
||||
>
|
||||
{hoverCardData && <SpanTooltipContent {...hoverCardData.tooltip} />}
|
||||
</TooltipContent>
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import {
|
||||
formatCost,
|
||||
formatTokens,
|
||||
getUsageTotals,
|
||||
SpanAiUsage,
|
||||
} from './aiUsage';
|
||||
import TooltipRow from '../TraceTooltip/TooltipRow';
|
||||
import TooltipSection from '../TraceTooltip/TooltipSection';
|
||||
|
||||
interface SpanUsageBreakdownProps {
|
||||
usage: SpanAiUsage;
|
||||
}
|
||||
|
||||
function SpanUsageBreakdown({ usage }: SpanUsageBreakdownProps): JSX.Element {
|
||||
const { outputTokens = 0, cacheReadTokens, cacheCreationTokens, cost } = usage;
|
||||
const { inputUsage, totalUsage } = getUsageTotals(usage);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TooltipSection>
|
||||
<TooltipRow label="Input usage" value={formatTokens(inputUsage)} isTotal />
|
||||
{cacheReadTokens !== undefined && (
|
||||
<TooltipRow label="cache read" value={formatTokens(cacheReadTokens)} />
|
||||
)}
|
||||
{cacheCreationTokens !== undefined && (
|
||||
<TooltipRow
|
||||
label="cache creation"
|
||||
value={formatTokens(cacheCreationTokens)}
|
||||
/>
|
||||
)}
|
||||
</TooltipSection>
|
||||
<TooltipSection>
|
||||
<TooltipRow
|
||||
label="Output usage"
|
||||
value={formatTokens(outputTokens)}
|
||||
isTotal
|
||||
/>
|
||||
</TooltipSection>
|
||||
<TooltipSection>
|
||||
<TooltipRow label="Total usage" value={formatTokens(totalUsage)} isTotal />
|
||||
{cost !== undefined && <TooltipRow label="cost" value={formatCost(cost)} />}
|
||||
</TooltipSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpanUsageBreakdown;
|
||||
@@ -1,144 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { getSpanAiDetails, getUsageTotals } from '../aiUsage';
|
||||
import { SpanTooltipContent } from '../SpanHoverCard';
|
||||
|
||||
const baseProps = {
|
||||
spanName: 'chat gpt-4o',
|
||||
color: '#fff',
|
||||
hasError: false,
|
||||
relativeStartMs: 268,
|
||||
durationMs: 1110,
|
||||
};
|
||||
|
||||
const spanWith = (attributes: Record<string, string | number>): SpanV3 =>
|
||||
({ attributes, resource: {} }) as unknown as SpanV3;
|
||||
|
||||
const usageAttributes = {
|
||||
'gen_ai.usage.input_tokens': 1000,
|
||||
'gen_ai.usage.output_tokens': 200,
|
||||
'gen_ai.usage.cache_read.input_tokens': 600,
|
||||
'gen_ai.usage.cache_creation.input_tokens': 100,
|
||||
};
|
||||
|
||||
describe('getSpanAiDetails', () => {
|
||||
it('returns undefined for a span without gen_ai attributes', () => {
|
||||
expect(getSpanAiDetails(spanWith({ 'http.method': 'GET' }))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reads model, tool, agent, token usage and cost', () => {
|
||||
expect(
|
||||
getSpanAiDetails(
|
||||
spanWith({
|
||||
'gen_ai.request.model': 'gpt-4o',
|
||||
'gen_ai.tool.name': 'search',
|
||||
'gen_ai.agent.name': 'planner',
|
||||
'gen_ai.usage.input_tokens': '120',
|
||||
'gen_ai.usage.output_tokens': 30,
|
||||
'signoz.gen_ai.usage.tokens.cost': '0.00000056',
|
||||
}),
|
||||
),
|
||||
).toStrictEqual({
|
||||
model: 'gpt-4o',
|
||||
toolName: 'search',
|
||||
agentName: 'planner',
|
||||
usage: {
|
||||
inputTokens: 120,
|
||||
outputTokens: 30,
|
||||
cacheReadTokens: undefined,
|
||||
cacheCreationTokens: undefined,
|
||||
cost: 0.00000056,
|
||||
isAnthropic: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each<Record<string, string>>([
|
||||
{ 'gen_ai.provider.name': 'Anthropic' },
|
||||
{ 'gen_ai.system': 'anthropic' },
|
||||
{ 'gen_ai.response.model': 'claude-sonnet-4' },
|
||||
{ 'gen_ai.request.model': 'anthropic.claude-v2' },
|
||||
])('detects Anthropic from %p', (attributes) => {
|
||||
expect(
|
||||
getSpanAiDetails(spanWith({ ...usageAttributes, ...attributes }))?.usage
|
||||
?.isAnthropic,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUsageTotals', () => {
|
||||
it('adds cache tokens to input for Anthropic', () => {
|
||||
const usage = getSpanAiDetails(
|
||||
spanWith({ ...usageAttributes, 'gen_ai.provider.name': 'anthropic' }),
|
||||
)?.usage;
|
||||
|
||||
expect(usage && getUsageTotals(usage)).toStrictEqual({
|
||||
inputUsage: 1700,
|
||||
totalUsage: 1900,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats input as already including cache tokens for other providers', () => {
|
||||
const usage = getSpanAiDetails(
|
||||
spanWith({ ...usageAttributes, 'gen_ai.provider.name': 'openai' }),
|
||||
)?.usage;
|
||||
|
||||
expect(usage && getUsageTotals(usage)).toStrictEqual({
|
||||
inputUsage: 1000,
|
||||
totalUsage: 1200,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SpanTooltipContent', () => {
|
||||
it('renders status, start and duration for a non-AI span', () => {
|
||||
render(<SpanTooltipContent {...baseProps} />);
|
||||
|
||||
expect(screen.getByText('chat gpt-4o')).toBeInTheDocument();
|
||||
expect(screen.getByText('ok')).toBeInTheDocument();
|
||||
expect(screen.getByText('268 ms')).toBeInTheDocument();
|
||||
expect(screen.getByText('1.11 s')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Usage Breakdown')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the usage breakdown with cache rows and cost', () => {
|
||||
render(
|
||||
<SpanTooltipContent
|
||||
{...baseProps}
|
||||
ai={{
|
||||
model: 'gpt-4o',
|
||||
usage: {
|
||||
inputTokens: 1000,
|
||||
outputTokens: 200,
|
||||
cacheReadTokens: 600,
|
||||
cacheCreationTokens: 100,
|
||||
cost: 0.00000056,
|
||||
isAnthropic: false,
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Usage Breakdown')).toBeInTheDocument();
|
||||
expect(screen.getByText('1,000')).toBeInTheDocument();
|
||||
expect(screen.getByText('cache read')).toBeInTheDocument();
|
||||
expect(screen.getByText('600')).toBeInTheDocument();
|
||||
expect(screen.getByText('cache creation')).toBeInTheDocument();
|
||||
expect(screen.getByText('1,200')).toBeInTheDocument();
|
||||
expect(screen.getByText('$ 0.00000056')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides cache rows and cost when the attributes are absent', () => {
|
||||
render(
|
||||
<SpanTooltipContent
|
||||
{...baseProps}
|
||||
ai={{ usage: { inputTokens: 10, outputTokens: 5, isAnthropic: false } }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('cache read')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('cache creation')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('cost')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,97 +0,0 @@
|
||||
import { getSpanAttribute } from 'pages/TraceDetailsV3/utils';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { GEN_AI_KEYS } from '../utils/genAi';
|
||||
|
||||
export interface SpanAiUsage {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
cacheReadTokens?: number;
|
||||
cacheCreationTokens?: number;
|
||||
cost?: number;
|
||||
isAnthropic: boolean;
|
||||
}
|
||||
|
||||
export interface SpanAiDetails {
|
||||
model?: string;
|
||||
toolName?: string;
|
||||
agentName?: string;
|
||||
usage?: SpanAiUsage;
|
||||
}
|
||||
|
||||
function getNumber(span: SpanV3, key: string): number | undefined {
|
||||
const value = getSpanAttribute(span, key);
|
||||
if (value === undefined || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function isAnthropicSpan(span: SpanV3): boolean {
|
||||
const provider = (
|
||||
getSpanAttribute(span, GEN_AI_KEYS.providerName) ??
|
||||
getSpanAttribute(span, GEN_AI_KEYS.system) ??
|
||||
''
|
||||
).toLowerCase();
|
||||
const model = (
|
||||
getSpanAttribute(span, GEN_AI_KEYS.responseModel) ??
|
||||
getSpanAttribute(span, GEN_AI_KEYS.requestModel) ??
|
||||
''
|
||||
).toLowerCase();
|
||||
|
||||
return (
|
||||
provider === 'anthropic' ||
|
||||
model.includes('claude') ||
|
||||
model.includes('anthropic')
|
||||
);
|
||||
}
|
||||
|
||||
export function getSpanAiDetails(span: SpanV3): SpanAiDetails | undefined {
|
||||
const usage: SpanAiUsage = {
|
||||
inputTokens: getNumber(span, GEN_AI_KEYS.inputTokens),
|
||||
outputTokens: getNumber(span, GEN_AI_KEYS.outputTokens),
|
||||
cacheReadTokens: getNumber(span, GEN_AI_KEYS.cacheReadTokens),
|
||||
cacheCreationTokens: getNumber(span, GEN_AI_KEYS.cacheCreationTokens),
|
||||
cost: getNumber(span, GEN_AI_KEYS.cost),
|
||||
isAnthropic: isAnthropicSpan(span),
|
||||
};
|
||||
const hasUsage =
|
||||
usage.inputTokens !== undefined || usage.outputTokens !== undefined;
|
||||
|
||||
const details: SpanAiDetails = {
|
||||
model: getSpanAttribute(span, GEN_AI_KEYS.requestModel) || undefined,
|
||||
toolName: getSpanAttribute(span, GEN_AI_KEYS.toolName) || undefined,
|
||||
agentName: getSpanAttribute(span, GEN_AI_KEYS.agentName) || undefined,
|
||||
usage: hasUsage ? usage : undefined,
|
||||
};
|
||||
|
||||
return details.model || details.toolName || details.agentName || hasUsage
|
||||
? details
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Anthropic reports input_tokens without cached tokens; every other provider
|
||||
* (OpenAI, Azure OpenAI, Gemini, Mistral, DeepSeek, Groq, xAI) counts cached
|
||||
* tokens inside input_tokens.
|
||||
*/
|
||||
export function getUsageTotals(usage: SpanAiUsage): {
|
||||
inputUsage: number;
|
||||
totalUsage: number;
|
||||
} {
|
||||
const input = usage.inputTokens ?? 0;
|
||||
const inputUsage = usage.isAnthropic
|
||||
? input + (usage.cacheReadTokens ?? 0) + (usage.cacheCreationTokens ?? 0)
|
||||
: input;
|
||||
|
||||
return { inputUsage, totalUsage: inputUsage + (usage.outputTokens ?? 0) };
|
||||
}
|
||||
|
||||
export function formatTokens(value: number): string {
|
||||
return value.toLocaleString('en-US');
|
||||
}
|
||||
|
||||
export function formatCost(value: number): string {
|
||||
return `$ ${value.toLocaleString('en-US', { maximumFractionDigits: 10 })}`;
|
||||
}
|
||||
@@ -34,15 +34,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.tabs {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: unset;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.filterSection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import {
|
||||
@@ -18,26 +18,40 @@ import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { TraceDetailsTab } from '../constants';
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
|
||||
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
|
||||
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
|
||||
import { useTraceStore } from '../stores/traceStore';
|
||||
import TraceDownloadPanel from './TraceDownloadPanel';
|
||||
import EntityMetadataRow from '../EntityMetadata/EntityMetadataRow';
|
||||
import AnalyticsPanel from '../SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel';
|
||||
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
|
||||
import { useTraceDetailsStripInfo } from '../useTraceDetailsStripInfo';
|
||||
import MissingSpansBanner from './MissingSpansBanner';
|
||||
import { useTraceSummary } from './useTraceSummary';
|
||||
import TraceDetailsTabs from './TraceDetailsTabs';
|
||||
import TraceOptionsMenu from './TraceOptionsMenu';
|
||||
|
||||
import styles from './TraceDetailsHeader.module.scss';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
|
||||
interface FilterMetadata {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
traceId: string;
|
||||
}
|
||||
|
||||
export interface TraceMetadataForHeader {
|
||||
startTimestampMillis: number;
|
||||
endTimestampMillis: number;
|
||||
rootServiceName: string;
|
||||
rootServiceEntryPoint: string;
|
||||
rootSpanStatusCode: string;
|
||||
hasMissingSpans: boolean;
|
||||
totalSpansCount: number;
|
||||
}
|
||||
|
||||
interface TraceDetailsHeaderProps {
|
||||
onFilteredSpansChange?: (spanIds: string[], isFilterActive: boolean) => void;
|
||||
filterMetadata: FilterMetadata;
|
||||
onFilteredSpansChange: (spanIds: string[], isFilterActive: boolean) => void;
|
||||
isDataLoaded?: boolean;
|
||||
traceMetadata?: TraceMetadataForHeader;
|
||||
}
|
||||
|
||||
const SKELETON_COUNT = 3;
|
||||
@@ -59,22 +73,16 @@ function DetailsLoader(): JSX.Element {
|
||||
}
|
||||
|
||||
function TraceDetailsHeader({
|
||||
filterMetadata,
|
||||
onFilteredSpansChange,
|
||||
isDataLoaded,
|
||||
traceMetadata,
|
||||
}: TraceDetailsHeaderProps): JSX.Element {
|
||||
const { id: traceID } = useParams<TraceDetailV3URLProps>();
|
||||
const { data: summary, isLoading } = useTraceSummary();
|
||||
const [showTraceDetails, setShowTraceDetails] = useState(true);
|
||||
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
|
||||
const [isPreviewFieldsOpen, setIsPreviewFieldsOpen] = useState(false);
|
||||
const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false);
|
||||
const [tab] = useTraceDetailsTab();
|
||||
const isOverview = tab === TraceDetailsTab.Overview;
|
||||
|
||||
// Overview-only panels must not linger over another tab.
|
||||
useEffect(() => {
|
||||
setIsPreviewFieldsOpen(false);
|
||||
setIsAnalyticsOpen(false);
|
||||
}, [tab]);
|
||||
const previewFields = useTraceStore((s) => s.previewFields);
|
||||
const setPreviewFields = useTraceStore((s) => s.setPreviewFields);
|
||||
|
||||
@@ -108,13 +116,9 @@ function TraceDetailsHeader({
|
||||
setShowTraceDetails((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
useTraceDetailsStripInfo({
|
||||
totalSpansCount: summary?.totalSpansCount ?? 0,
|
||||
totalErrorSpansCount: summary?.totalErrorSpansCount ?? 0,
|
||||
});
|
||||
|
||||
const startTime = (summary?.startTimestampMillis ?? 0) / 1e3;
|
||||
const endTime = (summary?.endTimestampMillis ?? 0) / 1e3;
|
||||
const durationMs = traceMetadata
|
||||
? traceMetadata.endTimestampMillis - traceMetadata.startTimestampMillis
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
@@ -136,10 +140,9 @@ function TraceDetailsHeader({
|
||||
badgeValue={traceID || ''}
|
||||
maxCharacters={100}
|
||||
/>
|
||||
<TraceDetailsTabs />
|
||||
</div>
|
||||
)}
|
||||
{isOverview && onFilteredSpansChange && summary && (
|
||||
{isDataLoaded && (
|
||||
<div
|
||||
className={cx(
|
||||
styles.filterSection,
|
||||
@@ -168,9 +171,9 @@ function TraceDetailsHeader({
|
||||
onToggleTraceDetails={handleToggleTraceDetails}
|
||||
onOpenPreviewFields={(): void => setIsPreviewFieldsOpen(true)}
|
||||
traceId={traceID || ''}
|
||||
startTime={startTime}
|
||||
endTime={endTime}
|
||||
totalSpansCount={summary.totalSpansCount}
|
||||
startTime={filterMetadata.startTime}
|
||||
endTime={filterMetadata.endTime}
|
||||
totalSpansCount={traceMetadata?.totalSpansCount || 0}
|
||||
/>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
@@ -180,9 +183,9 @@ function TraceDetailsHeader({
|
||||
className={cx(styles.filter, isFilterExpanded && styles.isExpanded)}
|
||||
>
|
||||
<Filters
|
||||
startTime={startTime}
|
||||
endTime={endTime}
|
||||
traceID={traceID || ''}
|
||||
startTime={filterMetadata.startTime}
|
||||
endTime={filterMetadata.endTime}
|
||||
traceID={filterMetadata.traceId}
|
||||
onFilteredSpansChange={onFilteredSpansChange}
|
||||
isExpanded={isFilterExpanded}
|
||||
onExpand={(): void => setIsFilterExpanded(true)}
|
||||
@@ -193,29 +196,28 @@ function TraceDetailsHeader({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(showTraceDetails || !isOverview) && (
|
||||
{showTraceDetails && (
|
||||
<div className={styles.subHeader}>
|
||||
{isLoading || !summary ? (
|
||||
<DetailsLoader />
|
||||
) : (
|
||||
{traceMetadata ? (
|
||||
<EntityMetadataRow
|
||||
entity="trace"
|
||||
service={{
|
||||
name: summary.rootServiceName,
|
||||
entryPoint: summary.rootServiceEntryPoint,
|
||||
name: traceMetadata.rootServiceName,
|
||||
entryPoint: traceMetadata.rootServiceEntryPoint,
|
||||
}}
|
||||
durationMs={summary.endTimestampMillis - summary.startTimestampMillis}
|
||||
timestamp={dayjs(summary.startTimestampMillis).format(
|
||||
durationMs={durationMs}
|
||||
timestamp={dayjs(traceMetadata.startTimestampMillis).format(
|
||||
DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS,
|
||||
)}
|
||||
tokens={summary.ai?.tokens}
|
||||
cost={summary.ai?.totalCost}
|
||||
statusCode={traceMetadata.rootSpanStatusCode}
|
||||
/>
|
||||
) : (
|
||||
<DetailsLoader />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOverview && summary?.hasMissingSpans && <MissingSpansBanner />}
|
||||
{traceMetadata?.hasMissingSpans && <MissingSpansBanner />}
|
||||
|
||||
<FieldsSelector
|
||||
isOpen={isPreviewFieldsOpen}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
|
||||
|
||||
import { TraceDetailsTab } from '../constants';
|
||||
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
|
||||
|
||||
import styles from './TraceDetailsHeader.module.scss';
|
||||
|
||||
function TraceDetailsTabs(): JSX.Element {
|
||||
const [tab, setTab] = useTraceDetailsTab();
|
||||
|
||||
const handleChange = (value: string): void => {
|
||||
// Clicking the active item deselects it and reports ''.
|
||||
if (value) {
|
||||
void setTab(value as TraceDetailsTab);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={tab}
|
||||
onChange={handleChange}
|
||||
className={styles.tabs}
|
||||
size="sm"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value={TraceDetailsTab.Overview}
|
||||
className={styles.tab}
|
||||
data-testid="trace-details-tab-overview"
|
||||
>
|
||||
Overview
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value={TraceDetailsTab.Thread}
|
||||
className={styles.tab}
|
||||
data-testid="trace-details-tab-thread"
|
||||
>
|
||||
Thread
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceDetailsTabs;
|
||||
@@ -1,12 +1,9 @@
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import { MOCK_TRACE_SUMMARY } from '../mockTraceSummary';
|
||||
import * as traceSummaryModule from '../useTraceSummary';
|
||||
import TraceDetailsHeader from '../TraceDetailsHeader';
|
||||
|
||||
const mockGoBack = jest.fn();
|
||||
@@ -53,9 +50,15 @@ jest.mock('components/FieldsSelector', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
const baseProps = { onFilteredSpansChange: jest.fn() };
|
||||
|
||||
const metadataText = /Missing Span/;
|
||||
const baseProps = {
|
||||
filterMetadata: {
|
||||
startTime: 0,
|
||||
endTime: 1,
|
||||
traceId: 'trace-123',
|
||||
},
|
||||
onFilteredSpansChange: jest.fn(),
|
||||
isDataLoaded: false,
|
||||
};
|
||||
|
||||
describe('TraceDetailsHeader – back button', () => {
|
||||
beforeEach(() => {
|
||||
@@ -91,8 +94,8 @@ describe('TraceDetailsHeader – action cluster', () => {
|
||||
mockReplace.mockClear();
|
||||
});
|
||||
|
||||
it('does not render the action buttons without a filter handler', () => {
|
||||
render(<TraceDetailsHeader />);
|
||||
it('does not render the action buttons while data is still loading', () => {
|
||||
render(<TraceDetailsHeader {...baseProps} isDataLoaded={false} />);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /^analytics$/i }),
|
||||
@@ -102,8 +105,8 @@ describe('TraceDetailsHeader – action cluster', () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Analytics and Settings action buttons', () => {
|
||||
render(<TraceDetailsHeader {...baseProps} />);
|
||||
it('renders Analytics and Settings action buttons once data is loaded', () => {
|
||||
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /^analytics$/i }),
|
||||
@@ -114,7 +117,7 @@ describe('TraceDetailsHeader – action cluster', () => {
|
||||
});
|
||||
|
||||
it('toggles the AnalyticsPanel open state when the Analytics button is clicked', () => {
|
||||
render(<TraceDetailsHeader {...baseProps} />);
|
||||
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
|
||||
|
||||
const panel = screen.getByTestId('analytics-panel');
|
||||
expect(panel).toHaveAttribute('data-open', 'false');
|
||||
@@ -130,125 +133,68 @@ describe('TraceDetailsHeader – action cluster', () => {
|
||||
});
|
||||
|
||||
describe('TraceDetailsHeader – trace metadata row', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
// Plain prop, no API mock needed: traceMetadata is passed straight in.
|
||||
const traceMetadata = {
|
||||
startTimestampMillis: 1_700_000_000_000,
|
||||
endTimestampMillis: 1_700_000_120_000, // +120000ms = 2 min
|
||||
rootServiceName: 'inventory-frontend',
|
||||
rootServiceEntryPoint: 'large-trace-root',
|
||||
rootSpanStatusCode: '404',
|
||||
hasMissingSpans: false,
|
||||
totalSpansCount: 42,
|
||||
};
|
||||
|
||||
it('shows skeletons instead of the metadata while the summary loads', () => {
|
||||
jest
|
||||
.spyOn(traceSummaryModule, 'useTraceSummary')
|
||||
.mockReturnValue({ data: undefined, isLoading: true });
|
||||
const { container } = render(<TraceDetailsHeader {...baseProps} />);
|
||||
|
||||
expect(screen.queryByText(metadataText)).not.toBeInTheDocument();
|
||||
expect(container.querySelectorAll('.ant-skeleton-input')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('renders the summary metadata with tokens and cost', () => {
|
||||
render(<TraceDetailsHeader {...baseProps} />);
|
||||
|
||||
const { startTimestampMillis, endTimestampMillis } = MOCK_TRACE_SUMMARY;
|
||||
const duration = getYAxisFormattedValue(
|
||||
`${endTimestampMillis - startTimestampMillis}`,
|
||||
'ms',
|
||||
it('renders the metadata (service, entry point, duration, status) when provided', () => {
|
||||
render(
|
||||
<TraceDetailsHeader
|
||||
{...baseProps}
|
||||
isDataLoaded
|
||||
traceMetadata={traceMetadata}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(metadataText)).toBeInTheDocument();
|
||||
expect(screen.getByText(/inventory-frontend/)).toBeInTheDocument();
|
||||
expect(screen.getByText('large-trace-root')).toBeInTheDocument();
|
||||
expect(screen.getByText('404')).toBeInTheDocument();
|
||||
// Duration goes through the shared formatter (e.g. "2 min").
|
||||
const duration = getYAxisFormattedValue(
|
||||
`${traceMetadata.endTimestampMillis - traceMetadata.startTimestampMillis}`,
|
||||
'ms',
|
||||
);
|
||||
expect(screen.getByText(duration)).toBeInTheDocument();
|
||||
expect(screen.getByText('Tokens: 12,040 → 3,110')).toBeInTheDocument();
|
||||
expect(screen.getByText('$ 0.0421')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is shown by default and can be hidden / shown again via the Trace options menu', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
render(<TraceDetailsHeader {...baseProps} />);
|
||||
render(
|
||||
<TraceDetailsHeader
|
||||
{...baseProps}
|
||||
isDataLoaded
|
||||
traceMetadata={traceMetadata}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(metadataText)).toBeInTheDocument();
|
||||
// Visible by default (showTraceDetails defaults to true).
|
||||
expect(screen.getByText(/inventory-frontend/)).toBeInTheDocument();
|
||||
|
||||
// Hide it.
|
||||
await user.click(screen.getByRole('button', { name: /trace options/i }));
|
||||
await user.click(
|
||||
await screen.findByRole('menuitem', { name: /hide trace details/i }),
|
||||
);
|
||||
expect(screen.queryByText(metadataText)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/inventory-frontend/)).not.toBeInTheDocument();
|
||||
|
||||
// Show it again.
|
||||
await user.click(screen.getByRole('button', { name: /trace options/i }));
|
||||
await user.click(
|
||||
await screen.findByRole('menuitem', { name: /show trace details/i }),
|
||||
);
|
||||
expect(screen.getByText(metadataText)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TraceDetailsHeader – tabs', () => {
|
||||
// In-memory URL state, so a tab switch can't leak into the next test.
|
||||
const renderHeader = (): void => {
|
||||
render(
|
||||
<NuqsTestingAdapter hasMemory>
|
||||
<TraceDetailsHeader {...baseProps} />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
};
|
||||
|
||||
it('shows the Overview-only sections on the Overview tab', () => {
|
||||
renderHeader();
|
||||
|
||||
expect(screen.getByTestId('filters-stub')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /^analytics$/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /trace options/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('missing-spans-banner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps only the metadata row on the Thread tab', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderHeader();
|
||||
|
||||
await user.click(screen.getByTestId('trace-details-tab-thread'));
|
||||
|
||||
expect(screen.queryByTestId('filters-stub')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /^analytics$/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /trace options/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('missing-spans-banner')).not.toBeInTheDocument();
|
||||
expect(screen.getByText(metadataText)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the metadata row on the Thread tab even when hidden on Overview', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderHeader();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /trace options/i }));
|
||||
await user.click(
|
||||
await screen.findByRole('menuitem', { name: /hide trace details/i }),
|
||||
);
|
||||
expect(screen.queryByText(metadataText)).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('trace-details-tab-thread'));
|
||||
|
||||
expect(screen.getByText(metadataText)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes the Analytics panel when switching to the Thread tab', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderHeader();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /^analytics$/i }));
|
||||
expect(screen.getByTestId('analytics-panel')).toHaveAttribute(
|
||||
'data-open',
|
||||
'true',
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('trace-details-tab-thread'));
|
||||
|
||||
expect(screen.getByTestId('analytics-panel')).toHaveAttribute(
|
||||
'data-open',
|
||||
'false',
|
||||
);
|
||||
expect(screen.getByText(/inventory-frontend/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the metadata row when traceMetadata is absent', () => {
|
||||
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
|
||||
|
||||
expect(screen.queryByText(/inventory-frontend/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { TraceSummary } from './types';
|
||||
|
||||
// Placeholder until GET /api/v1/traces/{id}/summary feeds the header.
|
||||
export const MOCK_TRACE_SUMMARY: TraceSummary = {
|
||||
startTimestampMillis: 1789627365929,
|
||||
endTimestampMillis: 1789627367867,
|
||||
rootServiceName: '',
|
||||
rootServiceEntryPoint: 'Missing Span',
|
||||
totalSpansCount: 3,
|
||||
totalErrorSpansCount: 0,
|
||||
hasMissingSpans: true,
|
||||
ai: {
|
||||
tokens: {
|
||||
input: 12040,
|
||||
output: 3110,
|
||||
cacheRead: 8000,
|
||||
cacheWrite: 1200,
|
||||
reasoning: 900,
|
||||
},
|
||||
totalCost: 0.0421,
|
||||
},
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
export interface TraceTokenUsage {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead?: number;
|
||||
cacheWrite?: number;
|
||||
reasoning?: number;
|
||||
}
|
||||
|
||||
/** Response of GET /api/v1/traces/{id}/summary. */
|
||||
export interface TraceSummary {
|
||||
startTimestampMillis: number;
|
||||
endTimestampMillis: number;
|
||||
rootServiceName: string;
|
||||
rootServiceEntryPoint: string;
|
||||
totalSpansCount: number;
|
||||
totalErrorSpansCount: number;
|
||||
hasMissingSpans: boolean;
|
||||
ai?: {
|
||||
tokens?: TraceTokenUsage;
|
||||
// null when no span has a cost attribute
|
||||
totalCost: number | null;
|
||||
};
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { MOCK_TRACE_SUMMARY } from './mockTraceSummary';
|
||||
import { TraceSummary } from './types';
|
||||
|
||||
interface UseTraceSummaryResult {
|
||||
data: TraceSummary | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
// Placeholder until GET /api/v1/traces/{id}/summary is available.
|
||||
export function useTraceSummary(): UseTraceSummaryResult {
|
||||
return { data: MOCK_TRACE_SUMMARY, isLoading: false };
|
||||
}
|
||||
@@ -1,500 +0,0 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
ChartNoAxesGantt,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Info,
|
||||
TriangleAlert,
|
||||
} from '@signozhq/icons';
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import setLocalStorageKey from 'api/browser/localstorage/set';
|
||||
import { Collapse } from 'antd';
|
||||
import { useDetailsPanel } from 'components/DetailsPanel';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import useGetTraceV4 from 'hooks/trace/useGetTraceV4';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import { SpanV3, TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
|
||||
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
|
||||
import NoData from '../NoData/NoData';
|
||||
import {
|
||||
setTraceStoreAvailableColorByFields,
|
||||
useTraceStore,
|
||||
} from '../stores/traceStore';
|
||||
import { SpanDetailVariant } from '../SpanDetailsPanel/constants';
|
||||
import SpanDetailsPanel from '../SpanDetailsPanel/SpanDetailsPanel';
|
||||
import { FLAMEGRAPH_SPAN_LIMIT } from '../TraceFlamegraph/constants';
|
||||
import TraceFlamegraph from '../TraceFlamegraph/TraceFlamegraph';
|
||||
import TraceWaterfall from '../TraceWaterfall/TraceWaterfall';
|
||||
import { IInterestedSpan } from '../TraceWaterfall/types';
|
||||
import { getAncestorSpanIds } from '../TraceWaterfall/utils';
|
||||
import { getAvailableColorByFieldNames } from '../utils';
|
||||
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './TraceDetailsOverview.module.scss';
|
||||
|
||||
// Lucide chevrons for the flame/waterfall accordion headers, matching the
|
||||
// span-tree chevrons in the waterfall.
|
||||
function renderPanelExpandIcon({
|
||||
isActive,
|
||||
}: {
|
||||
isActive?: boolean;
|
||||
}): JSX.Element {
|
||||
return isActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />;
|
||||
}
|
||||
|
||||
interface TraceDetailsOverviewProps {
|
||||
filteredSpanIds: string[];
|
||||
isFilterActive: boolean;
|
||||
}
|
||||
|
||||
function TraceDetailsOverview({
|
||||
filteredSpanIds,
|
||||
isFilterActive,
|
||||
}: TraceDetailsOverviewProps): JSX.Element {
|
||||
const { id: traceId } = useParams<TraceDetailV3URLProps>();
|
||||
const urlQuery = useUrlQuery();
|
||||
const [interestedSpanId, setInterestedSpanId] = useState<IInterestedSpan>(
|
||||
() => ({
|
||||
spanId: urlQuery.get('spanId') || '',
|
||||
isUncollapsed: urlQuery.get('spanId') !== '',
|
||||
}),
|
||||
);
|
||||
const [uncollapsedNodes, setUncollapsedNodes] = useState<string[]>([]);
|
||||
const [localUncollapsedNodes, setLocalUncollapsedNodes] = useState<
|
||||
Set<string>
|
||||
>(new Set());
|
||||
const [selectedSpan, setSelectedSpan] = useState<SpanV3>();
|
||||
|
||||
const selectedSpanId = urlQuery.get('spanId') || undefined;
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const logTraceEvent = useTraceDetailLogEvent('v3', traceId || '');
|
||||
// Tracks which traceId the load event already fired for, so navigating
|
||||
// between traces (the route component stays mounted) re-fires it once each.
|
||||
const dataLoadedFiredForRef = useRef('');
|
||||
const colorByField = useTraceStore((s) => s.colorByField);
|
||||
const previewFieldsCount = useTraceStore((s) => s.previewFields.length);
|
||||
const userPrefsReady = useTraceStore((s) => s.userPreferences !== null);
|
||||
|
||||
const handleSpanDetailsClose = useCallback((): void => {
|
||||
urlQuery.delete('spanId');
|
||||
safeNavigate({ search: urlQuery.toString() }, { replace: true });
|
||||
}, [urlQuery, safeNavigate]);
|
||||
|
||||
const panelState = useDetailsPanel({
|
||||
entityId: selectedSpanId,
|
||||
onClose: handleSpanDetailsClose,
|
||||
});
|
||||
|
||||
const allSpansRef = useRef<SpanV3[]>([]);
|
||||
const deepLinkResolvedRef = useRef(false);
|
||||
|
||||
// Refetch only when the URL target isn't already loaded. Keeps row clicks
|
||||
// and other in-window URL navigation from triggering a backend window slide.
|
||||
useEffect(() => {
|
||||
const spanId = urlQuery.get('spanId') || '';
|
||||
if (!spanId) {
|
||||
return;
|
||||
}
|
||||
const idx = allSpansRef.current.findIndex((s) => s.span_id === spanId);
|
||||
if (idx !== -1) {
|
||||
setSelectedSpan(allSpansRef.current[idx]);
|
||||
return;
|
||||
}
|
||||
setInterestedSpanId({ spanId, isUncollapsed: true });
|
||||
}, [urlQuery]);
|
||||
|
||||
// Once all spans are loaded (frontend mode), freeze query params so
|
||||
// subsequent interestedSpanId changes don't trigger unnecessary refetches.
|
||||
const fullDataLoadedRef = useRef(false);
|
||||
const frozenParamsRef = useRef({
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
});
|
||||
|
||||
const queryParams = fullDataLoadedRef.current
|
||||
? frozenParamsRef.current
|
||||
: {
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
};
|
||||
|
||||
const {
|
||||
data: traceData,
|
||||
isFetching: isFetchingTraceData,
|
||||
error: errorFetchingTraceData,
|
||||
} = useGetTraceV4({
|
||||
traceId,
|
||||
uncollapsedSpans: queryParams.uncollapsedSpans,
|
||||
selectedSpanId: queryParams.selectedSpanId,
|
||||
isSelectedSpanIDUnCollapsed: queryParams.isSelectedSpanIDUnCollapsed,
|
||||
});
|
||||
|
||||
const allSpans = traceData?.payload?.spans || [];
|
||||
const totalSpansCount = traceData?.payload?.totalSpansCount || 0;
|
||||
|
||||
const isFullDataLoaded =
|
||||
totalSpansCount > 0 && totalSpansCount <= allSpans.length;
|
||||
|
||||
// Color-by options, gated on fields in loaded spans. Resource attrs are
|
||||
// trace-wide, so any window has the full set — no need to accumulate.
|
||||
const availableColorByFields = useMemo(() => {
|
||||
const spans = traceData?.payload?.spans;
|
||||
return spans?.length ? getAvailableColorByFieldNames(spans) : undefined;
|
||||
}, [traceData?.payload?.spans]);
|
||||
|
||||
useEffect(() => {
|
||||
setTraceStoreAvailableColorByFields(availableColorByFields);
|
||||
}, [availableColorByFields]);
|
||||
|
||||
// Lock the ref once we confirm all data is loaded
|
||||
if (isFullDataLoaded && !fullDataLoadedRef.current) {
|
||||
fullDataLoadedRef.current = true;
|
||||
frozenParamsRef.current = {
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
allSpansRef.current = allSpans;
|
||||
}, [allSpans]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!traceId ||
|
||||
dataLoadedFiredForRef.current === traceId ||
|
||||
!userPrefsReady
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const payload = traceData?.payload;
|
||||
if (!payload?.spans?.length) {
|
||||
return;
|
||||
}
|
||||
dataLoadedFiredForRef.current = traceId;
|
||||
const numServices = new Set(payload.spans.map((s) => s['service.name'])).size;
|
||||
logTraceEvent(TraceDetailEvents.DataLoaded, {
|
||||
[TraceDetailEventKeys.TotalSpansCount]: totalSpansCount,
|
||||
[TraceDetailEventKeys.NumServices]: numServices,
|
||||
[TraceDetailEventKeys.TraceDurationMs]:
|
||||
payload.endTimestampMillis - payload.startTimestampMillis,
|
||||
[TraceDetailEventKeys.HadErrors]: (payload.totalErrorSpansCount || 0) > 0,
|
||||
[TraceDetailEventKeys.FlamegraphSampled]:
|
||||
totalSpansCount > FLAMEGRAPH_SPAN_LIMIT,
|
||||
[TraceDetailEventKeys.SpanPanelVariant]:
|
||||
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION) ||
|
||||
SpanDetailVariant.DOCKED_RIGHT,
|
||||
[TraceDetailEventKeys.ColorByField]: colorByField.name,
|
||||
[TraceDetailEventKeys.PreviewFieldsCount]: previewFieldsCount,
|
||||
});
|
||||
}, [
|
||||
traceId,
|
||||
userPrefsReady,
|
||||
traceData,
|
||||
totalSpansCount,
|
||||
colorByField,
|
||||
previewFieldsCount,
|
||||
logTraceEvent,
|
||||
]);
|
||||
|
||||
// Frontend mode: expand all parents by default when full data arrives
|
||||
useEffect(() => {
|
||||
if (isFullDataLoaded && allSpans.length > 0) {
|
||||
const parentIds = new Set(
|
||||
allSpans.filter((s) => s.has_children).map((s) => s.span_id),
|
||||
);
|
||||
setLocalUncollapsedNodes(parentIds);
|
||||
}
|
||||
}, [isFullDataLoaded, allSpans]);
|
||||
|
||||
// Backend mode: sync uncollapsed state from API response
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isFullDataLoaded &&
|
||||
traceData &&
|
||||
traceData.payload &&
|
||||
traceData.payload.uncollapsedSpans
|
||||
) {
|
||||
setUncollapsedNodes(traceData.payload.uncollapsedSpans);
|
||||
}
|
||||
}, [traceData, isFullDataLoaded]);
|
||||
|
||||
// Tracks whether we've already done the initial URL→selectedSpan handoff
|
||||
//Lets `interestedSpanId` stay purely as the refetch trigger in frontend mode.
|
||||
useEffect(() => {
|
||||
if (deepLinkResolvedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (allSpans.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (selectedSpanId) {
|
||||
const span = allSpans.find((s) => s.span_id === selectedSpanId);
|
||||
if (!span) {
|
||||
// Span not in the current window — wait for more data (backend
|
||||
// pagination) before marking resolved.
|
||||
return;
|
||||
}
|
||||
setSelectedSpan(span);
|
||||
} else {
|
||||
setSelectedSpan((prev) => prev ?? allSpans[0]);
|
||||
}
|
||||
deepLinkResolvedRef.current = true;
|
||||
}, [selectedSpanId, allSpans]);
|
||||
|
||||
// Frontend mode: auto-expand ancestors of the URL-targeted span so it's
|
||||
// visible. Keyed on URL `spanId`(selectedSpanId).
|
||||
useEffect(() => {
|
||||
if (!isFullDataLoaded || !selectedSpanId || allSpans.length === 0) {
|
||||
return;
|
||||
}
|
||||
const ancestors = getAncestorSpanIds(allSpans, selectedSpanId);
|
||||
if (ancestors.size === 0) {
|
||||
return;
|
||||
}
|
||||
setLocalUncollapsedNodes((prev) => {
|
||||
// Check if all ancestors are already expanded — avoid unnecessary state update
|
||||
let allPresent = true;
|
||||
for (const id of ancestors) {
|
||||
if (!prev.has(id)) {
|
||||
allPresent = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (allPresent) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const next = new Set(prev);
|
||||
for (const id of ancestors) {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [isFullDataLoaded, selectedSpanId, allSpans]);
|
||||
|
||||
const [activeKeys, setActiveKeys] = useState<string[]>(['flame', 'waterfall']);
|
||||
|
||||
const handleCollapseChange = (key: string): void => {
|
||||
logTraceEvent(
|
||||
key === 'flame'
|
||||
? TraceDetailEvents.FlameGraphToggled
|
||||
: TraceDetailEvents.WaterfallToggled,
|
||||
{ [TraceDetailEventKeys.Expanded]: !activeKeys.includes(key) },
|
||||
);
|
||||
setActiveKeys((prev) =>
|
||||
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
|
||||
);
|
||||
};
|
||||
|
||||
const [spanDetailVariant, setSpanDetailVariant] = useState<SpanDetailVariant>(
|
||||
() =>
|
||||
(getLocalStorageKey(
|
||||
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
|
||||
) as SpanDetailVariant) || SpanDetailVariant.DOCKED_RIGHT,
|
||||
);
|
||||
|
||||
const RIGHT_DOCK_MIN = 480;
|
||||
const RIGHT_DOCK_MAX = 720;
|
||||
const [rightDockWidth, setRightDockWidth] = useState(RIGHT_DOCK_MIN);
|
||||
|
||||
const handleVariantChange = useCallback(
|
||||
(newVariant: SpanDetailVariant): void => {
|
||||
setLocalStorageKey(
|
||||
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
|
||||
newVariant,
|
||||
);
|
||||
setSpanDetailVariant(newVariant);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const showNoData =
|
||||
!isFetchingTraceData &&
|
||||
(!!errorFetchingTraceData || !traceData?.payload?.spans?.length);
|
||||
|
||||
const isDocked = spanDetailVariant === SpanDetailVariant.DOCKED;
|
||||
const isRightDocked = spanDetailVariant === SpanDetailVariant.DOCKED_RIGHT;
|
||||
const isWaterfallDocked = panelState.isOpen && isDocked;
|
||||
const showRightDock = panelState.isOpen && isRightDocked;
|
||||
|
||||
const waterfallChildren = (
|
||||
<ResizableBox
|
||||
defaultHeight={300}
|
||||
minHeight={150}
|
||||
disabled={!isWaterfallDocked}
|
||||
>
|
||||
<TraceWaterfall
|
||||
traceData={traceData}
|
||||
isFetchingTraceData={isFetchingTraceData}
|
||||
errorFetchingTraceData={errorFetchingTraceData}
|
||||
traceId={traceId || ''}
|
||||
interestedSpanId={interestedSpanId}
|
||||
setInterestedSpanId={setInterestedSpanId}
|
||||
uncollapsedNodes={uncollapsedNodes}
|
||||
isFullDataLoaded={isFullDataLoaded}
|
||||
localUncollapsedNodes={localUncollapsedNodes}
|
||||
setLocalUncollapsedNodes={setLocalUncollapsedNodes}
|
||||
selectedSpan={selectedSpan}
|
||||
setSelectedSpan={setSelectedSpan}
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
/>
|
||||
</ResizableBox>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showNoData ? (
|
||||
<NoData />
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.layoutRow}>
|
||||
<div className={styles.content}>
|
||||
<Collapse
|
||||
// @ts-expect-error motion is passed through to rc-collapse to disable animation
|
||||
motion={false}
|
||||
activeKey={activeKeys.filter((k) => k === 'flame')}
|
||||
onChange={(): void => handleCollapseChange('flame')}
|
||||
size="small"
|
||||
expandIcon={renderPanelExpandIcon}
|
||||
className={styles.flameCollapse}
|
||||
items={[
|
||||
{
|
||||
key: 'flame',
|
||||
label: (
|
||||
<div className={styles.collapseLabel}>
|
||||
<span className={styles.collapseTitle}>
|
||||
Flame Graph
|
||||
{traceData?.payload?.totalSpansCount &&
|
||||
traceData.payload.totalSpansCount > FLAMEGRAPH_SPAN_LIMIT && (
|
||||
<WarningPopover
|
||||
message="The total span count exceeds the visualization limit. Displaying a sampled subset of spans in flamegraph."
|
||||
placement="bottomLeft"
|
||||
>
|
||||
<Info
|
||||
size={16}
|
||||
color="var(--l2-foreground)"
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</WarningPopover>
|
||||
)}
|
||||
</span>
|
||||
{traceData?.payload?.totalSpansCount ? (
|
||||
<span className={styles.collapseCount}>
|
||||
<span className={styles.collapseCountItem}>
|
||||
<ChartNoAxesGantt size={13} />
|
||||
Spans: {traceData.payload.totalSpansCount}
|
||||
</span>
|
||||
<span
|
||||
className={cx(styles.collapseCountItem, {
|
||||
[styles.hasErrors]: traceData.payload.totalErrorSpansCount > 0,
|
||||
})}
|
||||
>
|
||||
<TriangleAlert size={13} />
|
||||
Errors:{' '}
|
||||
{traceData.payload.totalErrorSpansCount ?? (
|
||||
<span className="translate-safe">{0}</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<ResizableBox defaultHeight={300} minHeight={100} maxHeight={400}>
|
||||
<TraceFlamegraph
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
selectedSpan={selectedSpan}
|
||||
totalSpansCount={totalSpansCount}
|
||||
/>
|
||||
</ResizableBox>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Collapse
|
||||
// @ts-expect-error motion is passed through to rc-collapse to disable animation
|
||||
motion={false}
|
||||
activeKey={activeKeys.filter((k) => k === 'waterfall')}
|
||||
onChange={(): void => handleCollapseChange('waterfall')}
|
||||
size="small"
|
||||
expandIcon={renderPanelExpandIcon}
|
||||
className={cx(styles.waterfallCollapse, {
|
||||
[styles.isDocked]: isWaterfallDocked,
|
||||
})}
|
||||
items={[
|
||||
{
|
||||
key: 'waterfall',
|
||||
label: 'Waterfall',
|
||||
children: activeKeys.includes('waterfall') ? waterfallChildren : null,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{panelState.isOpen && isDocked && (
|
||||
<div className={styles.dockedSpanDetails}>
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
variant={SpanDetailVariant.DOCKED}
|
||||
onVariantChange={handleVariantChange}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showRightDock && (
|
||||
<ResizableBox
|
||||
handle="left"
|
||||
defaultWidth={rightDockWidth}
|
||||
minWidth={RIGHT_DOCK_MIN}
|
||||
maxWidth={RIGHT_DOCK_MAX}
|
||||
onResize={setRightDockWidth}
|
||||
className={styles.rightDock}
|
||||
>
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
variant={SpanDetailVariant.DOCKED_RIGHT}
|
||||
onVariantChange={handleVariantChange}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||
/>
|
||||
</ResizableBox>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{panelState.isOpen && spanDetailVariant === SpanDetailVariant.DIALOG && (
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
variant={SpanDetailVariant.DIALOG}
|
||||
onVariantChange={handleVariantChange}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceDetailsOverview;
|
||||
@@ -1,5 +0,0 @@
|
||||
function TraceDetailsThread(): JSX.Element {
|
||||
return <div data-testid="trace-details-thread" />;
|
||||
}
|
||||
|
||||
export default TraceDetailsThread;
|
||||
@@ -1,3 +1,10 @@
|
||||
.root {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layoutRow {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@@ -1,6 +0,0 @@
|
||||
.root {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import TraceStoreSync from '../stores/TraceStoreSync';
|
||||
|
||||
import styles from './TraceDetailsWrapper.module.scss';
|
||||
|
||||
interface TraceDetailsWrapperProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function TraceDetailsWrapper({
|
||||
children,
|
||||
}: TraceDetailsWrapperProps): JSX.Element {
|
||||
return (
|
||||
<TraceStoreSync>
|
||||
<div className={styles.root}>{children}</div>
|
||||
</TraceStoreSync>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceDetailsWrapper;
|
||||
@@ -33,5 +33,10 @@
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
background-color: var(--l1-background);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--l2-border);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import cx from 'classnames';
|
||||
import TimelineV3 from 'components/TimelineV3/TimelineV3';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
@@ -18,7 +17,6 @@ import { useFlamegraphZoom } from './hooks/useFlamegraphZoom';
|
||||
import { useScrollToSpan } from './hooks/useScrollToSpan';
|
||||
import { EventRect, FlamegraphCanvasProps, SpanRect } from './types';
|
||||
|
||||
import tooltipStyles from '../TraceTooltip/TraceTooltip.module.scss';
|
||||
import styles from './FlamegraphCanvas.module.scss';
|
||||
|
||||
function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
|
||||
@@ -205,7 +203,7 @@ function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
|
||||
const tooltipElement = tooltipContent
|
||||
? createPortal(
|
||||
<div
|
||||
className={cx(tooltipStyles.surface, styles.tooltip)}
|
||||
className={styles.tooltip}
|
||||
style={{
|
||||
left: Math.min(tooltipContent.clientX + 15, window.innerWidth - 220),
|
||||
top: Math.min(tooltipContent.clientY + 15, window.innerHeight - 100),
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './TraceTooltip.module.scss';
|
||||
|
||||
interface TooltipRowProps {
|
||||
label: string;
|
||||
value: string;
|
||||
isTotal?: boolean;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
function TooltipRow({
|
||||
label,
|
||||
value,
|
||||
isTotal,
|
||||
testId,
|
||||
}: TooltipRowProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.row} data-testid={testId}>
|
||||
<Typography.Text
|
||||
size="small"
|
||||
color={isTotal ? undefined : 'muted'}
|
||||
truncate={1}
|
||||
>
|
||||
{label}
|
||||
</Typography.Text>
|
||||
<Typography.Text size="small" truncate={1} className={styles.rowValue}>
|
||||
{value}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TooltipRow;
|
||||
@@ -1,13 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import styles from './TraceTooltip.module.scss';
|
||||
|
||||
interface TooltipSectionProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function TooltipSection({ children }: TooltipSectionProps): JSX.Element {
|
||||
return <div className={styles.section}>{children}</div>;
|
||||
}
|
||||
|
||||
export default TooltipSection;
|
||||
@@ -1,82 +0,0 @@
|
||||
// Figma `dark/shadow-dropdown` popover surface, shared by every trace-details
|
||||
// tooltip so they look the same.
|
||||
$surface-background: color-mix(in srgb, var(--l3-background) 60%, transparent);
|
||||
$surface-border-color: var(--l2-border);
|
||||
$surface-radius: var(--radius-2);
|
||||
$surface-padding: var(--spacing-4);
|
||||
$surface-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
|
||||
// For plain elements, e.g. the flamegraph's cursor-following portal.
|
||||
.surface {
|
||||
background-color: $surface-background;
|
||||
border: 1px solid $surface-border-color;
|
||||
border-radius: $surface-radius;
|
||||
padding: $surface-padding;
|
||||
box-shadow: $surface-shadow;
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
// For @signozhq/ui Tooltip content: it paints both the bubble and the arrow
|
||||
// from these variables.
|
||||
.tooltipContent {
|
||||
--tooltip-background: #{$surface-background};
|
||||
--tooltip-border-color: #{$surface-border-color};
|
||||
--tooltip-border-radius: #{$surface-radius};
|
||||
--tooltip-padding: #{$surface-padding};
|
||||
--tooltip-box-shadow: #{$surface-shadow};
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
letter-spacing: var(--paragraph-small-400-letter-spacing);
|
||||
color: var(--l1-foreground-hover);
|
||||
}
|
||||
|
||||
.title {
|
||||
--typography-font-family: var(--code-small-500-font-family);
|
||||
--typography-font-size: var(--code-small-500-font-size);
|
||||
--typography-font-weight: var(--code-small-500-font-weight);
|
||||
--typography-line-height: var(--code-small-500-line-height);
|
||||
letter-spacing: var(--code-small-500-letter-spacing);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px 0;
|
||||
border-top: 1px solid;
|
||||
border-image: linear-gradient(
|
||||
90deg,
|
||||
var(--l3-background-transparent),
|
||||
var(--l3-border),
|
||||
var(--l3-background-transparent)
|
||||
)
|
||||
1;
|
||||
|
||||
&:first-child {
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rowValue {
|
||||
flex-shrink: 0;
|
||||
max-width: 60%;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -217,9 +217,8 @@
|
||||
.statusCell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding-left: var(--spacing-2);
|
||||
|
||||
--badge-border-radius: 2px;
|
||||
--badge-padding: 3px 6px;
|
||||
@@ -409,11 +408,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.aiSpanIcon {
|
||||
flex-shrink: 0;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.treeLabel {
|
||||
color: var(--l1-foreground);
|
||||
font-family: 'Inter';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user