mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-13 04:10:27 +01:00
Compare commits
15 Commits
feat/dashb
...
feat/panel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9db935fe87 | ||
|
|
a795d7d940 | ||
|
|
ebae086731 | ||
|
|
7bd66a354c | ||
|
|
7db7b4aeb5 | ||
|
|
d17578eb59 | ||
|
|
0f21f0ad46 | ||
|
|
966ca178ba | ||
|
|
d47ac3c836 | ||
|
|
1bf580df72 | ||
|
|
51852fce5c | ||
|
|
20930d666c | ||
|
|
93d2ce40eb | ||
|
|
10d0480754 | ||
|
|
948d94f50c |
@@ -1,5 +1,6 @@
|
||||
export enum QueryParams {
|
||||
interval = 'interval',
|
||||
editPanelId = 'editPanelId',
|
||||
startTime = 'startTime',
|
||||
endTime = 'endTime',
|
||||
service = 'service',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import {
|
||||
createColumnsAndDataSource,
|
||||
evaluateThresholdWithConvertedValue,
|
||||
getQueryLegend,
|
||||
sortFunction,
|
||||
} from '../utils';
|
||||
@@ -225,3 +226,30 @@ describe('Table Panel utils with QB v5 aggregations', () => {
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// No units passed, so `convertUnit` is a no-op and the comparison runs against
|
||||
// the raw value — exercising `evaluateCondition`'s operator switch directly.
|
||||
describe('evaluateThresholdWithConvertedValue operators', () => {
|
||||
it('handles ordering operators', () => {
|
||||
expect(evaluateThresholdWithConvertedValue(5, 3, '>')).toBe(true);
|
||||
expect(evaluateThresholdWithConvertedValue(2, 3, '>')).toBe(false);
|
||||
expect(evaluateThresholdWithConvertedValue(2, 3, '<')).toBe(true);
|
||||
expect(evaluateThresholdWithConvertedValue(3, 3, '>=')).toBe(true);
|
||||
expect(evaluateThresholdWithConvertedValue(3, 3, '<=')).toBe(true);
|
||||
});
|
||||
|
||||
it('treats = and == as equality', () => {
|
||||
expect(evaluateThresholdWithConvertedValue(3, 3, '=')).toBe(true);
|
||||
expect(evaluateThresholdWithConvertedValue(3, 3, '==')).toBe(true);
|
||||
expect(evaluateThresholdWithConvertedValue(4, 3, '=')).toBe(false);
|
||||
});
|
||||
|
||||
it('handles != as inequality', () => {
|
||||
expect(evaluateThresholdWithConvertedValue(4, 3, '!=')).toBe(true);
|
||||
expect(evaluateThresholdWithConvertedValue(3, 3, '!=')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for an unknown operator', () => {
|
||||
expect(evaluateThresholdWithConvertedValue(3, 3, '~')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,8 +29,11 @@ function evaluateCondition(
|
||||
return value >= thresholdValue;
|
||||
case '<=':
|
||||
return value <= thresholdValue;
|
||||
case '=':
|
||||
case '==':
|
||||
return value === thresholdValue;
|
||||
case '!=':
|
||||
return value !== thresholdValue;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Dispatch, ReactNode, SetStateAction } from 'react';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { ColumnUnit } from 'types/api/dashboard/getAll';
|
||||
|
||||
export type ThresholdOperators = '>' | '<' | '>=' | '<=' | '=';
|
||||
export type ThresholdOperators = '>' | '<' | '>=' | '<=' | '=' | '!=';
|
||||
|
||||
export type ThresholdProps = {
|
||||
index: string;
|
||||
|
||||
@@ -6,6 +6,8 @@ export const operatorOptions: DefaultOptionType[] = [
|
||||
{ value: '>=', label: '>=' },
|
||||
{ value: '<', label: '<' },
|
||||
{ value: '<=', label: '<=' },
|
||||
{ value: '=', label: '=' },
|
||||
{ value: '!=', label: '≠' },
|
||||
];
|
||||
|
||||
export const showAsOptions: DefaultOptionType[] = [
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
.config {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
gap: 16px;
|
||||
background-color: var(--l2-background);
|
||||
border-left: 1px solid var(--l2-border);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Input } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import type { PanelDisplayDraft } from '../types';
|
||||
|
||||
import styles from './ConfigPane.module.scss';
|
||||
|
||||
interface ConfigPaneProps {
|
||||
display: PanelDisplayDraft;
|
||||
onChangeDisplay: (next: Partial<PanelDisplayDraft>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-hand configuration pane. Milestone 1 exposes only the panel title and
|
||||
* description; later milestones render the data-driven section framework
|
||||
* (Formatting, Axes, Legend, …) below these general fields, keyed off the
|
||||
* panel kind's `SectionConfig[]`.
|
||||
*/
|
||||
function ConfigPane({
|
||||
display,
|
||||
onChangeDisplay,
|
||||
}: ConfigPaneProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.config}>
|
||||
<div className={styles.section}>
|
||||
<Typography.Text>Panel settings</Typography.Text>
|
||||
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Title</Typography.Text>
|
||||
<Input
|
||||
data-testid="panel-editor-v2-title"
|
||||
value={display.name}
|
||||
placeholder="Panel title"
|
||||
onChange={(e): void => onChangeDisplay({ name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Description</Typography.Text>
|
||||
<Input.TextArea
|
||||
data-testid="panel-editor-v2-description"
|
||||
value={display.description}
|
||||
placeholder="Add a description"
|
||||
rows={3}
|
||||
onChange={(e): void => onChangeDisplay({ description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigPane;
|
||||
@@ -0,0 +1,22 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background-color: var(--l2-background);
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './Header.module.scss';
|
||||
|
||||
interface HeaderProps {
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
onSave: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function Header({
|
||||
isDirty,
|
||||
isSaving,
|
||||
onSave,
|
||||
onClose,
|
||||
}: HeaderProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.header}>
|
||||
<div className={styles.title}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
suffix={<X size={14} />}
|
||||
data-testid="panel-editor-v2-close"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<Divider type="vertical" />
|
||||
<Typography.Text>Configure panel</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
data-testid="panel-editor-v2-save"
|
||||
disabled={!isDirty || isSaving}
|
||||
loading={isSaving}
|
||||
onClick={onSave}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1,17 @@
|
||||
.root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
.preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: 8px;
|
||||
background-image: radial-gradient(var(--l2-border) 1px, transparent 0);
|
||||
background-size: 20px 20px;
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.header {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.queryType {
|
||||
display: inline-flex;
|
||||
padding: 4px 8px 4px 6px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--l3-background);
|
||||
backdrop-filter: blur(6px);
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
padding: 2% 5% 5% 5%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.surface {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
background: var(--l2-background);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
color: var(--bg-vanilla-400, #8993ae);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports -- seed initial time from global store; never written back
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Spin } from 'antd';
|
||||
import { Loader, Spline } from '@signozhq/icons';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import QueryTypeTag from 'container/NewWidget/LeftContainer/QueryTypeTag';
|
||||
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels';
|
||||
import {
|
||||
type PanelQueryTimeOverride,
|
||||
usePanelQuery,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import PreviewTimePicker, {
|
||||
type PreviewTime,
|
||||
} from '../PreviewTimePicker/PreviewTimePicker';
|
||||
|
||||
import styles from './PreviewPane.module.scss';
|
||||
|
||||
const NS_TO_SEC = 1e9;
|
||||
const SEC_TO_MS = 1e3;
|
||||
|
||||
interface PreviewPaneProps {
|
||||
panelId: string;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live preview for the panel editor. Renders the draft panel through the same
|
||||
* registry + query path the dashboard grid uses (`getPanelDefinition` +
|
||||
* `usePanelQuery`), so the preview is byte-for-byte the production renderer —
|
||||
* only the `panelMode` differs (DASHBOARD_EDIT).
|
||||
*
|
||||
* Time is editor-local (`PreviewTimePicker` never touches global Redux time or
|
||||
* the URL), so changing it here neither modifies nor re-runs the dashboard
|
||||
* behind the overlay. Seeded once from the current global selection so the
|
||||
* preview opens matching the dashboard. The local window is resolved to an
|
||||
* absolute `[startMs, endMs]` and handed to `usePanelQuery` as a time override
|
||||
* (the V5 request takes epoch ms; a relative selection is pinned at the moment
|
||||
* it's picked).
|
||||
*/
|
||||
function PreviewPane({ panelId, panel }: PreviewPaneProps): JSX.Element {
|
||||
const fullKind = panel.spec?.plugin?.kind;
|
||||
const panelDef = getPanelDefinition(fullKind);
|
||||
|
||||
const globalTime = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const [previewTime, setPreviewTime] = useState<PreviewTime>(() =>
|
||||
globalTime.selectedTime === 'custom'
|
||||
? {
|
||||
interval: 'custom',
|
||||
range: [
|
||||
Math.floor(globalTime.minTime / NS_TO_SEC),
|
||||
Math.floor(globalTime.maxTime / NS_TO_SEC),
|
||||
],
|
||||
}
|
||||
: { interval: globalTime.selectedTime, range: null },
|
||||
);
|
||||
|
||||
// Resolve the editor-local selection to an absolute epoch-ms window. Custom
|
||||
// uses the picked range; relative is computed now-based (Redux-independent)
|
||||
// and pinned until the user changes the picker — recomputing "now" each
|
||||
// render would churn the query key into an endless refetch loop.
|
||||
const time = useMemo<PanelQueryTimeOverride>(() => {
|
||||
if (previewTime.range) {
|
||||
return {
|
||||
startMs: previewTime.range[0] * SEC_TO_MS,
|
||||
endMs: previewTime.range[1] * SEC_TO_MS,
|
||||
};
|
||||
}
|
||||
const { start, end } = getStartEndRangeTime({
|
||||
type: 'GLOBAL_TIME',
|
||||
interval: previewTime.interval,
|
||||
});
|
||||
return { startMs: Number(start) * SEC_TO_MS, endMs: Number(end) * SEC_TO_MS };
|
||||
}, [previewTime]);
|
||||
|
||||
const { data, isLoading, error } = usePanelQuery({
|
||||
panel,
|
||||
panelId,
|
||||
enabled: !!panelDef,
|
||||
time,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.preview}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.queryType}>
|
||||
<Spline size={14} />
|
||||
Plotted with <QueryTypeTag queryType={EQueryType.QUERY_BUILDER} />
|
||||
</div>
|
||||
<PreviewTimePicker value={previewTime} onChange={setPreviewTime} />
|
||||
</div>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.surface}>
|
||||
{/* eslint-disable-next-line no-nested-ternary -- 3-way branch on render state */}
|
||||
{!panelDef ? (
|
||||
<div className={styles.state} data-testid="panel-editor-v2-unknown-kind">
|
||||
This panel type is not yet supported in V2.
|
||||
</div>
|
||||
) : isLoading && !data.response ? (
|
||||
<div className={styles.state} data-testid="panel-editor-v2-loading">
|
||||
<Spin indicator={<Loader size={14} className="animate-spin" />} />
|
||||
</div>
|
||||
) : (
|
||||
<panelDef.Renderer
|
||||
panelId={panelId}
|
||||
panel={panel}
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
panelMode={PanelMode.DASHBOARD_EDIT}
|
||||
enableDrillDown={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PreviewPane;
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import CustomTimePicker from 'components/CustomTimePicker/CustomTimePicker';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import type { DateTimeRangeType } from 'container/TopNav/CustomDateTimeModal';
|
||||
import { getOptions } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
import type {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import dayjs from 'dayjs';
|
||||
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
const MS_TO_NS = 1e6;
|
||||
|
||||
export interface PreviewTime {
|
||||
/** Relative shorthand (e.g. `30m`) or `custom`. */
|
||||
interval: Time | CustomTimeType;
|
||||
/** Custom range `[startSec, endSec]`; null for relative. */
|
||||
range: [number, number] | null;
|
||||
}
|
||||
|
||||
interface PreviewTimePickerProps {
|
||||
value: PreviewTime;
|
||||
onChange: (next: PreviewTime) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Time picker for the panel editor preview. Wraps the shared `CustomTimePicker`
|
||||
* with fully-local state — it never reads or writes global Redux time or the
|
||||
* URL, so changing the preview window doesn't touch (or re-run) the dashboard
|
||||
* behind the editor overlay. Selections are emitted via `onChange`; the parent
|
||||
* feeds them to the preview fetch.
|
||||
*/
|
||||
function PreviewTimePicker({
|
||||
value,
|
||||
onChange,
|
||||
}: PreviewTimePickerProps): JSX.Element {
|
||||
const { pathname } = useLocation();
|
||||
const { timezone } = useTimezone();
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
const [customVisible, setCustomVisible] = useState<boolean>(false);
|
||||
|
||||
const { interval, range } = value;
|
||||
const options = useMemo(() => getOptions(pathname), [pathname]);
|
||||
|
||||
// Active window in ms — custom uses the picked range; relative is computed
|
||||
// now-based (Redux-independent). Drives the relative-duration pill.
|
||||
const [startMs, endMs] = useMemo<[number, number]>(() => {
|
||||
if (range) {
|
||||
return [range[0] * 1000, range[1] * 1000];
|
||||
}
|
||||
const { start, end } = getStartEndRangeTime({
|
||||
type: 'GLOBAL_TIME',
|
||||
interval,
|
||||
});
|
||||
return [Number(start) * 1000, Number(end) * 1000];
|
||||
}, [interval, range]);
|
||||
|
||||
// Label shown for a custom range; relative selections render their own
|
||||
// "Last …" label from `selectedTime` inside CustomTimePicker.
|
||||
const selectedValue = useMemo(() => {
|
||||
if (!range) {
|
||||
return '';
|
||||
}
|
||||
const fmt = DATE_TIME_FORMATS.UK_DATETIME_SECONDS;
|
||||
const start = dayjs(startMs).tz(timezone.value).format(fmt);
|
||||
const end = dayjs(endMs).tz(timezone.value).format(fmt);
|
||||
return `${start} - ${end}`;
|
||||
}, [range, startMs, endMs, timezone.value]);
|
||||
|
||||
const onSelect = (next: string): void => {
|
||||
if (next === 'custom') {
|
||||
setCustomVisible(true);
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
onChange({ interval: next as Time, range: null });
|
||||
};
|
||||
|
||||
const onCustomDateHandler = (dateTimeRange: DateTimeRangeType): void => {
|
||||
if (!dateTimeRange) {
|
||||
return;
|
||||
}
|
||||
const [startMoment, endMoment] = dateTimeRange;
|
||||
if (!startMoment || !endMoment) {
|
||||
return;
|
||||
}
|
||||
setCustomVisible(false);
|
||||
setOpen(false);
|
||||
onChange({
|
||||
interval: 'custom',
|
||||
range: [
|
||||
Math.floor(startMoment.toDate().getTime() / 1000),
|
||||
Math.floor(endMoment.toDate().getTime() / 1000),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<CustomTimePicker
|
||||
newPopover
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
items={options}
|
||||
selectedTime={interval}
|
||||
selectedValue={selectedValue}
|
||||
minTime={startMs * MS_TO_NS}
|
||||
maxTime={endMs * MS_TO_NS}
|
||||
// Hides the zoom-out button — it mutates global time, which the editor
|
||||
// must not do.
|
||||
isModalTimeSelection
|
||||
onSelect={onSelect}
|
||||
onError={(): void => {}}
|
||||
onCustomDateHandler={onCustomDateHandler}
|
||||
customDateTimeVisible={customVisible}
|
||||
setCustomDTPickerVisible={setCustomVisible}
|
||||
onValidCustomDateChange={({ timeStr }): void => {
|
||||
setOpen(false);
|
||||
onChange({ interval: timeStr as Time, range: null });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default PreviewTimePicker;
|
||||
@@ -0,0 +1,12 @@
|
||||
.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 30%;
|
||||
margin: 0 16px 16px;
|
||||
border: 1px dashed var(--l2-border);
|
||||
border-radius: 4px;
|
||||
color: var(--l2-foreground);
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Terminal } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './QueryBuilderPlaceholder.module.scss';
|
||||
|
||||
/**
|
||||
* Placeholder for the query builder in the panel editor's left pane. Milestone 2
|
||||
* replaces this with the shared `QueryBuilderV2`, wired through `fromPerses` /
|
||||
* `toPerses` so query edits flow into the draft and re-fetch the preview.
|
||||
*/
|
||||
function QueryBuilderPlaceholder(): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={styles.placeholder}
|
||||
data-testid="panel-editor-v2-query-placeholder"
|
||||
>
|
||||
<Terminal size={16} />
|
||||
<Typography.Text>Query builder coming soon</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default QueryBuilderPlaceholder;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import ConfigPane from '../ConfigPane/ConfigPane';
|
||||
|
||||
describe('ConfigPane', () => {
|
||||
it('renders the seeded title and description', () => {
|
||||
render(
|
||||
<ConfigPane
|
||||
display={{ name: 'CPU', description: 'usage' }}
|
||||
onChangeDisplay={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('panel-editor-v2-title')).toHaveValue('CPU');
|
||||
expect(screen.getByTestId('panel-editor-v2-description')).toHaveValue(
|
||||
'usage',
|
||||
);
|
||||
});
|
||||
|
||||
it('reports title edits via onChangeDisplay', () => {
|
||||
const onChangeDisplay = jest.fn();
|
||||
render(
|
||||
<ConfigPane
|
||||
display={{ name: 'CPU', description: 'usage' }}
|
||||
onChangeDisplay={onChangeDisplay}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByTestId('panel-editor-v2-title'), {
|
||||
target: { value: 'Memory' },
|
||||
});
|
||||
|
||||
expect(onChangeDisplay).toHaveBeenCalledWith({ name: 'Memory' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { usePanelEditorDraft } from '../usePanelEditorDraft';
|
||||
|
||||
function panel(name = 'CPU', description = 'usage'): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name, description },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
|
||||
queries: [],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
describe('usePanelEditorDraft', () => {
|
||||
it('seeds display from the initial panel and starts clean', () => {
|
||||
const { result } = renderHook(() => usePanelEditorDraft(panel()));
|
||||
|
||||
expect(result.current.display).toStrictEqual({
|
||||
name: 'CPU',
|
||||
description: 'usage',
|
||||
});
|
||||
expect(result.current.isDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('updates display and flags the draft dirty', () => {
|
||||
const { result } = renderHook(() => usePanelEditorDraft(panel()));
|
||||
|
||||
act(() => result.current.setDisplay({ name: 'Memory' }));
|
||||
|
||||
expect(result.current.display.name).toBe('Memory');
|
||||
expect(result.current.display.description).toBe('usage');
|
||||
expect(result.current.isDirty).toBe(true);
|
||||
// draft stays in perses shape so preview + save consume it directly
|
||||
expect(result.current.draft.spec?.display?.name).toBe('Memory');
|
||||
});
|
||||
|
||||
it('reset restores the originally-loaded display', () => {
|
||||
const { result } = renderHook(() => usePanelEditorDraft(panel()));
|
||||
|
||||
act(() => result.current.setDisplay({ name: 'Memory', description: 'new' }));
|
||||
act(() => result.current.reset());
|
||||
|
||||
expect(result.current.display).toStrictEqual({
|
||||
name: 'CPU',
|
||||
description: 'usage',
|
||||
});
|
||||
expect(result.current.isDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a panel without display as empty strings', () => {
|
||||
const bare = {
|
||||
kind: 'Panel',
|
||||
spec: { plugin: { kind: 'signoz/PieChartPanel' } },
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
const { result } = renderHook(() => usePanelEditorDraft(bare));
|
||||
|
||||
expect(result.current.display).toStrictEqual({ name: '', description: '' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import {
|
||||
getGetDashboardV2QueryKey,
|
||||
usePatchDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
|
||||
import { usePanelEditorSave } from '../usePanelEditorSave';
|
||||
|
||||
const mockInvalidateQueries = jest.fn();
|
||||
jest.mock('react-query', () => ({
|
||||
useQueryClient: (): { invalidateQueries: jest.Mock } => ({
|
||||
invalidateQueries: mockInvalidateQueries,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
usePatchDashboardV2: jest.fn(),
|
||||
getGetDashboardV2QueryKey: jest.fn(() => ['/api/v2/dashboards/dash-1']),
|
||||
}));
|
||||
|
||||
const mockUsePatch = usePatchDashboardV2 as unknown as jest.Mock;
|
||||
const mockGetQueryKey = getGetDashboardV2QueryKey as unknown as jest.Mock;
|
||||
|
||||
describe('usePanelEditorSave', () => {
|
||||
const mutateAsync = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUsePatch.mockReturnValue({
|
||||
mutateAsync,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('emits an add patch for the panel display and invalidates the dashboard query', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelEditorSave({ dashboardId: 'dash-1', panelId: 'panel-9' }),
|
||||
);
|
||||
|
||||
await result.current.save({ name: 'New title', description: 'desc' });
|
||||
|
||||
expect(mutateAsync).toHaveBeenCalledWith({
|
||||
pathParams: { id: 'dash-1' },
|
||||
data: [
|
||||
{
|
||||
op: 'add',
|
||||
path: '/spec/panels/panel-9/spec/display',
|
||||
value: { name: 'New title', description: 'desc' },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(mockGetQueryKey).toHaveBeenCalledWith({ id: 'dash-1' });
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith([
|
||||
'/api/v2/dashboards/dash-1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('surfaces the mutation loading state as isSaving', () => {
|
||||
mockUsePatch.mockReturnValue({
|
||||
mutateAsync,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelEditorSave({ dashboardId: 'dash-1', panelId: 'panel-9' }),
|
||||
);
|
||||
|
||||
expect(result.current.isSaving).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
useDefaultLayout,
|
||||
} from '@signozhq/ui/resizable';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import ConfigPane from './ConfigPane/ConfigPane';
|
||||
import Header from './Header/Header';
|
||||
import layoutStorage from './layoutStorage';
|
||||
import PreviewPane from './PreviewPane/PreviewPane';
|
||||
import QueryBuilderPlaceholder from './QueryBuilderPlaceholder/QueryBuilderPlaceholder';
|
||||
import { usePanelEditorDraft } from './usePanelEditorDraft';
|
||||
import { usePanelEditorSave } from './usePanelEditorSave';
|
||||
|
||||
import styles from './PanelEditor.module.scss';
|
||||
|
||||
interface PanelEditorContainerProps {
|
||||
dashboardId: string;
|
||||
panelId: string;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** Dismiss the editor overlay (clears the `editPanelId` query param). */
|
||||
onClose: () => void;
|
||||
/** Called after a successful save so the dashboard can refetch. */
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 panel editor rendered as a full-screen overlay on top of the dashboard
|
||||
* view (the dashboard stays mounted underneath). A resizable split holds the
|
||||
* live preview + query builder on the left and the configuration pane on the
|
||||
* right. Owns the draft state and the save round-trip.
|
||||
*/
|
||||
function PanelEditorContainer({
|
||||
dashboardId,
|
||||
panelId,
|
||||
panel,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: PanelEditorContainerProps): JSX.Element {
|
||||
const { draft, display, setDisplay, isDirty } = usePanelEditorDraft(panel);
|
||||
const { save, isSaving } = usePanelEditorSave({ dashboardId, panelId });
|
||||
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
||||
id: 'panel-editor-v2',
|
||||
storage: layoutStorage,
|
||||
});
|
||||
|
||||
const onSave = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
await save(display);
|
||||
toast.success('Panel saved');
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch {
|
||||
toast.error('Failed to save panel');
|
||||
}
|
||||
}, [save, display, onSaved, onClose]);
|
||||
|
||||
// Portal to <body> so the fixed overlay escapes the dashboard content's
|
||||
// stacking context (AppLayout pins `.app-content` at `z-index: 0`, which
|
||||
// would otherwise trap the overlay below the side nav).
|
||||
return createPortal(
|
||||
<div className={styles.root} data-testid="panel-editor-v2">
|
||||
<Header
|
||||
isDirty={isDirty}
|
||||
isSaving={isSaving}
|
||||
onSave={onSave}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<ResizablePanelGroup
|
||||
id="panel-editor-v2"
|
||||
orientation="horizontal"
|
||||
defaultLayout={defaultLayout}
|
||||
onLayoutChanged={onLayoutChanged}
|
||||
>
|
||||
<ResizablePanel minSize="70%" maxSize="80%" defaultSize="75%">
|
||||
<div className={styles.left}>
|
||||
<PreviewPane panelId={panelId} panel={draft} />
|
||||
<QueryBuilderPlaceholder />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle />
|
||||
<ResizablePanel minSize="20%" maxSize="30%" defaultSize="25%">
|
||||
<ConfigPane display={display} onChangeDisplay={setDisplay} />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelEditorContainer;
|
||||
@@ -0,0 +1,17 @@
|
||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
|
||||
/**
|
||||
* `Storage`-shaped adapter (just `getItem`/`setItem`, which is all
|
||||
* `useDefaultLayout` consumes) backed by the scoped localStorage wrappers. The
|
||||
* wrappers prefix keys with the URL base path, so the persisted resizable
|
||||
* layout stays isolated per deployment instead of touching the raw global.
|
||||
*/
|
||||
const layoutStorage: Pick<Storage, 'getItem' | 'setItem'> = {
|
||||
getItem: (key: string): string | null => getLocalStorageApi(key),
|
||||
setItem: (key: string, value: string): void => {
|
||||
setLocalStorageApi(key, value);
|
||||
},
|
||||
};
|
||||
|
||||
export default layoutStorage;
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/** The panel display fields editable in milestone 1 of the V2 panel editor. */
|
||||
export interface PanelDisplayDraft {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local draft state for the panel being edited. The draft is kept as a perses
|
||||
* `DashboardtypesPanelDTO` so the live preview (which feeds the panel renderer)
|
||||
* and the save patch share a single shape — no intermediate translation.
|
||||
*/
|
||||
export interface PanelEditorDraftApi {
|
||||
/** The current (possibly edited) panel. Always a defined object once seeded. */
|
||||
draft: DashboardtypesPanelDTO;
|
||||
/** Read the current display values (name/description) for the config pane. */
|
||||
display: PanelDisplayDraft;
|
||||
/** Patch the panel's display (title/description). */
|
||||
setDisplay: (next: Partial<PanelDisplayDraft>) => void;
|
||||
/** True when the draft diverges from the originally-loaded panel. */
|
||||
isDirty: boolean;
|
||||
/** Restore the draft to the originally-loaded panel. */
|
||||
reset: () => void;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { PanelDisplayDraft, PanelEditorDraftApi } from './types';
|
||||
|
||||
function readDisplay(panel: DashboardtypesPanelDTO): PanelDisplayDraft {
|
||||
return {
|
||||
name: panel.spec?.display?.name ?? '',
|
||||
description: panel.spec?.display?.description ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the editable draft of a single panel. Seeded once from the loaded panel
|
||||
* (`useState` initializer), then mutated locally until the user saves. Keeping
|
||||
* the draft in the perses `DashboardtypesPanelDTO` shape lets the preview pane
|
||||
* render it through the same renderer registry the dashboard uses, and lets the
|
||||
* save hook diff/patch it without any conversion.
|
||||
*/
|
||||
export function usePanelEditorDraft(
|
||||
initialPanel: DashboardtypesPanelDTO,
|
||||
): PanelEditorDraftApi {
|
||||
const [draft, setDraft] = useState<DashboardtypesPanelDTO>(initialPanel);
|
||||
|
||||
const setDisplay = useCallback((next: Partial<PanelDisplayDraft>): void => {
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
spec: {
|
||||
...prev.spec,
|
||||
display: {
|
||||
...prev.spec?.display,
|
||||
...next,
|
||||
},
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const reset = useCallback((): void => {
|
||||
setDraft(initialPanel);
|
||||
}, [initialPanel]);
|
||||
|
||||
const display = useMemo(() => readDisplay(draft), [draft]);
|
||||
|
||||
const isDirty = useMemo(() => {
|
||||
const initial = readDisplay(initialPanel);
|
||||
return (
|
||||
initial.name !== display.name || initial.description !== display.description
|
||||
);
|
||||
}, [initialPanel, display]);
|
||||
|
||||
return { draft, display, setDisplay, isDirty, reset };
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import {
|
||||
getGetDashboardV2QueryKey,
|
||||
usePatchDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import {
|
||||
type DashboardtypesJSONPatchOperationDTO,
|
||||
DashboardtypesPatchOpDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { PanelDisplayDraft } from './types';
|
||||
|
||||
interface UsePanelEditorSaveArgs {
|
||||
dashboardId: string;
|
||||
panelId: string;
|
||||
}
|
||||
|
||||
interface UsePanelEditorSaveApi {
|
||||
save: (display: PanelDisplayDraft) => Promise<void>;
|
||||
isSaving: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists panel edits for the V2 editor via RFC-6902 JSON Patch.
|
||||
*
|
||||
* Milestone 1 only touches the panel's display (title/description), so it emits
|
||||
* a single `add` op against `/spec/panels/{panelId}/spec/display`. `add` doubles
|
||||
* as create-or-replace for the display object, so panels that loaded without a
|
||||
* display are handled without a separate existence check. Later milestones add
|
||||
* ops for queries and the per-kind plugin spec.
|
||||
*/
|
||||
export function usePanelEditorSave({
|
||||
dashboardId,
|
||||
panelId,
|
||||
}: UsePanelEditorSaveArgs): UsePanelEditorSaveApi {
|
||||
const queryClient = useQueryClient();
|
||||
const { mutateAsync, isLoading, error } = usePatchDashboardV2();
|
||||
|
||||
const save = useCallback(
|
||||
async (display: PanelDisplayDraft): Promise<void> => {
|
||||
const ops: DashboardtypesJSONPatchOperationDTO[] = [
|
||||
{
|
||||
op: DashboardtypesPatchOpDTO.add,
|
||||
path: `/spec/panels/${panelId}/spec/display`,
|
||||
value: { name: display.name, description: display.description },
|
||||
},
|
||||
];
|
||||
|
||||
await mutateAsync({ pathParams: { id: dashboardId }, data: ops });
|
||||
await queryClient.invalidateQueries(
|
||||
getGetDashboardV2QueryKey({ id: dashboardId }),
|
||||
);
|
||||
},
|
||||
[dashboardId, panelId, mutateAsync, queryClient],
|
||||
);
|
||||
|
||||
return { save, isSaving: isLoading, error: (error as Error) ?? null };
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
|
||||
/**
|
||||
* Builds a record keyed by builder-query name to that query's groupBy keys
|
||||
* in the V1 `BaseAutocompleteData` shape — the shape `TimeSeries` and the
|
||||
* tooltip plugin consume. Conversion from v5 `GroupByKey` lives at this one
|
||||
* call site that needs the V1 shape; the rest of V2 panel code stays on
|
||||
* v5 types.
|
||||
*/
|
||||
export function useGroupByPerQuery(
|
||||
builderQueries: BuilderQuery[],
|
||||
): Record<string, BaseAutocompleteData[]> {
|
||||
return useMemo(() => {
|
||||
const result: Record<string, BaseAutocompleteData[]> = {};
|
||||
builderQueries.forEach((q) => {
|
||||
if (!q.name) {
|
||||
return;
|
||||
}
|
||||
result[q.name] = (q.groupBy ?? []).map((g) => ({
|
||||
key: g.name,
|
||||
dataType: g.fieldDataType as BaseAutocompleteData['dataType'],
|
||||
type: (g.fieldContext as BaseAutocompleteData['type']) ?? '',
|
||||
id: '',
|
||||
}));
|
||||
});
|
||||
return result;
|
||||
}, [builderQueries]);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { RefObject, useEffect, useRef, useState } from 'react';
|
||||
|
||||
const MIN_FONT_PX = 16;
|
||||
const MAX_FONT_PX = 60;
|
||||
// The value font is sized to a fraction of the container's smaller dimension so
|
||||
// it scales with the panel without overflowing.
|
||||
const FONT_SCALE_DIVISOR = 5;
|
||||
|
||||
/**
|
||||
* Sizes a single large value to its container, recomputing on resize via a
|
||||
* ResizeObserver. Returns the ref to attach to the container and the current
|
||||
* font size (px) to apply to the value text.
|
||||
*/
|
||||
export function useResponsiveFontSize(): {
|
||||
containerRef: RefObject<HTMLDivElement>;
|
||||
fontSize: string;
|
||||
} {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [fontSize, setFontSize] = useState('2.5vw');
|
||||
|
||||
useEffect(() => {
|
||||
const updateFontSize = (): void => {
|
||||
if (!containerRef.current) {
|
||||
return;
|
||||
}
|
||||
const { width, height } = containerRef.current.getBoundingClientRect();
|
||||
const minDimension = Math.min(width, height);
|
||||
const newSize = Math.max(
|
||||
Math.min(minDimension / FONT_SCALE_DIVISOR, MAX_FONT_PX),
|
||||
MIN_FONT_PX,
|
||||
);
|
||||
setFontSize(`${newSize}px`);
|
||||
};
|
||||
|
||||
updateFontSize();
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateFontSize);
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current);
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { containerRef, fontSize };
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { definition as barChart } from './kinds/BarChartPanel/definition';
|
||||
import { definition as histogram } from './kinds/HistogramPanel/definition';
|
||||
import { definition as number } from './kinds/NumberPanel/definition';
|
||||
import { definition as pieChart } from './kinds/PieChartPanel/definition';
|
||||
import { definition as timeSeries } from './kinds/TimeSeriesPanel/definition';
|
||||
import type {
|
||||
PanelRegistry,
|
||||
RenderablePanelDefinition,
|
||||
} from './types/panelDefinition';
|
||||
import type { PanelKind } from './types/panelKind';
|
||||
|
||||
// Pure assembly: each kind owns its own PanelDefinition (see
|
||||
// `kinds/<Kind>/definition.ts`). Registering a new panel = add its folder and a
|
||||
// single entry below — no other central file needs editing.
|
||||
export const PANELS: PanelRegistry = {
|
||||
[timeSeries.kind]: timeSeries,
|
||||
[barChart.kind]: barChart,
|
||||
[histogram.kind]: histogram,
|
||||
[number.kind]: number,
|
||||
[pieChart.kind]: pieChart,
|
||||
};
|
||||
|
||||
export function getPanelDefinition(
|
||||
kind: string | undefined,
|
||||
): RenderablePanelDefinition | undefined {
|
||||
if (!kind) {
|
||||
return undefined;
|
||||
}
|
||||
// The registry is correlated by kind, so a string lookup yields a union over
|
||||
// every kind's exactly-typed definition. The renderer cannot be validated
|
||||
// against that union at the JSX boundary, so widen to the kind-agnostic
|
||||
// surface here — the single, intentional cast for the whole panel system.
|
||||
return PANELS[kind as PanelKind] as unknown as
|
||||
| RenderablePanelDefinition
|
||||
| undefined;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
|
||||
import TooltipFooter from 'container/DashboardContainer/visualization/panels/components/TooltipFooter';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
|
||||
import {
|
||||
flattenTimeSeries,
|
||||
getExecStats,
|
||||
getTimeSeriesResults,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
|
||||
import { prepareAlignedData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/uplotData';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
|
||||
import { getTimeRangeFromQueryRangeRequest } from 'utils/getTimeRange';
|
||||
|
||||
import { useGroupByPerQuery } from '../../hooks/useGroupByPerQuery';
|
||||
import PanelStyles from '../../panel.module.scss';
|
||||
import { PanelRendererProps } from '../../types/rendererProps';
|
||||
import {
|
||||
resolveDecimalPrecision,
|
||||
resolveLegendPosition,
|
||||
} from '../../utils/chartAppearanceMappings';
|
||||
import { getBuilderQueries } from '../../utils/getBuilderQueries';
|
||||
|
||||
import { buildBarChartConfig } from './buildConfig';
|
||||
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
function BarPanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
onClick,
|
||||
onDragSelect,
|
||||
dashboardPreference,
|
||||
panelMode,
|
||||
}: PanelRendererProps<'signoz/BarChartPanel'>): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
// The registry guarantees this Renderer only runs when
|
||||
// `panel.spec.plugin.kind === 'signoz/BarChartPanel'`, so the cast is a
|
||||
// documented boundary narrowing. Memoized so the `?? {}` fallback doesn't
|
||||
// produce a fresh object on each render.
|
||||
const spec = useMemo<DashboardtypesBarChartPanelSpecDTO>(
|
||||
() => (panel?.spec?.plugin?.spec ?? {}) as DashboardtypesBarChartPanelSpecDTO,
|
||||
[panel?.spec?.plugin?.spec],
|
||||
);
|
||||
|
||||
const builderQueries = useMemo(
|
||||
() => getBuilderQueries(panel?.spec?.queries),
|
||||
[panel?.spec?.queries],
|
||||
);
|
||||
|
||||
// X-scale clamps come from the request that produced the data (falls back
|
||||
// to the global picker inside the helper). The generated request DTO is
|
||||
// structurally the hand-written V5 request; the cast is the boundary.
|
||||
const { minTimeScale, maxTimeScale } = useMemo(() => {
|
||||
const { startTime, endTime } = getTimeRangeFromQueryRangeRequest(
|
||||
data?.requestPayload as unknown as QueryRangeRequestV5 | undefined,
|
||||
);
|
||||
return { minTimeScale: startTime, maxTimeScale: endTime };
|
||||
}, [data?.requestPayload]);
|
||||
|
||||
const groupByPerQuery = useGroupByPerQuery(builderQueries);
|
||||
|
||||
const flatSeries = useMemo(
|
||||
() =>
|
||||
flattenTimeSeries(
|
||||
getTimeSeriesResults(data?.response),
|
||||
data?.legendMap ?? {},
|
||||
),
|
||||
[data?.response, data?.legendMap],
|
||||
);
|
||||
|
||||
const config = useMemo(
|
||||
() =>
|
||||
buildBarChartConfig({
|
||||
panelId,
|
||||
spec,
|
||||
builderQueries,
|
||||
series: flatSeries,
|
||||
stepIntervals: getExecStats(data?.response)?.stepIntervals,
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelMode,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
onDragSelect,
|
||||
}),
|
||||
[
|
||||
panelId,
|
||||
spec,
|
||||
builderQueries,
|
||||
flatSeries,
|
||||
data?.response,
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelMode,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
onDragSelect,
|
||||
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
|
||||
// Rebuild it on syncMode changes so the new chart instance starts from a
|
||||
// clean config — otherwise switching to "No Sync" would inherit stale sync
|
||||
// settings from the previous mode.
|
||||
dashboardPreference?.syncMode,
|
||||
],
|
||||
);
|
||||
|
||||
const chartData = useMemo(() => prepareAlignedData(flatSeries), [flatSeries]);
|
||||
|
||||
const decimalPrecision = useMemo(
|
||||
() => resolveDecimalPrecision(spec.formatting?.decimalPrecision),
|
||||
[spec.formatting?.decimalPrecision],
|
||||
);
|
||||
|
||||
const legendPosition = useMemo(() => {
|
||||
return resolveLegendPosition(spec.legend?.position);
|
||||
}, [spec.legend?.position]);
|
||||
|
||||
const renderTooltipFooter = useCallback(
|
||||
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
|
||||
<TooltipFooter id={panelId} isPinned={isPinned} dismiss={dismiss} />
|
||||
),
|
||||
[panelId],
|
||||
);
|
||||
|
||||
// The uPlot key prop is the only way to force a full teardown and re-mount
|
||||
// of the chart. Including syncMode/syncFilterMode in the key ensures changes
|
||||
// to these preferences trigger a fresh chart instance, preventing stale
|
||||
// sync wiring from being inherited.
|
||||
const key = `${dashboardPreference?.syncMode}-${dashboardPreference?.syncFilterMode}`;
|
||||
|
||||
const handleChartClick = useCallback(
|
||||
(args: ChartClickData) => {
|
||||
onClick?.(args);
|
||||
},
|
||||
[onClick],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={graphRef}
|
||||
data-testid="bar-panel-renderer"
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
|
||||
<BarChart
|
||||
key={key}
|
||||
config={config}
|
||||
data={chartData}
|
||||
legendConfig={{ position: legendPosition }}
|
||||
groupByPerQuery={groupByPerQuery}
|
||||
canPinTooltip
|
||||
timezone={timezone}
|
||||
yAxisUnit={spec.formatting?.unit}
|
||||
decimalPrecision={decimalPrecision}
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
syncMode={dashboardPreference?.syncMode}
|
||||
syncFilterMode={dashboardPreference?.syncFilterMode}
|
||||
isStackedBarChart={spec.visualization?.stackedBarChart ?? false}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
onClick={handleChartClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BarPanelRenderer;
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { buildBaseConfig } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
|
||||
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
|
||||
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import { toClickPluginPayload } from 'pages/DashboardPageV2/DashboardContainer/queryV5/uplotData';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
import { DrawStyle } from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
|
||||
export interface BuildBarChartConfigArgs {
|
||||
panelId: string;
|
||||
spec: DashboardtypesBarChartPanelSpecDTO;
|
||||
/**
|
||||
* Flat list of builder queries on this panel (see `getBuilderQueries`).
|
||||
* Powers per-query legend resolution; empty for non-builder panels.
|
||||
*/
|
||||
builderQueries: BuilderQuery[];
|
||||
/** Flattened V5 series (see `flattenTimeSeries`). */
|
||||
series: PanelSeries[];
|
||||
/** Per-query step intervals from the response exec stats. */
|
||||
stepIntervals?: Record<string, number>;
|
||||
isDarkMode: boolean;
|
||||
timezone: Timezone;
|
||||
panelMode: PanelMode;
|
||||
onDragSelect?: (start: number, end: number) => void;
|
||||
onClick?: OnClickPluginOpts['onClick'];
|
||||
minTimeScale?: number;
|
||||
maxTimeScale?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a fully-wired `UPlotConfigBuilder` for a Bar chart panel.
|
||||
*
|
||||
* Delegates the panel-agnostic scaffolding (scales, thresholds, axes,
|
||||
* drag-to-zoom, click plugin) to the shared `buildBaseConfig`, then layers
|
||||
* in the Bar-specific concerns: optional stacking via uPlot bands, plus
|
||||
* one bar series per result row.
|
||||
*/
|
||||
export function buildBarChartConfig({
|
||||
panelId,
|
||||
spec,
|
||||
builderQueries,
|
||||
series,
|
||||
stepIntervals,
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelMode,
|
||||
onDragSelect,
|
||||
onClick,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
}: BuildBarChartConfigArgs): UPlotConfigBuilder {
|
||||
const builder = buildBaseConfig({
|
||||
panelId,
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelMode,
|
||||
isLogScale: spec.axes?.isLogScale,
|
||||
softMin: spec.axes?.softMin ?? undefined,
|
||||
softMax: spec.axes?.softMax ?? undefined,
|
||||
formatting: spec.formatting,
|
||||
thresholds: spec.thresholds,
|
||||
stepIntervals,
|
||||
clickPayload: toClickPluginPayload(series),
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
onDragSelect,
|
||||
onClick,
|
||||
});
|
||||
|
||||
addSeries({
|
||||
builder,
|
||||
spec,
|
||||
builderQueries,
|
||||
series,
|
||||
stepIntervals,
|
||||
isDarkMode,
|
||||
});
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
interface AddSeriesArgs {
|
||||
builder: UPlotConfigBuilder;
|
||||
spec: DashboardtypesBarChartPanelSpecDTO;
|
||||
builderQueries: BuilderQuery[];
|
||||
series: PanelSeries[];
|
||||
stepIntervals?: Record<string, number>;
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds one bar series per flattened V5 series, plus uPlot bands for stacking
|
||||
* when `spec.visualization.stackedBarChart` is set. Each series receives its
|
||||
* own per-query step interval so bar widths line up with the actual
|
||||
* sampling cadence reported by the backend.
|
||||
*
|
||||
* Order must match `prepareAlignedData` — both iterate the same flat list.
|
||||
*/
|
||||
function addSeries({
|
||||
builder,
|
||||
spec,
|
||||
builderQueries,
|
||||
series,
|
||||
stepIntervals,
|
||||
isDarkMode,
|
||||
}: AddSeriesArgs): void {
|
||||
const colorMapping = spec.legend?.customColors ?? {};
|
||||
|
||||
if (spec.visualization?.stackedBarChart) {
|
||||
// uPlot uses 1-based series indices (index 0 is the timestamp axis);
|
||||
// `+1` keeps the band targets aligned with the series we're about to add.
|
||||
builder.setBands(getInitialStackedBands(series.length + 1));
|
||||
}
|
||||
|
||||
series.forEach((s) => {
|
||||
const baseLabel = getLabelName(s.labels, s.queryName, s.legend);
|
||||
const label = resolveSeriesLabelV5(s, builderQueries, baseLabel);
|
||||
const stepInterval = s.queryName ? stepIntervals?.[s.queryName] : undefined;
|
||||
|
||||
builder.addSeries({
|
||||
scaleKey: 'y',
|
||||
drawStyle: DrawStyle.Bar,
|
||||
label,
|
||||
colorMapping,
|
||||
isDarkMode,
|
||||
stepInterval,
|
||||
metric: s.labels,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
|
||||
kind: 'signoz/BarChartPanel',
|
||||
displayName: 'Bar Chart',
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
actions: { view: true, edit: true, download: false, createAlert: true },
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'formatting', controls: { unit: true, decimals: true } },
|
||||
{ kind: 'axes', controls: { minMax: true, unit: true, logScale: true } },
|
||||
{ kind: 'legend', controls: { position: true, mode: true } },
|
||||
{ kind: 'thresholds', controls: { list: true } },
|
||||
{ kind: 'chartAppearance', controls: { stacked: true } },
|
||||
];
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import Histogram from 'container/DashboardContainer/visualization/charts/Histogram/Histogram';
|
||||
import TooltipFooter from 'container/DashboardContainer/visualization/panels/components/TooltipFooter';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
|
||||
import {
|
||||
flattenTimeSeries,
|
||||
getTimeSeriesResults,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import PanelStyles from '../../panel.module.scss';
|
||||
import { PanelRendererProps } from '../../types/rendererProps';
|
||||
import { resolveLegendPosition } from '../../utils/chartAppearanceMappings';
|
||||
import { getBuilderQueries } from '../../utils/getBuilderQueries';
|
||||
|
||||
import { buildHistogramConfig } from './buildConfig';
|
||||
import { prepareHistogramData } from './prepareData';
|
||||
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
function HistogramPanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
panelMode,
|
||||
onClick,
|
||||
}: PanelRendererProps<'signoz/HistogramPanel'>): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
// The registry guarantees this Renderer only runs when
|
||||
// `panel.spec.plugin.kind === 'signoz/HistogramPanel'`, so the cast is a
|
||||
// documented boundary narrowing.
|
||||
const spec = useMemo<DashboardtypesHistogramPanelSpecDTO>(
|
||||
() =>
|
||||
(panel?.spec?.plugin?.spec ?? {}) as DashboardtypesHistogramPanelSpecDTO,
|
||||
[panel?.spec?.plugin?.spec],
|
||||
);
|
||||
|
||||
const builderQueries = useMemo(
|
||||
() => getBuilderQueries(panel?.spec?.queries),
|
||||
[panel?.spec?.queries],
|
||||
);
|
||||
|
||||
const flatSeries = useMemo(
|
||||
() =>
|
||||
flattenTimeSeries(
|
||||
getTimeSeriesResults(data?.response),
|
||||
data?.legendMap ?? {},
|
||||
),
|
||||
[data?.response, data?.legendMap],
|
||||
);
|
||||
|
||||
const config = useMemo(
|
||||
() =>
|
||||
buildHistogramConfig({
|
||||
panelId,
|
||||
spec,
|
||||
builderQueries,
|
||||
series: flatSeries,
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelMode,
|
||||
}),
|
||||
[panelId, spec, builderQueries, flatSeries, isDarkMode, timezone, panelMode],
|
||||
);
|
||||
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
prepareHistogramData({
|
||||
series: flatSeries,
|
||||
bucketWidth: spec.histogramBuckets?.bucketWidth ?? undefined,
|
||||
bucketCount: spec.histogramBuckets?.bucketCount ?? undefined,
|
||||
mergeAllActiveQueries: spec.histogramBuckets?.mergeAllActiveQueries,
|
||||
}),
|
||||
[
|
||||
flatSeries,
|
||||
spec.histogramBuckets?.bucketWidth,
|
||||
spec.histogramBuckets?.bucketCount,
|
||||
spec.histogramBuckets?.mergeAllActiveQueries,
|
||||
],
|
||||
);
|
||||
|
||||
const legendPosition = useMemo(
|
||||
() => resolveLegendPosition(spec.legend?.position),
|
||||
[spec.legend?.position],
|
||||
);
|
||||
|
||||
const renderTooltipFooter = useCallback(
|
||||
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
|
||||
<TooltipFooter
|
||||
id={panelId}
|
||||
isPinned={isPinned}
|
||||
dismiss={dismiss}
|
||||
canDrilldown={false}
|
||||
/>
|
||||
),
|
||||
[panelId],
|
||||
);
|
||||
|
||||
const isQueriesMerged = spec.histogramBuckets?.mergeAllActiveQueries ?? false;
|
||||
|
||||
const handleChartClick = useCallback(
|
||||
(args: ChartClickData) => {
|
||||
onClick?.(args);
|
||||
},
|
||||
[onClick],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={graphRef}
|
||||
data-testid="histogram-panel-renderer"
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
|
||||
<Histogram
|
||||
key={panelId}
|
||||
config={config}
|
||||
data={chartData as uPlot.AlignedData}
|
||||
legendConfig={{ position: legendPosition }}
|
||||
canPinTooltip
|
||||
isQueriesMerged={isQueriesMerged}
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
onClick={handleChartClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HistogramPanelRenderer;
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { buildBaseConfig } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
|
||||
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
|
||||
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { DrawStyle } from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
|
||||
const POINT_SIZE = 5;
|
||||
const BAR_WIDTH_FACTOR = 1;
|
||||
// Merged-series colors mirror the V1 default — single histogram bin gets a
|
||||
// fixed blue-ish pair so the merged view looks the same as before.
|
||||
const MERGED_SERIES_LINE_COLOR = '#3f5ecc';
|
||||
const MERGED_SERIES_FILL_COLOR = '#4E74F8';
|
||||
|
||||
export interface BuildHistogramConfigArgs {
|
||||
panelId: string;
|
||||
spec: DashboardtypesHistogramPanelSpecDTO;
|
||||
/** Builder queries on this panel — used to resolve per-series labels. */
|
||||
builderQueries: BuilderQuery[];
|
||||
/** Flattened V5 series (see `flattenTimeSeries`). */
|
||||
series: PanelSeries[];
|
||||
isDarkMode: boolean;
|
||||
timezone: Timezone;
|
||||
panelMode: PanelMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a fully-wired `UPlotConfigBuilder` for a Histogram panel.
|
||||
*
|
||||
* Unlike time-axis panels, histograms have no time scale and no drag-to-zoom.
|
||||
* We still reuse `buildBaseConfig` for the consistent scaffolding (thresholds,
|
||||
* axes, click plugin) but then override the X/Y scales to be auto-linear
|
||||
* (`time: false, auto: true`) and install a histogram-specific cursor that
|
||||
* disables drag-pan and tightens focus proximity.
|
||||
*/
|
||||
export function buildHistogramConfig({
|
||||
panelId,
|
||||
spec,
|
||||
builderQueries,
|
||||
series,
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelMode,
|
||||
}: BuildHistogramConfigArgs): UPlotConfigBuilder {
|
||||
// Histograms have no time axis — no stepIntervals, and no click plugin
|
||||
// (the renderer passes no onClick), so the base config needs no response.
|
||||
const builder = buildBaseConfig({
|
||||
panelId,
|
||||
panelType: PANEL_TYPES.HISTOGRAM,
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelMode,
|
||||
});
|
||||
|
||||
builder.setCursor({
|
||||
drag: { x: false, y: false, setScale: true },
|
||||
focus: { prox: 1e3 },
|
||||
});
|
||||
|
||||
// Override the time-axis scales from `buildBaseConfig` — histograms are
|
||||
// distribution plots, not time series.
|
||||
builder.addScale({ scaleKey: 'x', time: false, auto: true });
|
||||
builder.addScale({ scaleKey: 'y', time: false, auto: true, min: 0 });
|
||||
|
||||
addSeries({ builder, spec, builderQueries, series, isDarkMode });
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
interface AddSeriesArgs {
|
||||
builder: UPlotConfigBuilder;
|
||||
spec: DashboardtypesHistogramPanelSpecDTO;
|
||||
builderQueries: BuilderQuery[];
|
||||
series: PanelSeries[];
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds histogram bar series to the builder. When `mergeAllActiveQueries` is
|
||||
* set, `prepareHistogramData` produces a single Y column, so we add exactly
|
||||
* one series with the fixed merged-mode colors. Otherwise one series per
|
||||
* result row, with labels resolved via the standard legend matrix.
|
||||
*/
|
||||
function addSeries({
|
||||
builder,
|
||||
spec,
|
||||
builderQueries,
|
||||
series,
|
||||
isDarkMode,
|
||||
}: AddSeriesArgs): void {
|
||||
const colorMapping = spec.legend?.customColors ?? {};
|
||||
const mergeAllActiveQueries =
|
||||
spec.histogramBuckets?.mergeAllActiveQueries ?? false;
|
||||
|
||||
if (mergeAllActiveQueries) {
|
||||
builder.addSeries({
|
||||
scaleKey: 'y',
|
||||
label: '',
|
||||
drawStyle: DrawStyle.Histogram,
|
||||
colorMapping,
|
||||
barWidthFactor: BAR_WIDTH_FACTOR,
|
||||
pointSize: POINT_SIZE,
|
||||
lineColor: MERGED_SERIES_LINE_COLOR,
|
||||
fillColor: MERGED_SERIES_FILL_COLOR,
|
||||
isDarkMode,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
series.forEach((s) => {
|
||||
const baseLabel = getLabelName(s.labels, s.queryName, s.legend);
|
||||
const label = resolveSeriesLabelV5(s, builderQueries, baseLabel);
|
||||
|
||||
builder.addSeries({
|
||||
scaleKey: 'y',
|
||||
label,
|
||||
drawStyle: DrawStyle.Histogram,
|
||||
colorMapping,
|
||||
barWidthFactor: BAR_WIDTH_FACTOR,
|
||||
pointSize: POINT_SIZE,
|
||||
isDarkMode,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
|
||||
kind: 'signoz/HistogramPanel',
|
||||
displayName: 'Histogram',
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
actions: { view: true, edit: true, download: false, createAlert: true },
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import { histogramBucketSizes } from '@grafana/data';
|
||||
import { DEFAULT_BUCKET_COUNT } from 'container/PanelWrapper/constants';
|
||||
import {
|
||||
buildHistogramBuckets,
|
||||
mergeAlignedDataTables,
|
||||
prependNullBinToFirstHistogramSeries,
|
||||
replaceUndefinedWithNullInAlignedData,
|
||||
} from 'container/DashboardContainer/visualization/panels/utils/histogram';
|
||||
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import { AlignedData } from 'uplot';
|
||||
import { incrRoundDn, roundDecimals } from 'utils/round';
|
||||
|
||||
export interface PrepareHistogramDataArgs {
|
||||
/** Flattened V5 series (see `flattenTimeSeries`). */
|
||||
series: PanelSeries[];
|
||||
bucketWidth?: number;
|
||||
bucketCount?: number;
|
||||
mergeAllActiveQueries?: boolean;
|
||||
}
|
||||
|
||||
const BUCKET_OFFSET = 0;
|
||||
const sortAscending = (a: number, b: number): number => a - b;
|
||||
|
||||
/**
|
||||
* Bins raw series values into a uPlot-aligned histogram. Picks a bucket size
|
||||
* either from `bucketWidth` (explicit override) or the smallest predefined
|
||||
* Grafana bucket that fits the data's `range / bucketCount` target while
|
||||
* staying ≥ the data's smallest non-zero delta (so we never sub-divide below
|
||||
* the resolution of the input).
|
||||
*
|
||||
* Empty input → `[[]]` (a valid empty AlignedData uPlot accepts).
|
||||
*/
|
||||
export function prepareHistogramData({
|
||||
series,
|
||||
bucketWidth,
|
||||
bucketCount = DEFAULT_BUCKET_COUNT,
|
||||
mergeAllActiveQueries = false,
|
||||
}: PrepareHistogramDataArgs): AlignedData {
|
||||
const values = extractNumericValues(series);
|
||||
if (values.length === 0) {
|
||||
return [[]];
|
||||
}
|
||||
|
||||
const sorted = [...values].sort(sortAscending);
|
||||
const range = sorted[sorted.length - 1] - sorted[0];
|
||||
const smallestDelta = computeSmallestDelta(sorted);
|
||||
let bucketSize = selectBucketSize({
|
||||
range,
|
||||
bucketCount,
|
||||
smallestDelta,
|
||||
bucketWidthOverride: bucketWidth,
|
||||
});
|
||||
if (bucketSize <= 0) {
|
||||
bucketSize = range > 0 ? range / bucketCount : 1;
|
||||
}
|
||||
|
||||
const getBucket = (v: number): number =>
|
||||
roundDecimals(incrRoundDn(v - BUCKET_OFFSET, bucketSize) + BUCKET_OFFSET, 9);
|
||||
|
||||
const frames = buildFrames(series, mergeAllActiveQueries);
|
||||
// Merged mode folds every query into frame 0 and leaves trailing empty
|
||||
// frames — drop those. Per-query mode must keep one column per result row
|
||||
// (even empty queries), or the data column count drifts below the series
|
||||
// count `buildHistogramConfig` adds per row → uPlot renders nothing.
|
||||
const histograms: AlignedData[] = frames
|
||||
.filter((frame) => !mergeAllActiveQueries || frame.length > 0)
|
||||
.map((frame) => buildHistogramBuckets(frame, getBucket, sortAscending));
|
||||
|
||||
if (histograms.length === 0) {
|
||||
return [[]];
|
||||
}
|
||||
|
||||
const merged = mergeAlignedDataTables(histograms);
|
||||
replaceUndefinedWithNullInAlignedData(merged);
|
||||
prependNullBinToFirstHistogramSeries(merged, bucketSize);
|
||||
return merged;
|
||||
}
|
||||
|
||||
// Non-finite samples degrade to 0 (legacy `parseFloat(...) || 0` parity).
|
||||
function toBinnableValue(value: number): number {
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
function extractNumericValues(series: PanelSeries[]): number[] {
|
||||
const values: number[] = [];
|
||||
for (const s of series) {
|
||||
for (const point of s.values) {
|
||||
values.push(toBinnableValue(point.value));
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function computeSmallestDelta(sortedValues: number[]): number {
|
||||
if (sortedValues.length <= 1) {
|
||||
return 0;
|
||||
}
|
||||
let smallest = Infinity;
|
||||
for (let i = 1; i < sortedValues.length; i++) {
|
||||
const delta = sortedValues[i] - sortedValues[i - 1];
|
||||
if (delta > 0) {
|
||||
smallest = Math.min(smallest, delta);
|
||||
}
|
||||
}
|
||||
return smallest === Infinity ? 0 : smallest;
|
||||
}
|
||||
|
||||
function selectBucketSize({
|
||||
range,
|
||||
bucketCount,
|
||||
smallestDelta,
|
||||
bucketWidthOverride,
|
||||
}: {
|
||||
range: number;
|
||||
bucketCount: number;
|
||||
smallestDelta: number;
|
||||
bucketWidthOverride?: number;
|
||||
}): number {
|
||||
if (bucketWidthOverride != null && bucketWidthOverride > 0) {
|
||||
return bucketWidthOverride;
|
||||
}
|
||||
const targetSize = range / bucketCount;
|
||||
for (const candidate of histogramBucketSizes) {
|
||||
if (targetSize < candidate && candidate >= smallestDelta) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// When merging is on, fold all frames into the first; the trailing empty
|
||||
// frames stay in the array so downstream `.filter(length > 0)` drops them.
|
||||
function buildFrames(
|
||||
series: PanelSeries[],
|
||||
mergeAllActiveQueries: boolean,
|
||||
): number[][] {
|
||||
const frames: number[][] = series.map((s) =>
|
||||
s.values.map((point) => toBinnableValue(point.value)),
|
||||
);
|
||||
if (mergeAllActiveQueries && frames.length > 1) {
|
||||
const first = frames[0];
|
||||
for (let i = 1; i < frames.length; i++) {
|
||||
first.push(...frames[i]);
|
||||
frames[i] = [];
|
||||
}
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'legend', controls: { position: true, mode: true } },
|
||||
{ kind: 'buckets', controls: { count: true } },
|
||||
];
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { DashboardtypesNumberPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { prepareScalarTables } from 'pages/DashboardPageV2/DashboardContainer/queryV5/prepareScalarTables';
|
||||
import { getScalarResults } from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
|
||||
|
||||
import PanelStyles from '../../panel.module.scss';
|
||||
import { PanelRendererProps } from '../../types/rendererProps';
|
||||
import { formatPanelValue } from '../../utils/formatPanelValue';
|
||||
import { resolveDecimalPrecision } from '../../utils/chartAppearanceMappings';
|
||||
|
||||
import { prepareNumberData } from './prepareData';
|
||||
import { mapNumberThresholds } from './utils';
|
||||
import ValueDisplay from './components/ValueDisplay/ValueDisplay';
|
||||
|
||||
function NumberPanelRenderer({
|
||||
panel,
|
||||
data,
|
||||
}: PanelRendererProps<'signoz/NumberPanel'>): JSX.Element {
|
||||
// The registry guarantees this Renderer only runs when
|
||||
// `panel.spec.plugin.kind === 'signoz/NumberPanel'`, so the cast is a
|
||||
// documented boundary narrowing. Memoized so the `?? {}` fallback doesn't
|
||||
// produce a fresh object on each render.
|
||||
const spec = useMemo<DashboardtypesNumberPanelSpecDTO>(
|
||||
() => (panel?.spec?.plugin?.spec ?? {}) as DashboardtypesNumberPanelSpecDTO,
|
||||
[panel?.spec?.plugin?.spec],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() =>
|
||||
prepareNumberData(
|
||||
prepareScalarTables({
|
||||
results: getScalarResults(data?.response),
|
||||
legendMap: data?.legendMap ?? {},
|
||||
requestPayload: data?.requestPayload,
|
||||
}),
|
||||
),
|
||||
[data?.response, data?.legendMap, data?.requestPayload],
|
||||
);
|
||||
|
||||
const thresholds = useMemo(
|
||||
() => mapNumberThresholds(spec.thresholds),
|
||||
[spec.thresholds],
|
||||
);
|
||||
|
||||
const decimalPrecision = useMemo(
|
||||
() => resolveDecimalPrecision(spec.formatting?.decimalPrecision),
|
||||
[spec.formatting?.decimalPrecision],
|
||||
);
|
||||
|
||||
const unit = spec.formatting?.unit;
|
||||
|
||||
// Precision is applied regardless of whether a unit is set (see
|
||||
// `formatPanelValue`), so decimal-precision changes always take effect.
|
||||
const formattedValue = useMemo(
|
||||
() => (value === null ? '' : formatPanelValue(value, unit, decimalPrecision)),
|
||||
[value, unit, decimalPrecision],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="number-panel-renderer"
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{value === null ? (
|
||||
<Typography.Text data-testid="number-panel-no-data">
|
||||
No Data
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<ValueDisplay
|
||||
value={formattedValue}
|
||||
rawValue={value}
|
||||
thresholds={thresholds}
|
||||
unit={unit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NumberPanelRenderer;
|
||||
@@ -0,0 +1,155 @@
|
||||
import {
|
||||
DashboardtypesComparisonOperatorDTO,
|
||||
type DashboardtypesNumberPanelSpecDTO,
|
||||
type DashboardtypesPanelDTO,
|
||||
DashboardtypesThresholdFormatDTO,
|
||||
type QueryRangeV5200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import { BaseRendererProps } from '../../../types/rendererProps';
|
||||
import BaseNumberPanelRenderer from '../Renderer';
|
||||
|
||||
// The kind's interaction map is `Record<string, never>`, which makes the strict
|
||||
// `PanelRendererProps<'signoz/NumberPanel'>` intersection impossible to satisfy
|
||||
// with a literal. NumberPanel reads no interaction props, so render it against
|
||||
// the base prop surface.
|
||||
const NumberPanelRenderer =
|
||||
BaseNumberPanelRenderer as React.FC<BaseRendererProps>;
|
||||
|
||||
// ValueDisplay observes its container to size the font.
|
||||
window.ResizeObserver =
|
||||
window.ResizeObserver ||
|
||||
jest.fn().mockImplementation(() => ({
|
||||
disconnect: jest.fn(),
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
}));
|
||||
|
||||
function panelWith(
|
||||
spec: DashboardtypesNumberPanelSpecDTO,
|
||||
): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: { plugin: { kind: 'signoz/NumberPanel', spec } },
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
// V5 scalar response: one table per query, value in the aggregation column.
|
||||
function dataWith(value: string | number): PanelQueryData {
|
||||
return {
|
||||
response: {
|
||||
status: 'success',
|
||||
data: {
|
||||
type: 'scalar',
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
queryName: 'A',
|
||||
columns: [
|
||||
{
|
||||
name: '__result',
|
||||
queryName: 'A',
|
||||
columnType: 'aggregation',
|
||||
aggregationIndex: 0,
|
||||
},
|
||||
],
|
||||
data: [[value]],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as unknown as QueryRangeV5200,
|
||||
requestPayload: undefined,
|
||||
legendMap: {},
|
||||
};
|
||||
}
|
||||
|
||||
const emptyData: PanelQueryData = {
|
||||
response: {
|
||||
status: 'success',
|
||||
data: { type: 'scalar', data: { results: [] } },
|
||||
} as unknown as QueryRangeV5200,
|
||||
requestPayload: undefined,
|
||||
legendMap: {},
|
||||
};
|
||||
|
||||
// NumberPanel adds no interaction props (its interaction map is
|
||||
// `Record<string, never>`), so the base renderer props fully describe it.
|
||||
function renderPanel(
|
||||
props: Partial<BaseRendererProps>,
|
||||
): ReturnType<typeof render> {
|
||||
const baseProps: BaseRendererProps = {
|
||||
panelId: 'panel-1',
|
||||
panel: panelWith({}),
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
panelMode: PanelMode.DASHBOARD_VIEW,
|
||||
...props,
|
||||
};
|
||||
return render(<NumberPanelRenderer {...baseProps} />);
|
||||
}
|
||||
|
||||
describe('NumberPanelRenderer', () => {
|
||||
it('renders the value with its y-axis unit', () => {
|
||||
const { getByText } = renderPanel({
|
||||
panel: panelWith({ formatting: { unit: 'ms' } }),
|
||||
data: dataWith('295.4299833508185'),
|
||||
});
|
||||
|
||||
expect(getByText('295.43')).toBeInTheDocument();
|
||||
expect(getByText('ms')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Regression: with no unit configured, decimal precision must still apply.
|
||||
// Previously the renderer fell back to `value.toString()` whenever the unit
|
||||
// was empty, so precision changes had no effect on unitless panels.
|
||||
it('applies decimal precision even when no unit is set', () => {
|
||||
const { getByText, queryByText } = renderPanel({
|
||||
panel: panelWith({}),
|
||||
data: dataWith('3.14159'),
|
||||
});
|
||||
|
||||
expect(getByText('3.14')).toBeInTheDocument();
|
||||
expect(queryByText('3.14159')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders No Data when the response has no scalar results', () => {
|
||||
const { getByTestId } = renderPanel({ data: emptyData });
|
||||
|
||||
expect(getByTestId('number-panel-no-data')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders No Data when the response is absent', () => {
|
||||
const { getByTestId } = renderPanel({ data: undefined });
|
||||
|
||||
expect(getByTestId('number-panel-no-data')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces the conflicting-thresholds indicator when a value matches multiple thresholds', () => {
|
||||
const { getByTestId } = renderPanel({
|
||||
panel: panelWith({
|
||||
thresholds: [
|
||||
{
|
||||
color: '#f00',
|
||||
operator: DashboardtypesComparisonOperatorDTO.above,
|
||||
value: 0,
|
||||
format: DashboardtypesThresholdFormatDTO.background,
|
||||
},
|
||||
{
|
||||
color: '#0f0',
|
||||
operator: DashboardtypesComparisonOperatorDTO.above,
|
||||
value: 100,
|
||||
format: DashboardtypesThresholdFormatDTO.background,
|
||||
},
|
||||
],
|
||||
}),
|
||||
data: dataWith('295.4299833508185'),
|
||||
});
|
||||
|
||||
expect(getByTestId('conflicting-thresholds')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { PanelTable } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
|
||||
import { prepareNumberData } from '../prepareData';
|
||||
|
||||
function tableWith(
|
||||
columns: PanelTable['columns'],
|
||||
rows: PanelTable['rows'],
|
||||
): PanelTable {
|
||||
return { queryName: 'A', legend: '', columns, rows };
|
||||
}
|
||||
|
||||
describe('prepareNumberData', () => {
|
||||
it('returns null for no tables', () => {
|
||||
expect(prepareNumberData([])).toBeNull();
|
||||
});
|
||||
|
||||
it('reads the first row of the value column', () => {
|
||||
const table = tableWith(
|
||||
[
|
||||
{ name: 'group', queryName: 'A', isValueColumn: false, id: 'group' },
|
||||
{ name: 'value', queryName: 'A', isValueColumn: true, id: 'val' },
|
||||
],
|
||||
[
|
||||
{ data: { group: 'prod', val: '295.4299833508185' } },
|
||||
{ data: { group: 'dev', val: '7' } },
|
||||
],
|
||||
);
|
||||
|
||||
expect(prepareNumberData([table])).toBeCloseTo(295.43, 2);
|
||||
});
|
||||
|
||||
it('falls back to the row first value when no column is tagged isValueColumn', () => {
|
||||
const table = tableWith(
|
||||
[{ name: 'value', queryName: 'A', isValueColumn: false, id: 'value' }],
|
||||
[{ data: { value: '7' } }],
|
||||
);
|
||||
|
||||
expect(prepareNumberData([table])).toBe(7);
|
||||
});
|
||||
|
||||
it('skips empty tables and reads the first one with rows', () => {
|
||||
const empty = tableWith(
|
||||
[{ name: 'value', queryName: 'A', isValueColumn: true, id: 'A' }],
|
||||
[],
|
||||
);
|
||||
const filled = tableWith(
|
||||
[{ name: 'value', queryName: 'B', isValueColumn: true, id: 'B' }],
|
||||
[{ data: { B: 42 } }],
|
||||
);
|
||||
|
||||
expect(prepareNumberData([empty, filled])).toBe(42);
|
||||
});
|
||||
|
||||
it('returns null when the value is non-numeric', () => {
|
||||
const table = tableWith(
|
||||
[{ name: 'value', queryName: 'A', isValueColumn: true, id: 'A' }],
|
||||
[{ data: { A: 'n/a' } }],
|
||||
);
|
||||
|
||||
expect(prepareNumberData([table])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
DashboardtypesComparisonOperatorDTO,
|
||||
DashboardtypesComparisonThresholdDTO,
|
||||
DashboardtypesThresholdFormatDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { mapNumberThresholds } from '../utils';
|
||||
|
||||
describe('mapNumberThresholds', () => {
|
||||
it('returns [] for null / undefined / empty', () => {
|
||||
expect(mapNumberThresholds(null)).toStrictEqual([]);
|
||||
expect(mapNumberThresholds(undefined)).toStrictEqual([]);
|
||||
expect(mapNumberThresholds([])).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('maps comparison operators to symbol operators', () => {
|
||||
const thresholds: DashboardtypesComparisonThresholdDTO[] = [
|
||||
{
|
||||
color: '#f00',
|
||||
operator: DashboardtypesComparisonOperatorDTO.above,
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
color: '#0f0',
|
||||
operator: DashboardtypesComparisonOperatorDTO.below,
|
||||
value: 2,
|
||||
},
|
||||
{
|
||||
color: '#00f',
|
||||
operator: DashboardtypesComparisonOperatorDTO.above_or_equal,
|
||||
value: 3,
|
||||
},
|
||||
{
|
||||
color: '#ff0',
|
||||
operator: DashboardtypesComparisonOperatorDTO.below_or_equal,
|
||||
value: 4,
|
||||
},
|
||||
{
|
||||
color: '#0ff',
|
||||
operator: DashboardtypesComparisonOperatorDTO.equal,
|
||||
value: 5,
|
||||
},
|
||||
];
|
||||
|
||||
const mapped = mapNumberThresholds(thresholds);
|
||||
|
||||
expect(mapped.map((t) => t.operator)).toStrictEqual([
|
||||
'>',
|
||||
'<',
|
||||
'>=',
|
||||
'<=',
|
||||
'=',
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps not_equal to !=', () => {
|
||||
const mapped = mapNumberThresholds([
|
||||
{
|
||||
color: '#f00',
|
||||
operator: DashboardtypesComparisonOperatorDTO.not_equal,
|
||||
value: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(mapped[0].operator).toBe('!=');
|
||||
});
|
||||
|
||||
it('maps format and carries value/unit/color', () => {
|
||||
const mapped = mapNumberThresholds([
|
||||
{
|
||||
color: '#abcdef',
|
||||
operator: DashboardtypesComparisonOperatorDTO.above,
|
||||
value: 100,
|
||||
unit: 'ms',
|
||||
format: DashboardtypesThresholdFormatDTO.background,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(mapped[0]).toStrictEqual({
|
||||
color: '#abcdef',
|
||||
operator: '>',
|
||||
value: 100,
|
||||
unit: 'ms',
|
||||
format: 'background',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps text format to text', () => {
|
||||
const mapped = mapNumberThresholds([
|
||||
{
|
||||
color: '#000',
|
||||
value: 1,
|
||||
format: DashboardtypesThresholdFormatDTO.text,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(mapped[0].format).toBe('text');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.textContainer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.valueText {
|
||||
text-align: center;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.conflictBackground {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
}
|
||||
|
||||
.conflictText {
|
||||
margin-left: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.conflictIcon {
|
||||
color: var(--warning-background);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Tooltip } from 'antd';
|
||||
import { CircleAlert } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import type { PanelThreshold } from '../../../../types/threshold';
|
||||
import { resolveActiveThreshold } from '../../../../utils/evaluateThresholds';
|
||||
|
||||
import { parseFormattedValue } from '../../../../utils/parseFormattedValue';
|
||||
import styles from './ValueDisplay.module.scss';
|
||||
import { useResponsiveFontSize } from '../../../../hooks/useResponsiveFontSize';
|
||||
import ValueUnit from '../ValueUnit/ValueUnit';
|
||||
|
||||
interface ValueDisplayProps {
|
||||
/** The pre-formatted value string (may include a unit label). */
|
||||
value: string;
|
||||
/** The raw numeric value, used for threshold evaluation. */
|
||||
rawValue: number;
|
||||
thresholds: PanelThreshold[];
|
||||
/** The panel's unit, used to convert threshold units before comparison. */
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single large scalar with optional prefix/suffix units and threshold
|
||||
* recoloring (text or background). A V2-native replacement for the V1
|
||||
* `ValueGraph` — depends only on V2 threshold utilities and the shared icon/
|
||||
* typography primitives.
|
||||
*/
|
||||
function ValueDisplay({
|
||||
value,
|
||||
rawValue,
|
||||
thresholds,
|
||||
unit,
|
||||
}: ValueDisplayProps): JSX.Element {
|
||||
const { t } = useTranslation(['valueGraph']);
|
||||
const { containerRef, fontSize } = useResponsiveFontSize();
|
||||
|
||||
const { numericValue, prefixUnit, suffixUnit } = useMemo(
|
||||
() => parseFormattedValue(value),
|
||||
[value],
|
||||
);
|
||||
|
||||
const { threshold, isConflicting } = useMemo(
|
||||
() => resolveActiveThreshold(thresholds, rawValue, unit),
|
||||
[thresholds, rawValue, unit],
|
||||
);
|
||||
|
||||
const isBackground = threshold?.format === 'background';
|
||||
const textColor = threshold?.format === 'text' ? threshold.color : undefined;
|
||||
const backgroundColor = isBackground ? threshold?.color : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={styles.container}
|
||||
style={{ backgroundColor }}
|
||||
>
|
||||
<div className={styles.textContainer}>
|
||||
{prefixUnit && (
|
||||
<ValueUnit
|
||||
type="prefix"
|
||||
unit={prefixUnit}
|
||||
color={textColor}
|
||||
fontSize={fontSize}
|
||||
/>
|
||||
)}
|
||||
<Typography.Text
|
||||
className={styles.valueText}
|
||||
data-testid="number-panel-value"
|
||||
style={{ color: textColor, fontSize }}
|
||||
>
|
||||
{numericValue}
|
||||
</Typography.Text>
|
||||
{suffixUnit && (
|
||||
<ValueUnit
|
||||
type="suffix"
|
||||
unit={suffixUnit}
|
||||
color={textColor}
|
||||
fontSize={fontSize}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isConflicting && (
|
||||
<div
|
||||
className={isBackground ? styles.conflictBackground : styles.conflictText}
|
||||
>
|
||||
<Tooltip title={t('this_value_satisfies_multiple_thresholds')}>
|
||||
<CircleAlert
|
||||
className={styles.conflictIcon}
|
||||
data-testid="conflicting-thresholds"
|
||||
size="md"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ValueDisplay;
|
||||
@@ -0,0 +1,5 @@
|
||||
.unit {
|
||||
margin-left: 4px;
|
||||
font-weight: 300;
|
||||
opacity: 0.8;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './ValueUnit.module.scss';
|
||||
|
||||
interface ValueUnitProps {
|
||||
type: 'prefix' | 'suffix';
|
||||
unit: string;
|
||||
/** Text color, set only when a "text" threshold is active. */
|
||||
color?: string;
|
||||
fontSize: string;
|
||||
}
|
||||
|
||||
/** A prefix/suffix unit label rendered alongside the numeric value. */
|
||||
function ValueUnit({
|
||||
type,
|
||||
unit,
|
||||
color,
|
||||
fontSize,
|
||||
}: ValueUnitProps): JSX.Element {
|
||||
return (
|
||||
<Typography.Text
|
||||
className={styles.unit}
|
||||
data-testid={`value-display-${type}-unit`}
|
||||
style={{ color, fontSize: `calc(${fontSize} * 0.7)` }}
|
||||
>
|
||||
{unit}
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
|
||||
export default ValueUnit;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/NumberPanel'> = {
|
||||
kind: 'signoz/NumberPanel',
|
||||
displayName: 'Number',
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
actions: { view: true, edit: true, download: false, createAlert: true },
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { PanelTable } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
|
||||
/**
|
||||
* Reduces the scalar tables of a V5 response to the single number a
|
||||
* NumberPanel renders.
|
||||
*
|
||||
* V2 always issues `requestType: 'scalar'` for VALUE panels, so the response
|
||||
* is a scalar table per query (see `prepareScalarTables`). The value is the
|
||||
* first row's `isValueColumn` cell of the first table that has rows —
|
||||
* falling back to the row's first cell when no column is marked as the
|
||||
* value (mirrors the V1 `formatForWeb` fallback read).
|
||||
*
|
||||
* Returns `null` when there is no numeric value to show, which the renderer
|
||||
* maps to the "No Data" state.
|
||||
*/
|
||||
export function prepareNumberData(tables: PanelTable[]): number | null {
|
||||
for (const table of tables) {
|
||||
if (table.rows.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const row = table.rows[0].data;
|
||||
const valueColumn = table.columns.find((column) => column.isValueColumn);
|
||||
const raw = valueColumn
|
||||
? row[valueColumn.id || valueColumn.name]
|
||||
: Object.values(row)[0];
|
||||
const value = Number(raw);
|
||||
if (Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
// A number panel renders one scalar — no axes, legend, or stacking. Just value
|
||||
// formatting and thresholds that recolor the value/background.
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'formatting', controls: { unit: true, decimals: true } },
|
||||
{ kind: 'thresholds', controls: { list: true } },
|
||||
];
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
DashboardtypesComparisonOperatorDTO,
|
||||
DashboardtypesComparisonThresholdDTO,
|
||||
DashboardtypesThresholdFormatDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type {
|
||||
PanelThreshold,
|
||||
ThresholdComparisonOperator,
|
||||
ThresholdDisplayFormat,
|
||||
} from '../../types/threshold';
|
||||
|
||||
// Perses comparison operators → the symbol operators V2 threshold evaluation
|
||||
// uses.
|
||||
const OPERATOR_MAP: Record<
|
||||
DashboardtypesComparisonOperatorDTO,
|
||||
ThresholdComparisonOperator
|
||||
> = {
|
||||
[DashboardtypesComparisonOperatorDTO.above]: '>',
|
||||
[DashboardtypesComparisonOperatorDTO.below]: '<',
|
||||
[DashboardtypesComparisonOperatorDTO.above_or_equal]: '>=',
|
||||
[DashboardtypesComparisonOperatorDTO.below_or_equal]: '<=',
|
||||
[DashboardtypesComparisonOperatorDTO.equal]: '=',
|
||||
[DashboardtypesComparisonOperatorDTO.not_equal]: '!=',
|
||||
};
|
||||
|
||||
const FORMAT_MAP: Record<
|
||||
DashboardtypesThresholdFormatDTO,
|
||||
ThresholdDisplayFormat
|
||||
> = {
|
||||
[DashboardtypesThresholdFormatDTO.text]: 'text',
|
||||
[DashboardtypesThresholdFormatDTO.background]: 'background',
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps the panel-spec threshold shape (`ComparisonThresholdDTO`) onto the
|
||||
* V2-native `PanelThreshold` consumed by `ValueDisplay` / threshold
|
||||
* evaluation. No dependency on the V1 `ThresholdProps` shape.
|
||||
*/
|
||||
export function mapNumberThresholds(
|
||||
thresholds: DashboardtypesComparisonThresholdDTO[] | null | undefined,
|
||||
): PanelThreshold[] {
|
||||
if (!thresholds || thresholds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return thresholds.map((threshold) => ({
|
||||
color: threshold.color,
|
||||
operator: threshold.operator ? OPERATOR_MAP[threshold.operator] : undefined,
|
||||
value: threshold.value,
|
||||
unit: threshold.unit,
|
||||
format: threshold.format ? FORMAT_MAP[threshold.format] : undefined,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { DashboardtypesPieChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import Pie from 'container/DashboardContainer/visualization/charts/Pie/Pie';
|
||||
import type { PieSlice } from 'container/DashboardContainer/visualization/charts/types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { prepareScalarTables } from 'pages/DashboardPageV2/DashboardContainer/queryV5/prepareScalarTables';
|
||||
import { getScalarResults } from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
|
||||
|
||||
import PanelStyles from '../../panel.module.scss';
|
||||
import { PanelRendererProps } from '../../types/rendererProps';
|
||||
import {
|
||||
resolveDecimalPrecision,
|
||||
resolveLegendPosition,
|
||||
} from '../../utils/chartAppearanceMappings';
|
||||
|
||||
import { preparePieData } from './prepareData';
|
||||
|
||||
function PiePanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
onClick,
|
||||
}: PanelRendererProps<'signoz/PieChartPanel'>): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
// The registry guarantees this Renderer only runs when
|
||||
// `panel.spec.plugin.kind === 'signoz/PieChartPanel'`, so the cast is a
|
||||
// documented boundary narrowing. Memoized so the `?? {}` fallback doesn't
|
||||
// produce a fresh object on each render.
|
||||
const spec = useMemo<DashboardtypesPieChartPanelSpecDTO>(
|
||||
() => (panel?.spec?.plugin?.spec ?? {}) as DashboardtypesPieChartPanelSpecDTO,
|
||||
[panel?.spec?.plugin?.spec],
|
||||
);
|
||||
|
||||
const slices = useMemo(
|
||||
() =>
|
||||
preparePieData({
|
||||
tables: prepareScalarTables({
|
||||
results: getScalarResults(data?.response),
|
||||
legendMap: data?.legendMap ?? {},
|
||||
requestPayload: data?.requestPayload,
|
||||
}),
|
||||
customColors: spec.legend?.customColors,
|
||||
isDarkMode,
|
||||
}),
|
||||
[
|
||||
data?.response,
|
||||
data?.legendMap,
|
||||
data?.requestPayload,
|
||||
spec.legend?.customColors,
|
||||
isDarkMode,
|
||||
],
|
||||
);
|
||||
|
||||
const decimalPrecision = useMemo(
|
||||
() => resolveDecimalPrecision(spec.formatting?.decimalPrecision),
|
||||
[spec.formatting?.decimalPrecision],
|
||||
);
|
||||
|
||||
const legendPosition = useMemo(
|
||||
() => resolveLegendPosition(spec.legend?.position),
|
||||
[spec.legend?.position],
|
||||
);
|
||||
|
||||
const handleSliceClick = useCallback(
|
||||
(slice: PieSlice) => {
|
||||
onClick?.({ label: slice.label, value: slice.value });
|
||||
},
|
||||
[onClick],
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-testid="pie-panel-renderer" className={PanelStyles.panelContainer}>
|
||||
<Pie
|
||||
data={slices}
|
||||
yAxisUnit={spec.formatting?.unit}
|
||||
decimalPrecision={decimalPrecision}
|
||||
isDarkMode={isDarkMode}
|
||||
position={legendPosition}
|
||||
id={panelId}
|
||||
onSliceClick={handleSliceClick}
|
||||
data-testid="pie-chart"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PiePanelRenderer;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
|
||||
kind: 'signoz/PieChartPanel',
|
||||
displayName: 'Pie Chart',
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
actions: { view: true, edit: true, download: false, createAlert: true },
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { themeColors } from 'constants/theme';
|
||||
import type { PieSlice } from 'container/DashboardContainer/visualization/charts/types';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import type { PanelTable } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
|
||||
export interface PreparePieDataArgs {
|
||||
/** Scalar tables from the V5 response (see `prepareScalarTables`). */
|
||||
tables: PanelTable[];
|
||||
/** Per-label colour overrides from `spec.legend.customColors`. */
|
||||
customColors?: Record<string, string> | null;
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns the scalar tables of a V5 response into pie slices: one slice per
|
||||
* group row. The aggregation column holds the value, the group column(s)
|
||||
* form the label. Colours honour `customColors` then fall back to a
|
||||
* deterministic palette colour; non-positive / non-numeric values are
|
||||
* dropped.
|
||||
*/
|
||||
export function preparePieData({
|
||||
tables,
|
||||
customColors,
|
||||
isDarkMode,
|
||||
}: PreparePieDataArgs): PieSlice[] {
|
||||
const colorMap = isDarkMode
|
||||
? themeColors.chartcolors
|
||||
: themeColors.lightModeColor;
|
||||
|
||||
const slices: PieSlice[] = [];
|
||||
tables.forEach((table) => {
|
||||
const valueColumn = table.columns.find((column) => column.isValueColumn);
|
||||
if (!valueColumn) {
|
||||
return;
|
||||
}
|
||||
const valueKey = valueColumn.id || valueColumn.name;
|
||||
const labelColumns = table.columns.filter((column) => !column.isValueColumn);
|
||||
|
||||
table.rows.forEach((row) => {
|
||||
const value = Number(row.data[valueKey]);
|
||||
const label =
|
||||
labelColumns
|
||||
.map((column) => row.data[column.id || column.name])
|
||||
.filter((part) => part != null)
|
||||
.map(String)
|
||||
.join(', ') ||
|
||||
table.legend ||
|
||||
table.queryName ||
|
||||
'';
|
||||
const color = customColors?.[label] ?? generateColor(label, colorMap);
|
||||
slices.push({ label, value, color });
|
||||
});
|
||||
});
|
||||
|
||||
return slices.filter(
|
||||
(slice) => Number.isFinite(slice.value) && slice.value > 0,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
// Pie has no axes, thresholds, or stacking — just value formatting and a
|
||||
// legend. `mode` is omitted: the pie legend is always interactive swatches.
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'formatting', controls: { unit: true, decimals: true } },
|
||||
{ kind: 'legend', controls: { position: true } },
|
||||
];
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import type { DashboardtypesTimeSeriesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
|
||||
import TooltipFooter from 'container/DashboardContainer/visualization/panels/components/TooltipFooter';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
|
||||
import {
|
||||
flattenTimeSeries,
|
||||
getExecStats,
|
||||
getTimeSeriesResults,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
|
||||
import { prepareAlignedData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/uplotData';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
|
||||
import { getTimeRangeFromQueryRangeRequest } from 'utils/getTimeRange';
|
||||
|
||||
import { useGroupByPerQuery } from '../../hooks/useGroupByPerQuery';
|
||||
import PanelStyles from '../../panel.module.scss';
|
||||
import { PanelRendererProps } from '../../types/rendererProps';
|
||||
import {
|
||||
resolveDecimalPrecision,
|
||||
resolveLegendPosition,
|
||||
} from '../../utils/chartAppearanceMappings';
|
||||
import { getBuilderQueries } from '../../utils/getBuilderQueries';
|
||||
|
||||
import { buildTimeSeriesConfig } from './buildConfig';
|
||||
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
function TimeSeriesPanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
onClick,
|
||||
onDragSelect,
|
||||
dashboardPreference,
|
||||
panelMode,
|
||||
}: PanelRendererProps<'signoz/TimeSeriesPanel'>): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
// The registry guarantees this Renderer only runs when
|
||||
// `panel.spec.plugin.kind === 'signoz/TimeSeriesPanel'`, so the cast is a
|
||||
// documented boundary narrowing — not a blind assertion. Memoized so the
|
||||
// `?? {}` fallback doesn't produce a fresh object on each render.
|
||||
const spec = useMemo<DashboardtypesTimeSeriesPanelSpecDTO>(
|
||||
() =>
|
||||
(panel?.spec?.plugin?.spec ?? {}) as DashboardtypesTimeSeriesPanelSpecDTO,
|
||||
[panel?.spec?.plugin?.spec],
|
||||
);
|
||||
|
||||
const builderQueries = useMemo(
|
||||
() => getBuilderQueries(panel?.spec?.queries),
|
||||
[panel?.spec?.queries],
|
||||
);
|
||||
|
||||
// X-scale clamps come from the request that produced the data, so each
|
||||
// panel pins to the window it actually fetched — important during
|
||||
// drag-zoom transitions when the time picker has moved but new data
|
||||
// hasn't arrived yet. Falls back to the global picker inside the helper.
|
||||
// The generated request DTO is structurally the hand-written V5 request;
|
||||
// the cast is the documented boundary.
|
||||
const { minTimeScale, maxTimeScale } = useMemo(() => {
|
||||
const { startTime, endTime } = getTimeRangeFromQueryRangeRequest(
|
||||
data?.requestPayload as unknown as QueryRangeRequestV5 | undefined,
|
||||
);
|
||||
return { minTimeScale: startTime, maxTimeScale: endTime };
|
||||
}, [data?.requestPayload]);
|
||||
|
||||
const groupByPerQuery = useGroupByPerQuery(builderQueries);
|
||||
|
||||
const flatSeries = useMemo(
|
||||
() =>
|
||||
flattenTimeSeries(
|
||||
getTimeSeriesResults(data?.response),
|
||||
data?.legendMap ?? {},
|
||||
),
|
||||
[data?.response, data?.legendMap],
|
||||
);
|
||||
|
||||
const config = useMemo(
|
||||
() =>
|
||||
buildTimeSeriesConfig({
|
||||
panelId,
|
||||
spec,
|
||||
builderQueries,
|
||||
series: flatSeries,
|
||||
stepIntervals: getExecStats(data?.response)?.stepIntervals,
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelMode,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
onDragSelect,
|
||||
}),
|
||||
[
|
||||
panelId,
|
||||
spec,
|
||||
builderQueries,
|
||||
flatSeries,
|
||||
data?.response,
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelMode,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
onDragSelect,
|
||||
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
|
||||
// Rebuild it on syncMode changes so the new chart instance starts from a
|
||||
// clean config — otherwise switching to "No Sync" would inherit stale sync
|
||||
// settings from the previous mode.
|
||||
dashboardPreference?.syncMode,
|
||||
],
|
||||
);
|
||||
|
||||
const chartData = useMemo(() => prepareAlignedData(flatSeries), [flatSeries]);
|
||||
|
||||
const decimalPrecision = useMemo(
|
||||
() => resolveDecimalPrecision(spec.formatting?.decimalPrecision),
|
||||
[spec.formatting?.decimalPrecision],
|
||||
);
|
||||
|
||||
const legendPosition = useMemo(() => {
|
||||
return resolveLegendPosition(spec.legend?.position);
|
||||
}, [spec.legend?.position]);
|
||||
|
||||
const renderTooltipFooter = useCallback(
|
||||
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
|
||||
<TooltipFooter id={panelId} isPinned={isPinned} dismiss={dismiss} />
|
||||
),
|
||||
[panelId],
|
||||
);
|
||||
|
||||
/**
|
||||
* The uPlot key prop is the only way to force a full teardown and re-mount
|
||||
* of the chart. By including the syncMode and syncFilterMode in the key,
|
||||
* we ensure that changes to these preferences trigger a fresh chart instance,
|
||||
* preventing stale sync settings from being inherited.
|
||||
*/
|
||||
const key = `${dashboardPreference?.syncMode}-${dashboardPreference?.syncFilterMode}`;
|
||||
|
||||
const handleChartClick = useCallback(
|
||||
(args: ChartClickData) => {
|
||||
onClick?.(args);
|
||||
},
|
||||
[onClick],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={graphRef}
|
||||
data-testid="time-series-renderer"
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
|
||||
<TimeSeries
|
||||
key={key}
|
||||
config={config}
|
||||
data={chartData}
|
||||
legendConfig={{ position: legendPosition }}
|
||||
groupByPerQuery={groupByPerQuery}
|
||||
canPinTooltip
|
||||
timezone={timezone}
|
||||
yAxisUnit={spec.formatting?.unit}
|
||||
decimalPrecision={decimalPrecision}
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
syncMode={dashboardPreference?.syncMode}
|
||||
syncFilterMode={dashboardPreference?.syncFilterMode}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
onClick={handleChartClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TimeSeriesPanelRenderer;
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { DashboardtypesTimeSeriesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { buildBaseConfig } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
|
||||
import {
|
||||
FILL_MODE_MAP,
|
||||
LINE_INTERPOLATION_MAP,
|
||||
LINE_STYLE_MAP,
|
||||
resolveSpanGaps,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/chartAppearanceMappings';
|
||||
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
|
||||
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import {
|
||||
hasSingleVisiblePoint,
|
||||
toClickPluginPayload,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/uplotData';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
import {
|
||||
DrawStyle,
|
||||
FillMode,
|
||||
LineInterpolation,
|
||||
LineStyle,
|
||||
} from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
|
||||
const DEFAULT_POINT_SIZE = 5;
|
||||
|
||||
export interface BuildTimeSeriesConfigArgs {
|
||||
panelId: string;
|
||||
spec: DashboardtypesTimeSeriesPanelSpecDTO;
|
||||
/**
|
||||
* Flat list of builder queries on this panel (see `getBuilderQueries`).
|
||||
* Powers per-query legend resolution; empty for non-builder panels.
|
||||
*/
|
||||
builderQueries: BuilderQuery[];
|
||||
/** Flattened V5 series (see `flattenTimeSeries`). */
|
||||
series: PanelSeries[];
|
||||
/** Per-query step intervals from the response exec stats. */
|
||||
stepIntervals?: Record<string, number>;
|
||||
isDarkMode: boolean;
|
||||
timezone: Timezone;
|
||||
panelMode: PanelMode;
|
||||
onDragSelect?: (start: number, end: number) => void;
|
||||
onClick?: OnClickPluginOpts['onClick'];
|
||||
minTimeScale?: number;
|
||||
maxTimeScale?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a fully-wired `UPlotConfigBuilder` for a TimeSeries panel.
|
||||
*
|
||||
* Delegates the panel-agnostic scaffolding (scales, thresholds, axes,
|
||||
* drag-to-zoom, click plugin) to the shared `buildBaseConfig`, then layers
|
||||
* in the TimeSeries-specific concern: one series per result, with visuals
|
||||
* resolved from `spec.chartAppearance`.
|
||||
*/
|
||||
export function buildTimeSeriesConfig({
|
||||
panelId,
|
||||
spec,
|
||||
builderQueries,
|
||||
series,
|
||||
stepIntervals,
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelMode,
|
||||
onDragSelect,
|
||||
onClick,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
}: BuildTimeSeriesConfigArgs): UPlotConfigBuilder {
|
||||
const builder = buildBaseConfig({
|
||||
panelId,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelMode,
|
||||
isLogScale: spec.axes?.isLogScale,
|
||||
softMin: spec.axes?.softMin ?? undefined,
|
||||
softMax: spec.axes?.softMax ?? undefined,
|
||||
formatting: spec.formatting,
|
||||
thresholds: spec.thresholds,
|
||||
stepIntervals,
|
||||
clickPayload: toClickPluginPayload(series),
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
onDragSelect,
|
||||
onClick,
|
||||
});
|
||||
|
||||
addSeries({ builder, spec, builderQueries, series, isDarkMode });
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
interface AddSeriesArgs {
|
||||
builder: UPlotConfigBuilder;
|
||||
spec: DashboardtypesTimeSeriesPanelSpecDTO;
|
||||
builderQueries: BuilderQuery[];
|
||||
series: PanelSeries[];
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds one uPlot series per flattened V5 series to the scaffolded builder.
|
||||
* The visual resolution (line style, interpolation, fill mode, span gaps)
|
||||
* reads from `spec.chartAppearance`; the label is resolved via the legend
|
||||
* matrix in `resolveSeriesLabelV5`. Mutates the builder in place.
|
||||
*
|
||||
* Order must match `prepareAlignedData` — both iterate the same flat list.
|
||||
*/
|
||||
function addSeries({
|
||||
builder,
|
||||
spec,
|
||||
builderQueries,
|
||||
series,
|
||||
isDarkMode,
|
||||
}: AddSeriesArgs): void {
|
||||
const chartAppearance = spec.chartAppearance;
|
||||
// `customColors` is nullable on the spec; coerce so `addSeries` always gets
|
||||
// a defined record (it dereferences keys without a guard).
|
||||
const colorMapping = spec.legend?.customColors ?? {};
|
||||
const spanGaps = resolveSpanGaps(chartAppearance?.spanGaps?.fillLessThan);
|
||||
|
||||
const lineStyle = chartAppearance?.lineStyle
|
||||
? LINE_STYLE_MAP[chartAppearance.lineStyle]
|
||||
: LineStyle.Solid;
|
||||
const lineInterpolation = chartAppearance?.lineInterpolation
|
||||
? LINE_INTERPOLATION_MAP[chartAppearance.lineInterpolation]
|
||||
: LineInterpolation.Spline;
|
||||
const fillMode = chartAppearance?.fillMode
|
||||
? FILL_MODE_MAP[chartAppearance.fillMode]
|
||||
: FillMode.None;
|
||||
|
||||
series.forEach((s) => {
|
||||
const hasSingleValidPoint = hasSingleVisiblePoint(s.values);
|
||||
const baseLabel = getLabelName(s.labels, s.queryName, s.legend);
|
||||
const label = resolveSeriesLabelV5(s, builderQueries, baseLabel);
|
||||
|
||||
builder.addSeries({
|
||||
scaleKey: 'y',
|
||||
// A single visible point can't be drawn as a line — degrade to points
|
||||
// so the user still sees the datum (matches V1 behavior).
|
||||
drawStyle: hasSingleValidPoint ? DrawStyle.Points : DrawStyle.Line,
|
||||
label,
|
||||
colorMapping,
|
||||
spanGaps,
|
||||
lineStyle,
|
||||
lineInterpolation,
|
||||
showPoints: chartAppearance?.showPoints || hasSingleValidPoint,
|
||||
pointSize: DEFAULT_POINT_SIZE,
|
||||
fillMode,
|
||||
isDarkMode,
|
||||
metric: s.labels,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
displayName: 'Time Series',
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
actions: { view: true, edit: true, download: false, createAlert: true },
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
export const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: 'formatting',
|
||||
controls: {
|
||||
unit: true,
|
||||
decimals: true,
|
||||
},
|
||||
},
|
||||
{ kind: 'axes', controls: { minMax: true, unit: true, logScale: true } },
|
||||
{ kind: 'legend', controls: { position: true, mode: true } },
|
||||
{ kind: 'thresholds', controls: { list: true } },
|
||||
{ kind: 'chartAppearance', controls: { lineStyle: true, fillOpacity: true } },
|
||||
];
|
||||
@@ -0,0 +1,9 @@
|
||||
.panelContainer {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
/**
|
||||
* Source-tagged click events. The three uPlot panels share `ChartClickEvent`;
|
||||
* each non-chart kind carries the context its drill-down needs. The `source`
|
||||
* tag lets a kind-agnostic consumer (the render boundary, a shared drill-down
|
||||
* handler) discriminate without assuming a chart shape.
|
||||
*/
|
||||
export type ChartClickEvent = ChartClickData;
|
||||
export type TableClickEvent = {
|
||||
rowData: Record<string, unknown>;
|
||||
columnId?: string;
|
||||
};
|
||||
export type ListClickEvent = {
|
||||
rowData: Record<string, unknown>;
|
||||
};
|
||||
export type PieClickEvent = { label: string; value: number };
|
||||
|
||||
/** Union of every panel click event — switched on by `source` at the boundary. */
|
||||
export type PanelClickEvent =
|
||||
| ChartClickEvent
|
||||
| TableClickEvent
|
||||
| ListClickEvent
|
||||
| PieClickEvent;
|
||||
|
||||
type DragSelect = (start: number, end: number) => void;
|
||||
|
||||
/**
|
||||
* Per-kind interaction props. Each panel kind exposes ONLY the gestures it
|
||||
* supports: chart panels get a chart-shaped `onClick`, time-axis charts add
|
||||
* `onDragSelect`, histograms have no drag-to-zoom, a NumberPanel has no
|
||||
* interactions at all. Keys mirror `PanelKind`; `PanelRendererProps<K>` in
|
||||
* rendererProps.ts indexes this map, so a missing kind is a compile error there.
|
||||
*/
|
||||
export interface PanelInteractionMap {
|
||||
'signoz/TimeSeriesPanel': {
|
||||
onClick?: (event: ChartClickEvent) => void;
|
||||
onDragSelect?: DragSelect;
|
||||
};
|
||||
'signoz/BarChartPanel': {
|
||||
onClick?: (event: ChartClickEvent) => void;
|
||||
onDragSelect?: DragSelect;
|
||||
};
|
||||
'signoz/HistogramPanel': { onClick?: (event: ChartClickEvent) => void };
|
||||
'signoz/TablePanel': { onClick?: (event: TableClickEvent) => void };
|
||||
'signoz/ListPanel': { onClick?: (event: ListClickEvent) => void };
|
||||
'signoz/PieChartPanel': { onClick?: (event: PieClickEvent) => void };
|
||||
'signoz/NumberPanel': Record<string, never>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Widest interaction surface — used where the panel kind is not known
|
||||
* statically (the registry render boundary; see `getPanelDefinition`). It is
|
||||
* the structural supertype the per-kind shapes are cast to exactly once.
|
||||
*/
|
||||
export interface AnyPanelInteractionProps {
|
||||
onClick?: (event: PanelClickEvent) => void;
|
||||
onDragSelect?: DragSelect;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { SectionConfig } from './sections';
|
||||
import type { AnyPanelInteractionProps } from './interactions';
|
||||
import type { PanelKind } from './panelKind';
|
||||
import type { BaseRendererProps, PanelRendererProps } from './rendererProps';
|
||||
|
||||
/**
|
||||
* Kind-level action capabilities: which panel actions THIS kind supports.
|
||||
* Declared per-kind in `kinds/<Kind>/definition.ts` — the field is required,
|
||||
* so registering a new kind forces an explicit decision for every action
|
||||
* (mirroring how PanelInteractionMap forces per-kind interaction coverage).
|
||||
*
|
||||
* Chrome actions (move to section, clone, delete) are dashboard-layout
|
||||
* concerns, available for every panel — including kinds V2 can't render —
|
||||
* and are intentionally not declarable here.
|
||||
*/
|
||||
export interface PanelActionCapabilities {
|
||||
/** Kind has a full-screen view — gates the "View" action. */
|
||||
view: boolean;
|
||||
/** Kind is editable in the V2 panel editor — gates the "Edit panel" action. */
|
||||
edit: boolean;
|
||||
/**
|
||||
* Kind's data can be exported as CSV — gates "Download as CSV". V1 parity:
|
||||
* only table panels carry tabular data worth exporting.
|
||||
*/
|
||||
download: boolean;
|
||||
/** Kind's query can seed a new alert — gates "Create Alerts". */
|
||||
createAlert: boolean;
|
||||
}
|
||||
|
||||
export interface PanelDefinition<K extends PanelKind = PanelKind> {
|
||||
kind: K;
|
||||
displayName: string;
|
||||
Renderer: ComponentType<PanelRendererProps<K>>;
|
||||
sections: SectionConfig[];
|
||||
supportedSignals: DataSource[];
|
||||
actions: PanelActionCapabilities;
|
||||
}
|
||||
|
||||
// Keyed registry that preserves the kind ↔ definition correlation: indexing
|
||||
// with a literal kind yields that kind's exactly-typed PanelDefinition.
|
||||
export type PanelRegistry = { [K in PanelKind]?: PanelDefinition<K> };
|
||||
|
||||
// A PanelDefinition whose Renderer is widened to the kind-agnostic prop surface.
|
||||
// At the render boundary the concrete kind isn't known statically (a registry
|
||||
// lookup returns a union over kinds), so getPanelDefinition resolves to this —
|
||||
// concentrating the single unavoidable cast in one place instead of leaking it
|
||||
// to every call site.
|
||||
export interface RenderablePanelDefinition extends Omit<
|
||||
PanelDefinition,
|
||||
'Renderer'
|
||||
> {
|
||||
Renderer: ComponentType<BaseRendererProps & AnyPanelInteractionProps>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
export type PanelKind =
|
||||
| 'signoz/TimeSeriesPanel'
|
||||
| 'signoz/BarChartPanel'
|
||||
| 'signoz/NumberPanel'
|
||||
| 'signoz/PieChartPanel'
|
||||
| 'signoz/TablePanel'
|
||||
| 'signoz/HistogramPanel'
|
||||
| 'signoz/ListPanel';
|
||||
|
||||
export const PANEL_KIND_TO_PANEL_TYPE: Record<PanelKind, PANEL_TYPES> = {
|
||||
'signoz/TimeSeriesPanel': PANEL_TYPES.TIME_SERIES,
|
||||
'signoz/BarChartPanel': PANEL_TYPES.BAR,
|
||||
'signoz/NumberPanel': PANEL_TYPES.VALUE,
|
||||
'signoz/PieChartPanel': PANEL_TYPES.PIE,
|
||||
'signoz/TablePanel': PANEL_TYPES.TABLE,
|
||||
'signoz/HistogramPanel': PANEL_TYPES.HISTOGRAM,
|
||||
'signoz/ListPanel': PANEL_TYPES.LIST,
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardCursorSync,
|
||||
SyncTooltipFilterMode,
|
||||
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
|
||||
import type { PanelInteractionMap } from './interactions';
|
||||
import type { PanelKind } from './panelKind';
|
||||
|
||||
/**
|
||||
* Dashboard-wide rendering preferences propagated down to every panel renderer
|
||||
* on the same dashboard. Lets the shell push cross-panel concerns (cursor
|
||||
* sync, tooltip filter mode, dashboard id for scoped state) without each
|
||||
* renderer rediscovering them via hooks. All fields are optional — non-
|
||||
* dashboard render contexts (PanelEditor preview, standalone view) can pass
|
||||
* an empty object and the renderer will fall back to sensible defaults.
|
||||
*/
|
||||
export interface DashboardPreference {
|
||||
/**
|
||||
* Cursor-sync mode for the dashboard. Drives the uPlot tooltip plugin so
|
||||
* hovering one panel highlights the corresponding x on every other panel.
|
||||
*/
|
||||
syncMode?: DashboardCursorSync;
|
||||
/**
|
||||
* Filter applied to the synced tooltip across panels (e.g. only show series
|
||||
* whose label matches the hovered series).
|
||||
*/
|
||||
syncFilterMode?: SyncTooltipFilterMode;
|
||||
/**
|
||||
* Dashboard id — useful for renderers that scope per-dashboard state
|
||||
* (e.g. pinned-tooltip persistence, drill-down history).
|
||||
*/
|
||||
dashboardId?: string;
|
||||
}
|
||||
|
||||
// Kind-agnostic props every renderer receives, regardless of panel kind. The
|
||||
// kind-specific interaction props (onClick payload, onDragSelect) are layered
|
||||
// on per-kind by PanelRendererProps<K>.
|
||||
export interface BaseRendererProps {
|
||||
panelId: string;
|
||||
/**
|
||||
* The whole perses panel — renderers derive their concrete `spec` and the
|
||||
* perses-shaped `queries` from this. Passing the full panel keeps the prop
|
||||
* surface stable as new panel-level fields are added to the wire format.
|
||||
*/
|
||||
panel: DashboardtypesPanelDTO | undefined;
|
||||
/** Raw V5 fetch result — response + the request that produced it. */
|
||||
data?: PanelQueryData;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
/** Gate for the drill-down right-click menu. Off by default in V2. */
|
||||
enableDrillDown?: boolean;
|
||||
/**
|
||||
* Render context — varies behavior (e.g. dashboard widget vs. standalone
|
||||
* full-screen vs. inside the editor). See PanelMode for the contract.
|
||||
*/
|
||||
panelMode: PanelMode;
|
||||
/**
|
||||
* Dashboard-level preferences that should propagate to every panel
|
||||
* (cursor sync, tooltip filter mode, dashboard id). The shell owns
|
||||
* resolving these; the renderer just consumes them.
|
||||
*/
|
||||
dashboardPreference?: DashboardPreference;
|
||||
}
|
||||
|
||||
// Renderer props for a specific panel kind: the shared base plus that kind's
|
||||
// interaction surface (PanelInteractionMap[K]). Each renderer annotates with
|
||||
// its own kind — e.g. PanelRendererProps<'signoz/TimeSeriesPanel'> — so it can
|
||||
// only reference the gestures that kind supports. Indexing PanelInteractionMap
|
||||
// here forces the map to cover every PanelKind. The default K = PanelKind
|
||||
// yields the widest surface (a union over all kinds).
|
||||
export type PanelRendererProps<K extends PanelKind = PanelKind> =
|
||||
BaseRendererProps & PanelInteractionMap[K];
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
BarChart,
|
||||
Columns3,
|
||||
Hash,
|
||||
ListEnd,
|
||||
Palette,
|
||||
Ruler,
|
||||
SlidersHorizontal,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
// Derived from an actual icon component so the type stays exact (size is a
|
||||
// constrained IconSize union, not arbitrary strings) and ForwardRef-compatible.
|
||||
export type SectionIcon = typeof Hash;
|
||||
|
||||
export interface SectionMetadata {
|
||||
title: string;
|
||||
icon: SectionIcon;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// Per-kind control toggles (type-only — runtime metadata is in SECTIONS).
|
||||
// Section components type their controls prop via `SectionControls['axes']`.
|
||||
export type SectionControls = {
|
||||
formatting: { unit?: boolean; decimals?: boolean };
|
||||
axes: { minMax?: boolean; unit?: boolean; logScale?: boolean };
|
||||
legend: { position?: boolean; mode?: boolean };
|
||||
thresholds: { list?: boolean };
|
||||
chartAppearance: {
|
||||
lineStyle?: boolean;
|
||||
fillOpacity?: boolean;
|
||||
stacked?: boolean;
|
||||
};
|
||||
columnUnits: { perColumnUnit?: boolean };
|
||||
buckets: { count?: boolean; min?: boolean; max?: boolean };
|
||||
};
|
||||
|
||||
// Source of truth for sections. Its keys define SectionKind; its values are the
|
||||
// runtime UI metadata (consumed by PanelEditor in 1.8). Adding a new section =
|
||||
// one entry here + one entry in SectionControls.
|
||||
export const SECTIONS = {
|
||||
formatting: { title: 'Formatting', icon: Hash },
|
||||
axes: { title: 'Axes', icon: Ruler },
|
||||
legend: { title: 'Legend', icon: ListEnd },
|
||||
thresholds: { title: 'Thresholds', icon: SlidersHorizontal },
|
||||
chartAppearance: { title: 'Chart appearance', icon: Palette },
|
||||
columnUnits: { title: 'Column units', icon: Columns3 },
|
||||
buckets: { title: 'Buckets', icon: BarChart },
|
||||
} as const satisfies Record<string, SectionMetadata>;
|
||||
|
||||
export type SectionKind = keyof typeof SECTIONS;
|
||||
|
||||
// Discriminated union derived from SectionControls — kept in lockstep automatically.
|
||||
export type SectionConfig = {
|
||||
[K in SectionKind]: { kind: K; controls: SectionControls[K] };
|
||||
}[SectionKind];
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* V2-native threshold model.
|
||||
*
|
||||
* The panel spec carries thresholds as `DashboardtypesComparisonThresholdDTO`
|
||||
* (operator/format expressed as `above`/`below`/`text`/`background`). For
|
||||
* evaluation and rendering we work with the symbol operators and lowercase
|
||||
* display formats, kept here so V2 panels never reach into the V1
|
||||
* `container/NewWidget` `ThresholdProps` shape.
|
||||
*/
|
||||
|
||||
/** Comparison operators a threshold can use, as evaluable symbols. */
|
||||
export type ThresholdComparisonOperator = '>' | '<' | '>=' | '<=' | '=' | '!=';
|
||||
|
||||
/** How a matched threshold recolors the panel. */
|
||||
export type ThresholdDisplayFormat = 'text' | 'background';
|
||||
|
||||
/**
|
||||
* A threshold normalized for evaluation/rendering. `operator`/`format` are
|
||||
* optional because the spec allows partially-configured thresholds; a
|
||||
* threshold with no operator never matches.
|
||||
*/
|
||||
export interface PanelThreshold {
|
||||
color: string;
|
||||
operator?: ThresholdComparisonOperator;
|
||||
value: number;
|
||||
/** Unit the threshold value is expressed in; converted to the panel unit before comparison. */
|
||||
unit?: string;
|
||||
format?: ThresholdDisplayFormat;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { PanelThreshold } from '../../types/threshold';
|
||||
import {
|
||||
doesValueMatchThreshold,
|
||||
resolveActiveThreshold,
|
||||
} from '../evaluateThresholds';
|
||||
|
||||
const threshold = (overrides: Partial<PanelThreshold>): PanelThreshold => ({
|
||||
color: '#f00',
|
||||
value: 100,
|
||||
operator: '>',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('doesValueMatchThreshold', () => {
|
||||
it.each([
|
||||
['>', 150, 100, true],
|
||||
['>', 50, 100, false],
|
||||
['<', 50, 100, true],
|
||||
['>=', 100, 100, true],
|
||||
['<=', 100, 100, true],
|
||||
['=', 100, 100, true],
|
||||
['!=', 150, 100, true],
|
||||
] as const)('evaluates %s (%d vs %d)', (operator, value, target, expected) => {
|
||||
expect(
|
||||
doesValueMatchThreshold(value, threshold({ operator, value: target })),
|
||||
).toBe(expected);
|
||||
});
|
||||
|
||||
it('never matches a threshold without an operator', () => {
|
||||
expect(doesValueMatchThreshold(150, threshold({ operator: undefined }))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('compares the raw value when units are in different categories', () => {
|
||||
// 'bytes' vs 'ms' belong to different categories, so conversion is invalid
|
||||
// and the comparison falls back to the raw value (150 > 100).
|
||||
expect(
|
||||
doesValueMatchThreshold(150, threshold({ value: 100, unit: 'bytes' }), 'ms'),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveActiveThreshold', () => {
|
||||
it('returns no threshold when none match', () => {
|
||||
const result = resolveActiveThreshold([threshold({ value: 1000 })], 10);
|
||||
expect(result.threshold).toBeNull();
|
||||
expect(result.isConflicting).toBe(false);
|
||||
});
|
||||
|
||||
it('flags a conflict and picks the earliest-declared match', () => {
|
||||
const first = threshold({ color: '#aaa', operator: '>', value: 0 });
|
||||
const second = threshold({ color: '#bbb', operator: '>', value: 100 });
|
||||
|
||||
const result = resolveActiveThreshold([first, second], 150);
|
||||
|
||||
expect(result.isConflicting).toBe(true);
|
||||
expect(result.threshold).toBe(first);
|
||||
});
|
||||
|
||||
it('returns the single matching threshold without a conflict', () => {
|
||||
const only = threshold({ color: '#abc', operator: '>', value: 100 });
|
||||
const result = resolveActiveThreshold(
|
||||
[only, threshold({ value: 9999 })],
|
||||
150,
|
||||
);
|
||||
|
||||
expect(result.threshold).toBe(only);
|
||||
expect(result.isConflicting).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { PrecisionOptionsEnum } from 'components/Graph/types';
|
||||
|
||||
import { formatPanelValue } from '../formatPanelValue';
|
||||
|
||||
describe('formatPanelValue', () => {
|
||||
it('applies the configured precision and appends the unit label', () => {
|
||||
// The unit-aware formatter returns value + label as one string; the
|
||||
// ValueDisplay splits it into numeric/suffix parts when rendering.
|
||||
expect(formatPanelValue(295.4299833508185, 'ms', 2)).toBe('295.43 ms');
|
||||
});
|
||||
|
||||
// Regression: precision must apply even with no unit. The old gate
|
||||
// (`unit ? format() : value.toString()`) dropped precision on unitless
|
||||
// panels, so decimal-precision changes had no visible effect.
|
||||
it('applies precision when NO unit is set', () => {
|
||||
expect(formatPanelValue(3.14159, undefined, 2)).toBe('3.14');
|
||||
expect(formatPanelValue(3.14159, '', 2)).toBe('3.14');
|
||||
});
|
||||
|
||||
it('honors full precision without a unit', () => {
|
||||
expect(formatPanelValue(3.14159, undefined, PrecisionOptionsEnum.FULL)).toBe(
|
||||
'3.14159',
|
||||
);
|
||||
});
|
||||
|
||||
it('drops the fractional part at precision 0', () => {
|
||||
expect(formatPanelValue(3.14159, undefined, 0)).toBe('3');
|
||||
});
|
||||
|
||||
it('renders whole numbers without a trailing decimal', () => {
|
||||
expect(formatPanelValue(5, undefined, 2)).toBe('5');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
|
||||
import { resolveSeriesLabelV5 } from '../resolveSeriesLabel';
|
||||
|
||||
// Fixtures cast at the boundary; the v5 BuilderQuery union is too verbose to
|
||||
// construct field-typed inline.
|
||||
|
||||
function builderQuery(spec: Record<string, unknown>): BuilderQuery {
|
||||
return spec as unknown as BuilderQuery;
|
||||
}
|
||||
|
||||
function panelSeries(overrides: Partial<PanelSeries> = {}): PanelSeries {
|
||||
return {
|
||||
queryName: 'A',
|
||||
legend: '',
|
||||
labels: { host: 'h1' },
|
||||
kind: 'series',
|
||||
values: [],
|
||||
aggregation: { index: 0, alias: '' },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('resolveSeriesLabelV5', () => {
|
||||
it('returns baseLabel for panels without builder queries (promql/clickhouse)', () => {
|
||||
expect(resolveSeriesLabelV5(panelSeries(), [], 'base')).toBe('base');
|
||||
});
|
||||
|
||||
it('returns baseLabel when no query matches the series queryName', () => {
|
||||
expect(
|
||||
resolveSeriesLabelV5(
|
||||
panelSeries({ queryName: 'Z' }),
|
||||
[builderQuery({ name: 'A' })],
|
||||
'base',
|
||||
),
|
||||
).toBe('base');
|
||||
});
|
||||
|
||||
it('falls back to baseLabel || queryName when the aggregation has no alias/expression (metrics)', () => {
|
||||
const queries = [
|
||||
builderQuery({ name: 'A', aggregations: [{ metricName: 'cpu' }] }),
|
||||
];
|
||||
expect(resolveSeriesLabelV5(panelSeries(), queries, 'base')).toBe('base');
|
||||
expect(resolveSeriesLabelV5(panelSeries(), queries, '')).toBe('A');
|
||||
});
|
||||
|
||||
it('single query + groupBy + single aggregation → baseLabel', () => {
|
||||
const queries = [
|
||||
builderQuery({
|
||||
name: 'A',
|
||||
groupBy: [{ name: 'host' }],
|
||||
aggregations: [{ expression: 'count()', alias: '' }],
|
||||
}),
|
||||
];
|
||||
expect(resolveSeriesLabelV5(panelSeries(), queries, 'h1')).toBe('h1');
|
||||
});
|
||||
|
||||
it('single query + groupBy + multiple aggregations → "alias-or-expression"-baseLabel', () => {
|
||||
const queries = [
|
||||
builderQuery({
|
||||
name: 'A',
|
||||
groupBy: [{ name: 'host' }],
|
||||
aggregations: [
|
||||
{ expression: 'count()', alias: '' },
|
||||
{ expression: 'avg(x)', alias: 'mean' },
|
||||
],
|
||||
}),
|
||||
];
|
||||
expect(
|
||||
resolveSeriesLabelV5(
|
||||
panelSeries({ aggregation: { index: 1, alias: 'mean' } }),
|
||||
queries,
|
||||
'h1',
|
||||
),
|
||||
).toBe('mean-h1');
|
||||
});
|
||||
|
||||
it('single query, no groupBy, single aggregation → alias || legend || expression', () => {
|
||||
const queries = [
|
||||
builderQuery({
|
||||
name: 'A',
|
||||
legend: 'My legend',
|
||||
aggregations: [{ expression: 'count()', alias: '' }],
|
||||
}),
|
||||
];
|
||||
expect(resolveSeriesLabelV5(panelSeries({ labels: {} }), queries, 'A')).toBe(
|
||||
'My legend',
|
||||
);
|
||||
});
|
||||
|
||||
it('multiple queries, no groupBy, single aggregation → alias || baseLabel', () => {
|
||||
const queries = [
|
||||
builderQuery({ name: 'A', aggregations: [{ expression: 'count()' }] }),
|
||||
builderQuery({ name: 'B', aggregations: [{ expression: 'sum(y)' }] }),
|
||||
];
|
||||
expect(
|
||||
resolveSeriesLabelV5(panelSeries({ labels: {} }), queries, 'base'),
|
||||
).toBe('base');
|
||||
});
|
||||
|
||||
it('resolves via the aggregation index carried on the series', () => {
|
||||
const queries = [
|
||||
builderQuery({
|
||||
name: 'A',
|
||||
aggregations: [
|
||||
{ expression: 'count()', alias: 'hits' },
|
||||
{ expression: 'avg(x)', alias: 'mean' },
|
||||
],
|
||||
}),
|
||||
];
|
||||
expect(
|
||||
resolveSeriesLabelV5(
|
||||
panelSeries({ labels: {}, aggregation: { index: 1, alias: 'mean' } }),
|
||||
queries,
|
||||
'',
|
||||
),
|
||||
).toBe('mean');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import type {
|
||||
DashboardtypesPanelFormattingDTO,
|
||||
DashboardtypesThresholdWithLabelDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import onClickPlugin, {
|
||||
OnClickPluginOpts,
|
||||
} from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
import {
|
||||
DistributionType,
|
||||
SelectionPreferencesSource,
|
||||
} from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { ThresholdsDrawHookOptions } from 'lib/uPlotV2/hooks/types';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
/**
|
||||
* Inputs for the shared V2 chart pipeline. Mirrors the V1 helper of the same
|
||||
* name but accepts perses-shaped inputs directly (so callers don't translate
|
||||
* once per panel). The series-rendering step is panel-specific and lives in
|
||||
* each panel's `utils.ts` — this helper only wires the scaffolding (scales,
|
||||
* thresholds, axes, drag-to-zoom, click plugin).
|
||||
*/
|
||||
export interface BuildBaseConfigArgs {
|
||||
panelId: string;
|
||||
panelType: PANEL_TYPES;
|
||||
isDarkMode: boolean;
|
||||
timezone: Timezone;
|
||||
panelMode: PanelMode;
|
||||
|
||||
/** From `spec.axes` — drives the Y scale and (when log) both scales' base. */
|
||||
isLogScale?: boolean;
|
||||
softMin?: number;
|
||||
softMax?: number;
|
||||
|
||||
/** From `spec.formatting.unit` — drives Y axis tick formatting + threshold formatting. */
|
||||
formatting?: DashboardtypesPanelFormattingDTO;
|
||||
|
||||
/** From `spec.thresholds` — perses shape, mapped to the draw-hook shape internally. */
|
||||
thresholds?: DashboardtypesThresholdWithLabelDTO[] | null;
|
||||
|
||||
/** Per-query step intervals from the response exec stats. */
|
||||
stepIntervals?: Record<string, number>;
|
||||
/**
|
||||
* Tuple-shaped payload for the shared click plugin (see
|
||||
* `toClickPluginPayload`). Omitted by panels without click interactions.
|
||||
*/
|
||||
clickPayload?: MetricRangePayloadProps;
|
||||
|
||||
/** Time-range clamps for the X scale (typically from `getTimeRange(apiResponse)`). */
|
||||
minTimeScale?: number;
|
||||
maxTimeScale?: number;
|
||||
|
||||
/** Optional — histogram and other non-time panels omit drag-to-zoom. */
|
||||
onDragSelect?: (start: number, end: number) => void;
|
||||
onClick?: OnClickPluginOpts['onClick'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the panel-agnostic scaffolding of a uPlot chart: scales, thresholds,
|
||||
* axes, drag-to-zoom, click plugin. Callers (TimeSeriesPanel, BarPanel, …)
|
||||
* then call `addSeries`/`addPlugin` on the returned builder for their own
|
||||
* panel-specific rendering.
|
||||
*/
|
||||
export function buildBaseConfig({
|
||||
panelId,
|
||||
panelType,
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelMode,
|
||||
isLogScale,
|
||||
softMin,
|
||||
softMax,
|
||||
formatting,
|
||||
thresholds,
|
||||
stepIntervals,
|
||||
clickPayload,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
onDragSelect,
|
||||
onClick,
|
||||
}: BuildBaseConfigArgs): UPlotConfigBuilder {
|
||||
const yAxisUnit = formatting?.unit;
|
||||
|
||||
const builder = new UPlotConfigBuilder({
|
||||
id: panelId,
|
||||
onDragSelect,
|
||||
tzDate: makeTzDate(timezone),
|
||||
shouldSaveSelectionPreference: panelMode === PanelMode.DASHBOARD_VIEW,
|
||||
selectionPreferencesSource: resolveSelectionPreferencesSource(panelMode),
|
||||
stepInterval: stepIntervals ? minStepInterval(stepIntervals) : undefined,
|
||||
});
|
||||
|
||||
const thresholdOptions: ThresholdsDrawHookOptions = {
|
||||
scaleKey: 'y',
|
||||
thresholds: mapThresholds(thresholds),
|
||||
yAxisUnit,
|
||||
};
|
||||
|
||||
builder.addThresholds(thresholdOptions);
|
||||
|
||||
builder.addScale({
|
||||
scaleKey: 'x',
|
||||
time: true,
|
||||
min: minTimeScale,
|
||||
max: maxTimeScale,
|
||||
logBase: isLogScale ? 10 : undefined,
|
||||
distribution: isLogScale
|
||||
? DistributionType.Logarithmic
|
||||
: DistributionType.Linear,
|
||||
});
|
||||
|
||||
builder.addScale({
|
||||
scaleKey: 'y',
|
||||
time: false,
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
softMin,
|
||||
softMax,
|
||||
thresholds: thresholdOptions,
|
||||
logBase: isLogScale ? 10 : undefined,
|
||||
distribution: isLogScale
|
||||
? DistributionType.Logarithmic
|
||||
: DistributionType.Linear,
|
||||
});
|
||||
|
||||
if (typeof onClick === 'function') {
|
||||
builder.addPlugin(onClickPlugin({ onClick, apiResponse: clickPayload }));
|
||||
}
|
||||
|
||||
builder.addAxis({
|
||||
scaleKey: 'x',
|
||||
show: true,
|
||||
side: 2,
|
||||
isDarkMode,
|
||||
isLogScale,
|
||||
panelType,
|
||||
});
|
||||
|
||||
builder.addAxis({
|
||||
scaleKey: 'y',
|
||||
show: true,
|
||||
side: 3,
|
||||
isDarkMode,
|
||||
isLogScale,
|
||||
yAxisUnit,
|
||||
panelType,
|
||||
});
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
function makeTzDate(
|
||||
timezone: Timezone,
|
||||
): ((timestamp: number) => Date) | undefined {
|
||||
if (!timezone) {
|
||||
return undefined;
|
||||
}
|
||||
return (timestamp: number): Date =>
|
||||
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value);
|
||||
}
|
||||
|
||||
function resolveSelectionPreferencesSource(
|
||||
panelMode: PanelMode,
|
||||
): SelectionPreferencesSource {
|
||||
return panelMode === PanelMode.DASHBOARD_VIEW ||
|
||||
panelMode === PanelMode.STANDALONE_VIEW
|
||||
? SelectionPreferencesSource.LOCAL_STORAGE
|
||||
: SelectionPreferencesSource.IN_MEMORY;
|
||||
}
|
||||
|
||||
// Perses-shape thresholds → the draw-hook shape uPlotV2 consumes. Exported so
|
||||
// panels that need to feed the same threshold list elsewhere (e.g. to a series
|
||||
// `addSeries` thresholds hook) don't have to redo the mapping.
|
||||
export function mapThresholds(
|
||||
thresholds: DashboardtypesThresholdWithLabelDTO[] | null | undefined,
|
||||
): ThresholdsDrawHookOptions['thresholds'] {
|
||||
if (!thresholds || thresholds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return thresholds.map((t) => ({
|
||||
thresholdValue: t.value,
|
||||
thresholdColor: t.color,
|
||||
thresholdUnit: t.unit,
|
||||
thresholdLabel: t.label,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* V5 backend reports per-query step intervals; we feed the smallest one through
|
||||
* to uPlot so the X-axis tick density matches the densest query. An empty map
|
||||
* yields `Infinity` from `Math.min`, which would corrupt downstream scale math —
|
||||
* fall back to `undefined` (uPlot's "auto") in that case.
|
||||
*/
|
||||
function minStepInterval(
|
||||
stepIntervals: Record<string, number>,
|
||||
): number | undefined {
|
||||
const values = Object.values(stepIntervals);
|
||||
if (values.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const min = Math.min(...values);
|
||||
return Number.isFinite(min) ? min : undefined;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
DashboardtypesFillModeDTO,
|
||||
DashboardtypesLegendPositionDTO,
|
||||
DashboardtypesLineInterpolationDTO,
|
||||
DashboardtypesLineStyleDTO,
|
||||
DashboardtypesPrecisionOptionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import {
|
||||
FillMode,
|
||||
LineInterpolation,
|
||||
LineStyle,
|
||||
} from 'lib/uPlotV2/config/types';
|
||||
|
||||
/**
|
||||
* Bridges the V2 dashboard wire-format enums (snake_case, generated from Go)
|
||||
* to the uPlotV2 chart enums (PascalCase). String values diverge between the
|
||||
* two — don't coerce, map.
|
||||
*
|
||||
* Kept as a single source of truth so every panel that reads chart-appearance
|
||||
* fields stays in sync as either side's enum evolves.
|
||||
*/
|
||||
|
||||
export const LINE_STYLE_MAP: Record<DashboardtypesLineStyleDTO, LineStyle> = {
|
||||
[DashboardtypesLineStyleDTO.solid]: LineStyle.Solid,
|
||||
[DashboardtypesLineStyleDTO.dashed]: LineStyle.Dashed,
|
||||
};
|
||||
|
||||
export const LINE_INTERPOLATION_MAP: Record<
|
||||
DashboardtypesLineInterpolationDTO,
|
||||
LineInterpolation
|
||||
> = {
|
||||
[DashboardtypesLineInterpolationDTO.linear]: LineInterpolation.Linear,
|
||||
[DashboardtypesLineInterpolationDTO.spline]: LineInterpolation.Spline,
|
||||
[DashboardtypesLineInterpolationDTO.step_after]: LineInterpolation.StepAfter,
|
||||
[DashboardtypesLineInterpolationDTO.step_before]: LineInterpolation.StepBefore,
|
||||
};
|
||||
|
||||
export const FILL_MODE_MAP: Record<DashboardtypesFillModeDTO, FillMode> = {
|
||||
[DashboardtypesFillModeDTO.solid]: FillMode.Solid,
|
||||
[DashboardtypesFillModeDTO.gradient]: FillMode.Gradient,
|
||||
[DashboardtypesFillModeDTO.none]: FillMode.None,
|
||||
};
|
||||
|
||||
export const LEGEND_POSITION_MAP: Record<
|
||||
DashboardtypesLegendPositionDTO,
|
||||
LegendPosition
|
||||
> = {
|
||||
[DashboardtypesLegendPositionDTO.bottom]: LegendPosition.BOTTOM,
|
||||
[DashboardtypesLegendPositionDTO.right]: LegendPosition.RIGHT,
|
||||
};
|
||||
|
||||
/**
|
||||
* `spec.formatting.decimalPrecision` is a stringified-digit enum on the wire
|
||||
* (`'0'`–`'4'` plus the sentinel `'full'`). The chart consumes a numeric
|
||||
* `PrecisionOption` (`0`–`4`) or the same `'full'` sentinel from its own
|
||||
* enum. Missing / unknown → `undefined` (chart uses its default).
|
||||
*/
|
||||
export function resolveDecimalPrecision(
|
||||
precision: DashboardtypesPrecisionOptionDTO | undefined,
|
||||
): PrecisionOption | undefined {
|
||||
if (!precision) {
|
||||
return undefined;
|
||||
}
|
||||
if (precision === DashboardtypesPrecisionOptionDTO.full) {
|
||||
return PrecisionOptionsEnum.FULL;
|
||||
}
|
||||
const parsed = Number(precision);
|
||||
if (
|
||||
parsed === 0 ||
|
||||
parsed === 1 ||
|
||||
parsed === 2 ||
|
||||
parsed === 3 ||
|
||||
parsed === 4
|
||||
) {
|
||||
return parsed;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* `spec.chartAppearance.spanGaps.fillLessThan` is a stringified number on the
|
||||
* wire. Empty / missing → span all gaps (the chart default). Numeric → forward
|
||||
* the threshold so uPlot only bridges short runs of nulls.
|
||||
*/
|
||||
export function resolveSpanGaps(
|
||||
fillLessThan: string | undefined,
|
||||
): boolean | number {
|
||||
if (!fillLessThan) {
|
||||
return true;
|
||||
}
|
||||
const parsed = Number(fillLessThan);
|
||||
return Number.isFinite(parsed) ? parsed : true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the legend position for a panel. Missing / unknown values fall
|
||||
* back to `BOTTOM` to match the chart's default and the V1 behavior.
|
||||
*/
|
||||
export function resolveLegendPosition(
|
||||
position: DashboardtypesLegendPositionDTO | undefined,
|
||||
): LegendPosition {
|
||||
if (position && position in LEGEND_POSITION_MAP) {
|
||||
return LEGEND_POSITION_MAP[position];
|
||||
}
|
||||
return LegendPosition.BOTTOM;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
UniversalUnitToGrafanaUnit,
|
||||
YAxisCategoryNames,
|
||||
} from 'components/YAxisUnitSelector/constants';
|
||||
import { YAxisSource } from 'components/YAxisUnitSelector/types';
|
||||
import { getYAxisCategories } from 'components/YAxisUnitSelector/utils';
|
||||
import { convertValue } from 'lib/getConvertedValue';
|
||||
|
||||
import type {
|
||||
PanelThreshold,
|
||||
ThresholdComparisonOperator,
|
||||
} from '../types/threshold';
|
||||
|
||||
/**
|
||||
* Threshold evaluation for V2 panels — a self-contained port of the V1
|
||||
* `GridTableComponent`/`ValueGraph` logic that depends only on shared,
|
||||
* non-V1 primitives (`convertValue`, the Y-axis unit catalog). No imports
|
||||
* from `container/NewWidget`, `container/GridTableComponent`, or
|
||||
* `components/ValueGraph`.
|
||||
*/
|
||||
|
||||
/** Resolves which unit category a unit id belongs to, or null if unknown. */
|
||||
function getCategoryName(unitId: string): YAxisCategoryNames | null {
|
||||
const categories = getYAxisCategories(YAxisSource.DASHBOARDS);
|
||||
|
||||
const foundCategory = categories.find((category) =>
|
||||
category.units.some((unit) => {
|
||||
// Category units use universal ids; thresholds/panel units may use
|
||||
// Grafana-style ids. Match either the universal id directly or its
|
||||
// mapped Grafana id.
|
||||
if (unit.id === unitId) {
|
||||
return true;
|
||||
}
|
||||
return UniversalUnitToGrafanaUnit[unit.id] === unitId;
|
||||
}),
|
||||
);
|
||||
|
||||
return foundCategory ? foundCategory.name : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts `value` from `fromUnit` to `toUnit`, returning null when the
|
||||
* conversion is invalid (unknown unit, or units in different categories).
|
||||
*/
|
||||
function convertUnit(
|
||||
value: number,
|
||||
fromUnit?: string,
|
||||
toUnit?: string,
|
||||
): number | null {
|
||||
if (!fromUnit || !toUnit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fromCategory = getCategoryName(fromUnit);
|
||||
const toCategory = getCategoryName(toUnit);
|
||||
|
||||
if (!fromCategory || !toCategory || fromCategory !== toCategory) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return convertValue(value, fromUnit, toUnit);
|
||||
}
|
||||
|
||||
function evaluateCondition(
|
||||
operator: ThresholdComparisonOperator | undefined,
|
||||
value: number,
|
||||
thresholdValue: number,
|
||||
): boolean {
|
||||
switch (operator) {
|
||||
case '>':
|
||||
return value > thresholdValue;
|
||||
case '<':
|
||||
return value < thresholdValue;
|
||||
case '>=':
|
||||
return value >= thresholdValue;
|
||||
case '<=':
|
||||
return value <= thresholdValue;
|
||||
case '=':
|
||||
return value === thresholdValue;
|
||||
case '!=':
|
||||
return value !== thresholdValue;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `value` (expressed in `panelUnit`) satisfies `threshold`. When the
|
||||
* threshold declares its own unit, the panel value is converted into that unit
|
||||
* before comparing; if the conversion is invalid we compare the raw value.
|
||||
*/
|
||||
export function doesValueMatchThreshold(
|
||||
value: number,
|
||||
threshold: PanelThreshold,
|
||||
panelUnit?: string,
|
||||
): boolean {
|
||||
if (threshold.operator === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const convertedValue = convertUnit(value, panelUnit, threshold.unit);
|
||||
const comparable = convertedValue ?? value;
|
||||
|
||||
return evaluateCondition(threshold.operator, comparable, threshold.value);
|
||||
}
|
||||
|
||||
export interface ActiveThreshold {
|
||||
/** The matched threshold to apply, or null when none match. */
|
||||
threshold: PanelThreshold | null;
|
||||
/** True when more than one threshold matched the value. */
|
||||
isConflicting: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the threshold to apply for `value`. Among matching thresholds the
|
||||
* one declared earliest (lowest index) wins, mirroring V1 precedence; a match
|
||||
* count greater than one flags a conflict.
|
||||
*/
|
||||
export function resolveActiveThreshold(
|
||||
thresholds: PanelThreshold[],
|
||||
value: number,
|
||||
panelUnit?: string,
|
||||
): ActiveThreshold {
|
||||
const matching = thresholds.filter((threshold) =>
|
||||
doesValueMatchThreshold(value, threshold, panelUnit),
|
||||
);
|
||||
|
||||
if (matching.length === 0) {
|
||||
return { threshold: null, isConflicting: false };
|
||||
}
|
||||
|
||||
const highestPrecedence = matching.reduce((winner, candidate) =>
|
||||
thresholds.indexOf(candidate) < thresholds.indexOf(winner)
|
||||
? candidate
|
||||
: winner,
|
||||
);
|
||||
|
||||
return { threshold: highestPrecedence, isConflicting: matching.length > 1 };
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { PrecisionOption } from 'components/Graph/types';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
|
||||
/**
|
||||
* Formats a scalar for display in a V2 panel, honoring the configured decimal
|
||||
* precision. The shared, unit-aware `getYAxisFormattedValue` is the single
|
||||
* formatting helper across V2 panels (number/table/list/pie); this wrapper is
|
||||
* the only seam through which panels touch it.
|
||||
*
|
||||
* Precision is applied REGARDLESS of whether a unit is set. When no unit is
|
||||
* configured we format through the `'none'` unit, which still respects
|
||||
* precision — this is the fix for decimal precision being silently dropped on
|
||||
* unitless panels (the old `unit ? format() : value.toString()` gate threw the
|
||||
* precision away whenever the unit was empty).
|
||||
*/
|
||||
export function formatPanelValue(
|
||||
value: number,
|
||||
unit?: string,
|
||||
precision?: PrecisionOption,
|
||||
): string {
|
||||
return getYAxisFormattedValue(String(value), unit || 'none', precision);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
|
||||
/**
|
||||
* Flattens a panel's queries into the list of builder queries it contains —
|
||||
* unwrapping `CompositeQuery` envelopes along the way. Non-builder kinds
|
||||
* (PromQL, ClickHouseSQL, Formula, TraceOperator) are dropped: they don't
|
||||
* carry the legend / groupBy / aggregation context downstream code needs.
|
||||
*
|
||||
* Returns the generated v5 `BuilderQuery` shape directly — no intermediate
|
||||
* summary type — so callers consume the same type the wire format defines.
|
||||
*/
|
||||
export function getBuilderQueries(
|
||||
queries: DashboardtypesQueryDTO[] | null | undefined,
|
||||
): BuilderQuery[] {
|
||||
if (!queries) {
|
||||
return [];
|
||||
}
|
||||
const flattened: BuilderQuery[] = [];
|
||||
queries.forEach((envelope) => {
|
||||
const plugin = envelope?.spec?.plugin;
|
||||
if (!plugin) {
|
||||
return;
|
||||
}
|
||||
if (plugin.kind === 'signoz/BuilderQuery') {
|
||||
flattened.push(plugin.spec as BuilderQuery);
|
||||
return;
|
||||
}
|
||||
if (plugin.kind === 'signoz/CompositeQuery') {
|
||||
(plugin.spec.queries ?? []).forEach((sub) => {
|
||||
if (sub.type === 'builder_query') {
|
||||
flattened.push(sub.spec as BuilderQuery);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
return flattened;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export interface ParsedFormattedValue {
|
||||
/** The numeric portion (e.g. "295.43", "1.2K"). */
|
||||
numericValue: string;
|
||||
/** A leading unit symbol such as a currency prefix, if any. */
|
||||
prefixUnit: string;
|
||||
/** A trailing unit label such as "ms" or "MB", if any. */
|
||||
suffixUnit: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a formatted value string (e.g. "$ 1.2K", "295.43 ms") into its
|
||||
* numeric core and any prefix/suffix unit so each part can be styled
|
||||
* independently. Falls back to treating the whole string as the numeric value
|
||||
* when it doesn't match the expected shape.
|
||||
*/
|
||||
export function parseFormattedValue(value: string): ParsedFormattedValue {
|
||||
const matches = value.match(
|
||||
/^([^\d.]*)?([\d.]+(?:[eE][+-]?[\d]+)?[KMB]?)([^\d.]*)?$/,
|
||||
);
|
||||
|
||||
return {
|
||||
numericValue: matches?.[2] || value,
|
||||
prefixUnit: matches?.[1]?.trim() || '',
|
||||
suffixUnit: matches?.[3]?.trim() || '',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
|
||||
/**
|
||||
* Identity of one series for label resolution: which query produced it and
|
||||
* which of that query's aggregations.
|
||||
*/
|
||||
interface SeriesIdentity {
|
||||
queryName: string;
|
||||
/** Index into the matched query's aggregation list. */
|
||||
aggregationIndex: number;
|
||||
/** Fallback when the base label is empty and the aggregation is bare. */
|
||||
fallbackName?: string;
|
||||
}
|
||||
|
||||
/** Resolves the display label for one flattened V5 series. */
|
||||
export function resolveSeriesLabelV5(
|
||||
series: PanelSeries,
|
||||
builderQueries: BuilderQuery[],
|
||||
baseLabel: string,
|
||||
): string {
|
||||
return resolveLabel(
|
||||
{
|
||||
queryName: series.queryName,
|
||||
aggregationIndex: series.aggregation.index,
|
||||
fallbackName: series.queryName,
|
||||
},
|
||||
builderQueries,
|
||||
baseLabel,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the V1 legend matrix: `single-vs-many builder queries ×
|
||||
* with/without groupBy × single-vs-many aggregations`. Returns `baseLabel`
|
||||
* unchanged for panels without builder queries (PromQL, ClickHouseSQL) and
|
||||
* for builder series whose aggregation carries no alias/expression — metric
|
||||
* aggregations don't have those fields, so they naturally short-circuit to
|
||||
* the base label here.
|
||||
*/
|
||||
function resolveLabel(
|
||||
identity: SeriesIdentity,
|
||||
builderQueries: BuilderQuery[],
|
||||
baseLabel: string,
|
||||
): string {
|
||||
if (builderQueries.length === 0) {
|
||||
return baseLabel;
|
||||
}
|
||||
|
||||
const matching = builderQueries.find((q) => q.name === identity.queryName);
|
||||
if (!matching) {
|
||||
return baseLabel;
|
||||
}
|
||||
|
||||
const aggIndex = identity.aggregationIndex;
|
||||
const aggregations = matching.aggregations ?? [];
|
||||
const aggregation = aggregations[aggIndex];
|
||||
|
||||
// `alias` / `expression` exist on Log/Trace aggregations only —
|
||||
// `MetricAggregation` carries `metricName`/`temporality`/… instead. The
|
||||
// `in` guards narrow the union without a cast.
|
||||
const aggregationAlias =
|
||||
aggregation && 'alias' in aggregation ? (aggregation.alias ?? '') : '';
|
||||
const aggregationExpression =
|
||||
aggregation && 'expression' in aggregation
|
||||
? (aggregation.expression ?? '')
|
||||
: '';
|
||||
|
||||
if (!aggregationAlias && !aggregationExpression) {
|
||||
return baseLabel || identity.fallbackName || matching.name || '';
|
||||
}
|
||||
|
||||
const ctx: FormatContext = {
|
||||
aggregationAlias,
|
||||
aggregationExpression,
|
||||
baseLabel,
|
||||
legend: matching.legend ?? '',
|
||||
hasGroupBy: (matching.groupBy?.length ?? 0) > 0,
|
||||
singleAggregation: aggregations.length === 1,
|
||||
};
|
||||
|
||||
return builderQueries.length === 1
|
||||
? formatForSinglePanelQuery(ctx)
|
||||
: formatForMultiplePanelQueries(ctx);
|
||||
}
|
||||
|
||||
interface FormatContext {
|
||||
aggregationAlias: string;
|
||||
aggregationExpression: string;
|
||||
baseLabel: string;
|
||||
legend: string;
|
||||
hasGroupBy: boolean;
|
||||
singleAggregation: boolean;
|
||||
}
|
||||
|
||||
// Panel has one builder query — ports V1's `getLegendForSingleAggregation`.
|
||||
function formatForSinglePanelQuery({
|
||||
aggregationAlias,
|
||||
aggregationExpression,
|
||||
baseLabel,
|
||||
legend,
|
||||
hasGroupBy,
|
||||
singleAggregation,
|
||||
}: FormatContext): string {
|
||||
if (hasGroupBy) {
|
||||
if (singleAggregation) {
|
||||
return baseLabel;
|
||||
}
|
||||
return `${aggregationAlias || aggregationExpression}-${baseLabel}`;
|
||||
}
|
||||
if (singleAggregation) {
|
||||
return aggregationAlias || legend || aggregationExpression;
|
||||
}
|
||||
return aggregationAlias || aggregationExpression;
|
||||
}
|
||||
|
||||
// Panel has multiple builder queries — ports V1's `getLegendForMultipleAggregations`.
|
||||
// Differs from the single-query path in two cells: the no-groupBy / single-agg
|
||||
// cell falls through to `baseLabel` instead of `legend`, and the no-groupBy /
|
||||
// multi-agg cell prepends the base label.
|
||||
function formatForMultiplePanelQueries({
|
||||
aggregationAlias,
|
||||
aggregationExpression,
|
||||
baseLabel,
|
||||
hasGroupBy,
|
||||
singleAggregation,
|
||||
}: FormatContext): string {
|
||||
if (hasGroupBy) {
|
||||
if (singleAggregation) {
|
||||
return baseLabel;
|
||||
}
|
||||
return `${aggregationAlias || aggregationExpression}-${baseLabel}`;
|
||||
}
|
||||
if (singleAggregation) {
|
||||
return aggregationAlias || baseLabel || aggregationExpression;
|
||||
}
|
||||
return `${aggregationAlias || aggregationExpression}-${baseLabel}`;
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: var(--bg-ink-400, #0b0c0e);
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,15 @@
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
// Actions sit inside the drag-handle row but opt out of dragging
|
||||
// (`panel-no-drag`); reset the grab cursor so the menu reads as clickable.
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -50,3 +59,41 @@
|
||||
.bodyKind {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
// Container for the rendered chart — fills the panel below the header and lets
|
||||
// the chart shrink (min-* 0) so it resizes with the grid cell.
|
||||
.chartBody {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// Subtle background-refetch spinner in the header (chart stays mounted).
|
||||
.refetchIndicator {
|
||||
color: var(--l2-foreground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// Error state — shown only when there's no stale data to fall back to.
|
||||
.error {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.errorIcon {
|
||||
color: var(--bg-cherry-500);
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
max-width: 90%;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@@ -1,38 +1,41 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { EllipsisVertical } from '@signozhq/icons';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels';
|
||||
import { usePanelQuery } from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
|
||||
|
||||
import type { DashboardSection } from '../../utils';
|
||||
import type { DeletePanelArgs } from './hooks/useDeletePanel';
|
||||
import type { MovePanelArgs } from './hooks/useMovePanelToSection';
|
||||
import PanelActionsMenu from './PanelActionsMenu/PanelActionsMenu';
|
||||
import { usePanelInteractions } from './hooks/usePanelInteractions';
|
||||
import PanelBody from './PanelBody';
|
||||
import PanelHeader from './PanelHeader';
|
||||
import styles from './Panel.module.scss';
|
||||
|
||||
/** Panel action context — present together only in editable sectioned mode. */
|
||||
/**
|
||||
* Layout context for the panel actions menu — pure data, present only in
|
||||
* editable mode. No callbacks: the menu resolves its own mutations from
|
||||
* store-backed hooks (useDeletePanel / useMovePanelToSection), and edit is
|
||||
* URL-driven (useOpenPanelEditor).
|
||||
*/
|
||||
export interface PanelActionsConfig {
|
||||
currentLayoutIndex: number;
|
||||
sections: DashboardSection[];
|
||||
onMovePanel: (args: MovePanelArgs) => void;
|
||||
onDeletePanel: (args: DeletePanelArgs) => void;
|
||||
}
|
||||
|
||||
interface PanelProps {
|
||||
panel: DashboardtypesPanelDTO | undefined;
|
||||
panelId: string;
|
||||
/**
|
||||
* Placeholder: true once this panel's section enters the viewport. The panel
|
||||
* query-loading implementation (later PR) will consume this to lazily fetch
|
||||
* data. Currently unused on purpose.
|
||||
*/
|
||||
/** True once this panel's section enters the viewport — gates the fetch. */
|
||||
isVisible?: boolean;
|
||||
/** Move/delete actions — present only in editable sectioned mode. */
|
||||
panelActions?: PanelActionsConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single dashboard panel: chrome (header) + content (body). Thin orchestrator
|
||||
* — data fetching lives in `usePanelQuery`, cross-panel interactions in
|
||||
* `usePanelInteractions`, and the loading/error/chart state machine in
|
||||
* `PanelBody`.
|
||||
*/
|
||||
function Panel({
|
||||
panel,
|
||||
panelId,
|
||||
@@ -41,9 +44,22 @@ function Panel({
|
||||
}: PanelProps): JSX.Element {
|
||||
const name = panel?.spec?.display?.name || `Panel ${panelId.slice(0, 6)}`;
|
||||
const description = panel?.spec?.display?.description;
|
||||
const kind = panel?.spec?.plugin?.kind?.replace(/^signoz\//, '') ?? 'unknown';
|
||||
const fullKind = panel?.spec?.plugin?.kind;
|
||||
const kind = fullKind?.replace(/^signoz\//, '') ?? 'unknown';
|
||||
const queryCount = panel?.spec?.queries?.length ?? 0;
|
||||
|
||||
const panelDef = getPanelDefinition(fullKind);
|
||||
|
||||
const { data, isLoading, isFetching, error, refetch } = usePanelQuery({
|
||||
panel,
|
||||
panelId,
|
||||
// Lazy: only fetch once the section is on screen (undefined → treat as
|
||||
// visible) and a renderer exists for the kind.
|
||||
enabled: !!panelDef && isVisible !== false,
|
||||
});
|
||||
|
||||
const { onDragSelect, dashboardPreference } = usePanelInteractions();
|
||||
|
||||
const headerTitle = useMemo(() => {
|
||||
if (!description) {
|
||||
return name;
|
||||
@@ -60,35 +76,28 @@ function Panel({
|
||||
className={styles.panel}
|
||||
data-panel-visible={isVisible ? 'true' : 'false'}
|
||||
>
|
||||
<div className={cx(styles.header, 'panel-drag-handle')}>
|
||||
<div className={styles.headerLeft}>
|
||||
<Typography.Text className={styles.headerTitle}>
|
||||
{headerTitle}
|
||||
</Typography.Text>
|
||||
<Badge className={styles.badge}>{kind}</Badge>
|
||||
</div>
|
||||
{panelActions ? (
|
||||
<PanelActionsMenu
|
||||
panelId={panelId}
|
||||
currentLayoutIndex={panelActions.currentLayoutIndex}
|
||||
sections={panelActions.sections}
|
||||
onMovePanel={panelActions.onMovePanel}
|
||||
onDeletePanel={panelActions.onDeletePanel}
|
||||
/>
|
||||
) : (
|
||||
<EllipsisVertical size={14} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
<div>
|
||||
<div className={styles.bodyKind}>{kind} panel</div>
|
||||
<div>
|
||||
{queryCount} {queryCount === 1 ? 'query' : 'queries'} · chart rendering
|
||||
coming next
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PanelHeader
|
||||
title={headerTitle}
|
||||
panelId={panelId}
|
||||
panelKind={fullKind}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
warning={data.response?.data?.warning}
|
||||
panelActions={panelActions}
|
||||
/>
|
||||
<PanelBody
|
||||
panelDef={panelDef}
|
||||
panel={panel}
|
||||
panelId={panelId}
|
||||
kind={kind}
|
||||
queryCount={queryCount}
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
dashboardPreference={dashboardPreference}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,81 +1,37 @@
|
||||
import { useMemo } from 'react';
|
||||
import { EllipsisVertical, FolderInput, Trash2 } from '@signozhq/icons';
|
||||
import { EllipsisVertical } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
|
||||
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
import type { DeletePanelArgs } from '../hooks/useDeletePanel';
|
||||
import type { MovePanelArgs } from '../hooks/useMovePanelToSection';
|
||||
import type { PanelActionsConfig } from '../Panel';
|
||||
import { usePanelActionItems } from './usePanelActionItems';
|
||||
import styles from './PanelActionsMenu.module.scss';
|
||||
|
||||
interface PanelActionsMenuProps {
|
||||
panelId: string;
|
||||
currentLayoutIndex: number;
|
||||
sections: DashboardSection[];
|
||||
onMovePanel?: (args: MovePanelArgs) => void;
|
||||
onDeletePanel?: (args: DeletePanelArgs) => void;
|
||||
/** Full plugin kind (e.g. `signoz/TimeSeriesPanel`); undefined when absent. */
|
||||
panelKind: string | undefined;
|
||||
/** Layout context for move/delete — absent outside editable sectioned mode. */
|
||||
panelActions?: PanelActionsConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Purely presentational: the trigger button + dropdown. Which items appear —
|
||||
* and whether the menu renders at all — is owned by `usePanelActionItems`
|
||||
* (kind ∧ role ∧ context gating per action).
|
||||
*/
|
||||
function PanelActionsMenu({
|
||||
panelId,
|
||||
currentLayoutIndex,
|
||||
sections,
|
||||
onMovePanel,
|
||||
onDeletePanel,
|
||||
}: PanelActionsMenuProps): JSX.Element {
|
||||
const items = useMemo<MenuItem[]>(() => {
|
||||
const result: MenuItem[] = [];
|
||||
panelKind,
|
||||
panelActions,
|
||||
}: PanelActionsMenuProps): JSX.Element | null {
|
||||
const items = usePanelActionItems({ panelId, panelKind, panelActions });
|
||||
|
||||
if (onMovePanel) {
|
||||
const targets = sections.filter(
|
||||
(s) => s.title && s.layoutIndex !== currentLayoutIndex,
|
||||
);
|
||||
if (targets.length === 0) {
|
||||
result.push({
|
||||
key: 'move',
|
||||
label: 'Move to section',
|
||||
icon: <FolderInput size={14} />,
|
||||
disabled: true,
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
key: 'move',
|
||||
label: 'Move to section',
|
||||
icon: <FolderInput size={14} />,
|
||||
children: targets.map((s) => ({
|
||||
key: `move-${s.layoutIndex}`,
|
||||
label: s.title,
|
||||
onClick: (): void =>
|
||||
onMovePanel({
|
||||
panelId,
|
||||
fromLayoutIndex: currentLayoutIndex,
|
||||
toLayoutIndex: s.layoutIndex,
|
||||
}),
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (onDeletePanel) {
|
||||
if (result.length > 0) {
|
||||
result.push({ type: 'divider' });
|
||||
}
|
||||
result.push({
|
||||
key: 'delete-panel',
|
||||
danger: true,
|
||||
icon: <Trash2 size={14} />,
|
||||
label: 'Delete panel',
|
||||
onClick: (): void =>
|
||||
onDeletePanel({ panelId, layoutIndex: currentLayoutIndex }),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [sections, currentLayoutIndex, panelId, onMovePanel, onDeletePanel]);
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuSimple menu={{ items }}>
|
||||
<DropdownMenuSimple menu={{ items }} align="end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type { ROLES } from 'types/roles';
|
||||
|
||||
import type { DashboardSection } from '../../../../utils';
|
||||
import { useDashboardStore } from '../../../../store/useDashboardStore';
|
||||
import { usePanelActionItems } from '../usePanelActionItems';
|
||||
|
||||
const mockOpenEditor = jest.fn();
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/hooks/useOpenPanelEditor',
|
||||
() => ({
|
||||
useOpenPanelEditor: (): jest.Mock => mockOpenEditor,
|
||||
}),
|
||||
);
|
||||
|
||||
const mockMovePanel = jest.fn();
|
||||
jest.mock('../../hooks/useMovePanelToSection', () => ({
|
||||
useMovePanelToSection: (): jest.Mock => mockMovePanel,
|
||||
}));
|
||||
|
||||
const mockDeletePanel = jest.fn();
|
||||
jest.mock('../../hooks/useDeletePanel', () => ({
|
||||
useDeletePanel: (): jest.Mock => mockDeletePanel,
|
||||
}));
|
||||
|
||||
// Role is the only thing read off the app context; useComponentPermission runs
|
||||
// for real so the tests exercise the actual role → permission mapping.
|
||||
let mockRole: ROLES = 'ADMIN';
|
||||
jest.mock('providers/App/App', () => ({
|
||||
useAppContext: (): { user: { role: ROLES } } => ({
|
||||
user: { role: mockRole },
|
||||
}),
|
||||
}));
|
||||
|
||||
function section(
|
||||
layoutIndex: number,
|
||||
title: string | undefined,
|
||||
): DashboardSection {
|
||||
return {
|
||||
id: `section-${layoutIndex}`,
|
||||
layoutIndex,
|
||||
title,
|
||||
items: [],
|
||||
repeatVariable: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const TWO_TITLED_SECTIONS = [section(0, 'Overview'), section(1, 'Latency')];
|
||||
|
||||
const baseArgs = {
|
||||
panelId: 'panel-1',
|
||||
panelKind: 'signoz/TimeSeriesPanel',
|
||||
panelActions: { currentLayoutIndex: 0, sections: TWO_TITLED_SECTIONS },
|
||||
};
|
||||
|
||||
function itemKeys(items: ReturnType<typeof usePanelActionItems>): unknown[] {
|
||||
return items.map((item) =>
|
||||
'key' in item && item.key !== undefined ? item.key : item.type,
|
||||
);
|
||||
}
|
||||
|
||||
describe('usePanelActionItems', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockRole = 'ADMIN';
|
||||
useDashboardStore.setState({ isEditable: true });
|
||||
});
|
||||
|
||||
it('ADMIN on an editable dashboard with a known kind gets the full V1-parity set, divider-separated', () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
expect(itemKeys(result.current)).toStrictEqual([
|
||||
'view-panel',
|
||||
'edit-panel',
|
||||
'clone-panel',
|
||||
'divider',
|
||||
'create-alert',
|
||||
'divider',
|
||||
'move',
|
||||
'divider',
|
||||
'delete-panel',
|
||||
]);
|
||||
// download stays hidden: no current kind declares the capability
|
||||
// (V1 parity — CSV export was table-only).
|
||||
});
|
||||
|
||||
it('AUTHOR loses edit and clone (edit_widget excludes AUTHOR) but keeps the rest', () => {
|
||||
mockRole = 'AUTHOR';
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
expect(itemKeys(result.current)).toStrictEqual([
|
||||
'view-panel',
|
||||
'divider',
|
||||
'create-alert',
|
||||
'divider',
|
||||
'move',
|
||||
'divider',
|
||||
'delete-panel',
|
||||
]);
|
||||
});
|
||||
|
||||
it('VIEWER keeps only the role-ungated actions (view, create-alert)', () => {
|
||||
mockRole = 'VIEWER';
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
expect(itemKeys(result.current)).toStrictEqual([
|
||||
'view-panel',
|
||||
'divider',
|
||||
'create-alert',
|
||||
]);
|
||||
});
|
||||
|
||||
it('unknown panel kind hides all kind-gated actions but keeps the chrome ones (clone/move/delete)', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({ ...baseArgs, panelKind: 'signoz/TablePanel' }),
|
||||
);
|
||||
expect(itemKeys(result.current)).toStrictEqual([
|
||||
'clone-panel',
|
||||
'divider',
|
||||
'move',
|
||||
'divider',
|
||||
'delete-panel',
|
||||
]);
|
||||
});
|
||||
|
||||
it('read-only dashboard keeps only View (V1 parity)', () => {
|
||||
useDashboardStore.setState({ isEditable: false });
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({ ...baseArgs, panelActions: undefined }),
|
||||
);
|
||||
expect(itemKeys(result.current)).toStrictEqual(['view-panel']);
|
||||
});
|
||||
|
||||
it('move is disabled when there is no other titled section to move to', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({
|
||||
...baseArgs,
|
||||
panelActions: {
|
||||
currentLayoutIndex: 0,
|
||||
sections: [section(0, 'Overview'), section(1, undefined)],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const move = result.current.find((i) => 'key' in i && i.key === 'move');
|
||||
expect(move).toMatchObject({ disabled: true });
|
||||
});
|
||||
|
||||
it('edit opens the panel editor for this panel', () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
const edit = result.current.find((i) => 'key' in i && i.key === 'edit-panel');
|
||||
(edit as { onClick: () => void }).onClick();
|
||||
expect(mockOpenEditor).toHaveBeenCalledWith('panel-1');
|
||||
});
|
||||
|
||||
it('move targets call the mutation with from/to layout indexes', () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
const move = result.current.find((i) => 'key' in i && i.key === 'move') as {
|
||||
children: { key: string; onClick: () => void }[];
|
||||
};
|
||||
expect(move.children).toHaveLength(1);
|
||||
move.children[0].onClick();
|
||||
expect(mockMovePanel).toHaveBeenCalledWith({
|
||||
panelId: 'panel-1',
|
||||
fromLayoutIndex: 0,
|
||||
toLayoutIndex: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('delete calls the mutation with the panel and its layout index', () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
const del = result.current.find(
|
||||
(i) => 'key' in i && i.key === 'delete-panel',
|
||||
);
|
||||
(del as { onClick: () => void }).onClick();
|
||||
expect(mockDeletePanel).toHaveBeenCalledWith({
|
||||
panelId: 'panel-1',
|
||||
layoutIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('not-yet-implemented actions (view/clone/create-alert) fire the placeholder alert with the feature name', () => {
|
||||
const alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
|
||||
['view-panel', 'clone-panel', 'create-alert'].forEach((key) => {
|
||||
const item = result.current.find((i) => 'key' in i && i.key === key);
|
||||
(item as { onClick: () => void }).onClick();
|
||||
});
|
||||
|
||||
expect(alertSpy).toHaveBeenCalledTimes(3);
|
||||
expect(alertSpy).toHaveBeenCalledWith('View option clicked');
|
||||
expect(alertSpy).toHaveBeenCalledWith('Clone option clicked');
|
||||
expect(alertSpy).toHaveBeenCalledWith('Create Alerts option clicked');
|
||||
alertSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { PanelActionCapabilities } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type { ComponentTypes } from 'utils/permission';
|
||||
|
||||
/**
|
||||
* Every action the panel menu can offer. `Record<PanelActionId, …>` below
|
||||
* forces a meta entry per id — adding an action without declaring its gates is
|
||||
* a compile error.
|
||||
*/
|
||||
export type PanelActionId =
|
||||
| 'view'
|
||||
| 'edit'
|
||||
| 'clone'
|
||||
| 'download'
|
||||
| 'createAlert'
|
||||
| 'move'
|
||||
| 'delete';
|
||||
|
||||
export interface PanelActionMeta {
|
||||
/**
|
||||
* Role gate: componentPermission key checked against the current user.
|
||||
* Absent = available to every role (V1 parity: view, download and
|
||||
* create-alerts were never role-gated).
|
||||
*/
|
||||
permission?: ComponentTypes;
|
||||
/**
|
||||
* Kind gate: the PanelActionCapabilities flag this action requires.
|
||||
* Chrome actions (move/clone/delete) are layout concerns available for
|
||||
* every panel kind — including kinds V2 can't render — so they declare none.
|
||||
*/
|
||||
capability?: keyof PanelActionCapabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for how each panel action is gated, mirroring V1's
|
||||
* WidgetHeader rules. The third gate — context (dashboard editable, target
|
||||
* sections available) — is runtime state resolved in `usePanelActionItems`,
|
||||
* not declarable here.
|
||||
*/
|
||||
export const PANEL_ACTION_META: Record<PanelActionId, PanelActionMeta> = {
|
||||
view: { capability: 'view' },
|
||||
edit: { permission: 'edit_widget', capability: 'edit' },
|
||||
clone: { permission: 'edit_widget' },
|
||||
download: { capability: 'download' },
|
||||
createAlert: { capability: 'createAlert' },
|
||||
// Moving a panel between sections mutates the dashboard layout.
|
||||
move: { permission: 'edit_dashboard' },
|
||||
delete: { permission: 'delete_widget' },
|
||||
};
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
Bell,
|
||||
CloudDownload,
|
||||
Copy,
|
||||
FolderInput,
|
||||
Fullscreen,
|
||||
PenLine,
|
||||
Trash2,
|
||||
} from '@signozhq/icons';
|
||||
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels';
|
||||
import { useOpenPanelEditor } from 'pages/DashboardPageV2/DashboardContainer/hooks/useOpenPanelEditor';
|
||||
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
import type { PanelActionsConfig } from '../Panel';
|
||||
import { useDeletePanel } from '../hooks/useDeletePanel';
|
||||
import { useMovePanelToSection } from '../hooks/useMovePanelToSection';
|
||||
import { PANEL_ACTION_META } from './panelActionMeta';
|
||||
|
||||
// Stable fallback so renders without layout context don't churn the mutation
|
||||
// hooks' deps (a fresh [] each render would re-create their callbacks).
|
||||
const EMPTY_SECTIONS: DashboardSection[] = [];
|
||||
|
||||
// Placeholder for the V1-parity actions whose V2 implementations land in
|
||||
// later milestones (view, clone, download, create-alerts).
|
||||
function notImplementedYet(feature: string): void {
|
||||
// eslint-disable-next-line no-alert -- temporary placeholder, see above
|
||||
alert(`${feature} option clicked`);
|
||||
}
|
||||
|
||||
interface UsePanelActionItemsArgs {
|
||||
panelId: string;
|
||||
/** Full plugin kind (e.g. `signoz/TimeSeriesPanel`); undefined when absent. */
|
||||
panelKind: string | undefined;
|
||||
/** Layout context for move/delete — absent outside editable mode. */
|
||||
panelActions?: PanelActionsConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the panel actions menu items (the V1 WidgetHeader action set plus
|
||||
* V2's "Move to section"). Every action passes three gates before it appears:
|
||||
*
|
||||
* kind — what the panel kind declares it supports (PanelDefinition.actions);
|
||||
* unknown kinds support no kind-gated actions.
|
||||
* role — componentPermission lookup for the current user (PANEL_ACTION_META;
|
||||
* actions without a permission key are open to every role, V1 parity).
|
||||
* context — runtime state: dashboard editable (store), layout config present.
|
||||
* View and Download remain available on read-only dashboards, as in V1.
|
||||
*
|
||||
* Items are composed as groups with dividers inserted between non-empty
|
||||
* groups, so adding an action never touches divider bookkeeping.
|
||||
*/
|
||||
export function usePanelActionItems({
|
||||
panelId,
|
||||
panelKind,
|
||||
panelActions,
|
||||
}: UsePanelActionItemsArgs): MenuItem[] {
|
||||
const { user } = useAppContext();
|
||||
const [canEditWidget, canMove, canDelete] = useComponentPermission(
|
||||
[
|
||||
// edit_widget gates both Edit and Clone, exactly as in V1.
|
||||
PANEL_ACTION_META.edit.permission ?? 'edit_widget',
|
||||
PANEL_ACTION_META.move.permission ?? 'edit_dashboard',
|
||||
PANEL_ACTION_META.delete.permission ?? 'delete_widget',
|
||||
],
|
||||
user.role,
|
||||
);
|
||||
// Folds in the dashboard lock + edit_dashboard permission (set once by
|
||||
// DashboardContainer). Mutating actions respect it; view/download don't.
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const openPanelEditor = useOpenPanelEditor();
|
||||
|
||||
// Mutations are store-backed (dashboardId/refetch) — the layout tree only
|
||||
// supplies data (`sections`), so no callbacks are threaded through it.
|
||||
const sections = panelActions?.sections ?? EMPTY_SECTIONS;
|
||||
const movePanel = useMovePanelToSection({ sections });
|
||||
const deletePanel = useDeletePanel({ sections });
|
||||
|
||||
const kindActions = getPanelDefinition(panelKind)?.actions;
|
||||
|
||||
return useMemo<MenuItem[]>(() => {
|
||||
// Group 1 — open/author the panel: View, Edit, Clone.
|
||||
const panelGroup: MenuItem[] = [];
|
||||
if (kindActions?.view) {
|
||||
panelGroup.push({
|
||||
key: 'view-panel',
|
||||
label: 'View',
|
||||
icon: <Fullscreen size={14} />,
|
||||
onClick: (): void => notImplementedYet('View'),
|
||||
});
|
||||
}
|
||||
if (isEditable && canEditWidget && kindActions?.edit) {
|
||||
panelGroup.push({
|
||||
key: 'edit-panel',
|
||||
label: 'Edit panel',
|
||||
icon: <PenLine size={14} />,
|
||||
onClick: (): void => openPanelEditor(panelId),
|
||||
});
|
||||
}
|
||||
if (isEditable && canEditWidget) {
|
||||
panelGroup.push({
|
||||
key: 'clone-panel',
|
||||
label: 'Clone',
|
||||
icon: <Copy size={14} />,
|
||||
onClick: (): void => notImplementedYet('Clone'),
|
||||
});
|
||||
}
|
||||
|
||||
// Group 2 — derive from the panel's data: Download, Create Alerts.
|
||||
const dataGroup: MenuItem[] = [];
|
||||
if (kindActions?.download) {
|
||||
dataGroup.push({
|
||||
key: 'download-panel',
|
||||
label: 'Download as CSV',
|
||||
icon: <CloudDownload size={14} />,
|
||||
onClick: (): void => notImplementedYet('Download'),
|
||||
});
|
||||
}
|
||||
if (isEditable && kindActions?.createAlert) {
|
||||
dataGroup.push({
|
||||
key: 'create-alert',
|
||||
label: 'Create Alerts',
|
||||
icon: <Bell size={14} />,
|
||||
onClick: (): void => notImplementedYet('Create Alerts'),
|
||||
});
|
||||
}
|
||||
|
||||
// Group 3 — layout: Move to section.
|
||||
const moveGroup: MenuItem[] = [];
|
||||
if (canMove && panelActions) {
|
||||
const targets = sections.filter(
|
||||
(s) => s.title && s.layoutIndex !== panelActions.currentLayoutIndex,
|
||||
);
|
||||
moveGroup.push({
|
||||
key: 'move',
|
||||
label: 'Move to section',
|
||||
icon: <FolderInput size={14} />,
|
||||
...(targets.length === 0
|
||||
? { disabled: true }
|
||||
: {
|
||||
children: targets.map((s) => ({
|
||||
key: `move-${s.layoutIndex}`,
|
||||
label: s.title,
|
||||
onClick: (): void =>
|
||||
void movePanel({
|
||||
panelId,
|
||||
fromLayoutIndex: panelActions.currentLayoutIndex,
|
||||
toLayoutIndex: s.layoutIndex,
|
||||
}),
|
||||
})),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// Group 4 — danger: Delete.
|
||||
const deleteGroup: MenuItem[] =
|
||||
canDelete && panelActions
|
||||
? [
|
||||
{
|
||||
key: 'delete-panel',
|
||||
danger: true,
|
||||
icon: <Trash2 size={14} />,
|
||||
label: 'Delete panel',
|
||||
onClick: (): void =>
|
||||
void deletePanel({
|
||||
panelId,
|
||||
layoutIndex: panelActions.currentLayoutIndex,
|
||||
}),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return [panelGroup, dataGroup, moveGroup, deleteGroup]
|
||||
.filter((group) => group.length > 0)
|
||||
.flatMap((group, index) =>
|
||||
index === 0 ? group : [{ type: 'divider' as const }, ...group],
|
||||
);
|
||||
}, [
|
||||
isEditable,
|
||||
canEditWidget,
|
||||
canMove,
|
||||
canDelete,
|
||||
kindActions,
|
||||
panelActions,
|
||||
sections,
|
||||
panelId,
|
||||
openPanelEditor,
|
||||
movePanel,
|
||||
deletePanel,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Spin } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Loader, TriangleAlert } from '@signozhq/icons';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
|
||||
import styles from './Panel.module.scss';
|
||||
|
||||
interface PanelBodyProps {
|
||||
/** Resolved renderer for the panel kind; undefined when the kind is unknown. */
|
||||
panelDef: RenderablePanelDefinition | undefined;
|
||||
panel: DashboardtypesPanelDTO | undefined;
|
||||
panelId: string;
|
||||
kind: string;
|
||||
queryCount: number;
|
||||
data: PanelQueryData;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => void;
|
||||
onDragSelect: (start: number, end: number) => void;
|
||||
dashboardPreference: DashboardPreference;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the panel content as an explicit state machine so each state is
|
||||
* handled deliberately (no implicit fall-through):
|
||||
*
|
||||
* unknown-kind → unsupported fallback
|
||||
* error + no data → error message with retry
|
||||
* first load (no data) → loading indicator
|
||||
* otherwise → the kind's renderer (which owns its own "No Data" state, and
|
||||
* keeps stale data mounted during background refetches)
|
||||
*/
|
||||
function PanelBody({
|
||||
panelDef,
|
||||
panel,
|
||||
panelId,
|
||||
kind,
|
||||
queryCount,
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
onDragSelect,
|
||||
dashboardPreference,
|
||||
}: PanelBodyProps): JSX.Element {
|
||||
if (!panelDef) {
|
||||
return (
|
||||
<div className={styles.body} data-testid="panel-unknown-kind-fallback">
|
||||
<div>
|
||||
<div className={styles.bodyKind}>{kind} panel</div>
|
||||
<div>
|
||||
{queryCount} {queryCount === 1 ? 'query' : 'queries'} · not yet supported
|
||||
in V2
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Surface a hard failure only when there's no (stale) data to show; otherwise
|
||||
// keep the last-good chart and let the header indicate the refresh.
|
||||
// react-query keeps the previous response during background refetches, so
|
||||
// `data.response` presence is the "have something to show" signal.
|
||||
const hasData = !!data.response;
|
||||
|
||||
if (error && !hasData) {
|
||||
return (
|
||||
<div className={styles.error} data-testid="panel-error">
|
||||
<TriangleAlert size={20} className={styles.errorIcon} />
|
||||
<Typography.Text className={styles.errorMessage}>
|
||||
{error.message || 'Failed to load panel data'}
|
||||
</Typography.Text>
|
||||
<Button variant="outlined" color="secondary" onClick={refetch}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// First load only — background refetches keep the response populated so the
|
||||
// chart stays mounted instead of blinking.
|
||||
if (isLoading && !hasData) {
|
||||
return (
|
||||
<div className={styles.body} data-testid="panel-loading">
|
||||
<Spin indicator={<Loader size={14} className="animate-spin" />} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.chartBody}>
|
||||
<panelDef.Renderer
|
||||
panelId={panelId}
|
||||
panel={panel}
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
onDragSelect={onDragSelect}
|
||||
panelMode={PanelMode.DASHBOARD_VIEW}
|
||||
enableDrillDown={false}
|
||||
dashboardPreference={dashboardPreference}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelBody;
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useMemo, type ReactNode } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { Querybuildertypesv5QueryWarnDataDTO as WarningDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Loader } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
|
||||
import type { PanelActionsConfig } from './Panel';
|
||||
import PanelActionsMenu from './PanelActionsMenu/PanelActionsMenu';
|
||||
import PanelStatusPopover from './PanelStatus/PanelStatusPopover';
|
||||
import {
|
||||
panelStatusFromError,
|
||||
panelStatusFromWarning,
|
||||
} from './PanelStatus/utils';
|
||||
import styles from './Panel.module.scss';
|
||||
|
||||
interface PanelHeaderProps {
|
||||
title: ReactNode;
|
||||
panelId: string;
|
||||
/** Full plugin kind — drives kind-gated menu actions; undefined when absent. */
|
||||
panelKind: string | undefined;
|
||||
/** Background refresh in flight — shows a subtle spinner without blinking the chart. */
|
||||
isFetching: boolean;
|
||||
/** Latest query error, if any — surfaced as a header error indicator. */
|
||||
error?: Error | null;
|
||||
/** Non-fatal query warning lifted from the response payload. */
|
||||
warning?: WarningDTO;
|
||||
/** Layout context for move/delete — absent outside editable sectioned mode. */
|
||||
panelActions?: PanelActionsConfig;
|
||||
}
|
||||
|
||||
/** Panel chrome: drag handle, title, refetch + status indicators, actions. */
|
||||
function PanelHeader({
|
||||
title,
|
||||
panelId,
|
||||
panelKind,
|
||||
isFetching,
|
||||
error,
|
||||
warning,
|
||||
panelActions,
|
||||
}: PanelHeaderProps): JSX.Element {
|
||||
const errorDetail = useMemo(
|
||||
() => panelStatusFromError(error ?? null),
|
||||
[error],
|
||||
);
|
||||
|
||||
const warningDetail = useMemo(
|
||||
() => panelStatusFromWarning(warning),
|
||||
[warning],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cx(styles.header, 'panel-drag-handle')}>
|
||||
<div className={styles.headerLeft}>
|
||||
<Typography.Text className={styles.headerTitle}>{title}</Typography.Text>
|
||||
{isFetching && (
|
||||
<Loader
|
||||
size={12}
|
||||
className={cx('animate-spin', styles.refetchIndicator)}
|
||||
data-testid="panel-refetching"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* `panel-no-drag` opts this region out of the grid drag handle so the
|
||||
actions menu is clickable instead of starting a panel drag. */}
|
||||
<div className={cx('panel-no-drag', styles.actions)}>
|
||||
{errorDetail && <PanelStatusPopover variant="error" detail={errorDetail} />}
|
||||
{warningDetail && (
|
||||
<PanelStatusPopover variant="warning" detail={warningDetail} />
|
||||
)}
|
||||
{/* Renders nothing when no action survives its gates (kind/role/context). */}
|
||||
<PanelActionsMenu
|
||||
panelId={panelId}
|
||||
panelKind={panelKind}
|
||||
panelActions={panelActions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelHeader;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { BookOpenText } from '@signozhq/icons';
|
||||
|
||||
import type { PanelStatusDetail } from './types';
|
||||
import styles from './PanelStatusPopover.module.scss';
|
||||
|
||||
interface PanelStatusContentProps {
|
||||
detail: PanelStatusDetail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Popover body for a panel status (error or warning): a code + summary header
|
||||
* with an optional docs link, followed by any per-item messages. Pure
|
||||
* presentation — the variant's icon/colour is owned by `PanelStatusPopover`.
|
||||
*/
|
||||
function PanelStatusContent({ detail }: PanelStatusContentProps): JSX.Element {
|
||||
const { code, message, docsUrl, messages } = detail;
|
||||
|
||||
return (
|
||||
<section className={styles.content} data-testid="panel-status-content">
|
||||
<header className={styles.summary}>
|
||||
<div className={styles.summaryText}>
|
||||
{code && <h2 className={styles.code}>{code}</h2>}
|
||||
<p className={styles.message}>{message}</p>
|
||||
</div>
|
||||
{docsUrl && (
|
||||
<a
|
||||
className={styles.docsLink}
|
||||
href={docsUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
data-testid="panel-status-docs"
|
||||
>
|
||||
<BookOpenText size={14} />
|
||||
Open Docs
|
||||
</a>
|
||||
)}
|
||||
</header>
|
||||
{messages.length > 0 && (
|
||||
<ul className={styles.messageList}>
|
||||
{messages.map((m) => (
|
||||
<li key={m} className={styles.messageItem}>
|
||||
{m}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelStatusContent;
|
||||
@@ -0,0 +1,64 @@
|
||||
.trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 600px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.summaryText {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.code {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.message {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--l1-foreground);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.docsLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.messageList {
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.messageItem {
|
||||
font-size: 12px;
|
||||
color: var(--l1-foreground);
|
||||
word-break: break-word;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { CircleX, TriangleAlert } from '@signozhq/icons';
|
||||
import { Popover } from 'antd';
|
||||
|
||||
import PanelStatusContent from './PanelStatusContent';
|
||||
import type { PanelStatusDetail, PanelStatusVariant } from './types';
|
||||
import styles from './PanelStatusPopover.module.scss';
|
||||
|
||||
const VARIANT_CONFIG: Record<
|
||||
PanelStatusVariant,
|
||||
{ color: string; ariaLabel: string }
|
||||
> = {
|
||||
error: { color: Color.BG_CHERRY_500, ariaLabel: 'Panel error' },
|
||||
warning: { color: Color.BG_AMBER_500, ariaLabel: 'Panel warning' },
|
||||
};
|
||||
|
||||
interface PanelStatusPopoverProps {
|
||||
variant: PanelStatusVariant;
|
||||
detail: PanelStatusDetail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Header status indicator: a variant-coloured icon (error → CircleX,
|
||||
* warning → TriangleAlert) that opens a popover with the status detail. One
|
||||
* component drives both variants so error and warning surfacing stay in lockstep.
|
||||
*/
|
||||
function PanelStatusPopover({
|
||||
variant,
|
||||
detail,
|
||||
}: PanelStatusPopoverProps): JSX.Element {
|
||||
const { color, ariaLabel } = VARIANT_CONFIG[variant];
|
||||
const Icon = variant === 'error' ? CircleX : TriangleAlert;
|
||||
|
||||
const content = useMemo(
|
||||
() => <PanelStatusContent detail={detail} />,
|
||||
[detail],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
content={content}
|
||||
overlayInnerStyle={{ padding: 0 }}
|
||||
autoAdjustOverflow
|
||||
>
|
||||
{/* Wrapping span gives antd a ref-able, hoverable trigger (icon
|
||||
components don't forward refs) and a stable testid anchor. */}
|
||||
<span
|
||||
className={styles.trigger}
|
||||
aria-label={ariaLabel}
|
||||
data-testid={`panel-status-${variant}`}
|
||||
>
|
||||
<Icon size={16} color={color} />
|
||||
</span>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelStatusPopover;
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Querybuildertypesv5QueryWarnDataDTO as WarningDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import APIError from 'types/api/error';
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
|
||||
import { panelStatusFromError, panelStatusFromWarning } from '../utils';
|
||||
|
||||
describe('panelStatusFromError', () => {
|
||||
it('returns null when there is no error', () => {
|
||||
expect(panelStatusFromError(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('maps a structured APIError to code/message/docs/sub-messages', () => {
|
||||
const apiError = new APIError({
|
||||
httpStatusCode: StatusCodes.BAD_REQUEST,
|
||||
error: {
|
||||
code: 'invalid_query',
|
||||
message: 'Query is invalid',
|
||||
url: 'https://docs/err',
|
||||
errors: [{ message: 'missing aggregation' }, { message: 'bad filter' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(panelStatusFromError(apiError)).toStrictEqual({
|
||||
code: 'invalid_query',
|
||||
message: 'Query is invalid',
|
||||
docsUrl: 'https://docs/err',
|
||||
messages: ['missing aggregation', 'bad filter'],
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to a generic 500 for a plain Error', () => {
|
||||
expect(panelStatusFromError(new Error('boom'))).toStrictEqual({
|
||||
code: '500',
|
||||
message: 'boom',
|
||||
messages: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('omits docsUrl when the API error has no url', () => {
|
||||
const apiError = new APIError({
|
||||
httpStatusCode: StatusCodes.INTERNAL_SERVER_ERROR,
|
||||
error: { code: 'x', message: 'y', url: '', errors: [] },
|
||||
});
|
||||
|
||||
expect(panelStatusFromError(apiError)?.docsUrl).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('panelStatusFromWarning', () => {
|
||||
it('returns null when there is no warning', () => {
|
||||
expect(panelStatusFromWarning(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('maps a warning to the normalized status shape (no code — V5 warnings carry none)', () => {
|
||||
const warning: WarningDTO = {
|
||||
message: 'Some series were dropped',
|
||||
url: 'https://docs/warn',
|
||||
warnings: [{ message: 'series A truncated' }],
|
||||
};
|
||||
|
||||
expect(panelStatusFromWarning(warning)).toStrictEqual({
|
||||
message: 'Some series were dropped',
|
||||
docsUrl: 'https://docs/warn',
|
||||
messages: ['series A truncated'],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
/** Which kind of non-fatal panel status is being surfaced in the header. */
|
||||
export type PanelStatusVariant = 'error' | 'warning';
|
||||
|
||||
/**
|
||||
* Normalized status shape that both an API error and a query warning adapt into,
|
||||
* so a single popover can render either. Mirrors the fields the backend supplies
|
||||
* on its `ErrorV2` / `Warning` envelopes (code + summary + optional docs link +
|
||||
* per-item messages).
|
||||
*/
|
||||
export interface PanelStatusDetail {
|
||||
/** Short status code (e.g. an error/warning code) shown as the heading. Only present in error cases. */
|
||||
code?: string;
|
||||
/** Human-readable summary line. */
|
||||
message: string;
|
||||
/** Optional docs link; renders an "Open Docs" action when present. */
|
||||
docsUrl?: string;
|
||||
/** Additional per-item messages listed under the summary. */
|
||||
messages: string[];
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type APIError from 'types/api/error';
|
||||
import type { Querybuildertypesv5QueryWarnDataDTO as WarningDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { PanelStatusDetail } from './types';
|
||||
|
||||
const FALLBACK_CODE = '500';
|
||||
const FALLBACK_MESSAGE = 'Something went wrong';
|
||||
|
||||
/**
|
||||
* Narrows a thrown `Error` to our `APIError` (which carries the structured
|
||||
* `error.error` envelope). react-query types failures as `Error`, so a runtime
|
||||
* guard is the typed way to recover the richer shape.
|
||||
*/
|
||||
function isAPIError(error: Error): error is APIError {
|
||||
return (
|
||||
'error' in error &&
|
||||
typeof (error as APIError).error === 'object' &&
|
||||
(error as APIError).error !== null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapts a query failure into the normalized status shape. Structured
|
||||
* `APIError`s yield their backend code/message/docs/sub-messages; any other
|
||||
* `Error` falls back to a generic 500 with its message.
|
||||
*/
|
||||
export function panelStatusFromError(
|
||||
error: Error | null,
|
||||
): PanelStatusDetail | null {
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isAPIError(error)) {
|
||||
const detail = error.error.error;
|
||||
return {
|
||||
code: detail?.code || FALLBACK_CODE,
|
||||
message: detail?.message || FALLBACK_MESSAGE,
|
||||
docsUrl: detail?.url || undefined,
|
||||
messages: (detail?.errors ?? []).map((e) => e.message),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: FALLBACK_CODE,
|
||||
message: error.message || FALLBACK_MESSAGE,
|
||||
messages: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Adapts a query warning into the normalized status shape. */
|
||||
export function panelStatusFromWarning(
|
||||
warning: WarningDTO | undefined,
|
||||
): PanelStatusDetail | null {
|
||||
if (!warning) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
message: warning.message || 'Warning',
|
||||
docsUrl: warning.url || undefined,
|
||||
messages: (warning.warnings ?? [])
|
||||
.map((w) => w.message)
|
||||
.filter((message): message is string => Boolean(message)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { Warning } from 'types/api';
|
||||
|
||||
import PanelHeader from '../PanelHeader';
|
||||
|
||||
// The actions menu has its own gating logic (kind/role/context) and its own
|
||||
// tests; stub it so this test exercises only the header's status indicators.
|
||||
jest.mock(
|
||||
'../PanelActionsMenu/PanelActionsMenu',
|
||||
() =>
|
||||
function MockPanelActionsMenu(): null {
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
const baseProps = {
|
||||
title: 'My panel',
|
||||
panelKind: 'signoz/TimeSeriesPanel',
|
||||
panelId: 'panel-1',
|
||||
isFetching: false,
|
||||
};
|
||||
|
||||
const warning: Warning = {
|
||||
code: 'partial_data',
|
||||
message: 'Some series were dropped',
|
||||
url: '',
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
describe('PanelHeader status indicators', () => {
|
||||
it('shows the error indicator whenever an error is present', () => {
|
||||
render(<PanelHeader {...baseProps} error={new Error('boom')} />);
|
||||
expect(screen.getByTestId('panel-status-error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the warning indicator whenever a warning is present', () => {
|
||||
render(<PanelHeader {...baseProps} warning={warning} />);
|
||||
expect(screen.getByTestId('panel-status-warning')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no status indicators when there is no error or warning', () => {
|
||||
render(<PanelHeader {...baseProps} />);
|
||||
expect(screen.queryByTestId('panel-status-error')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('panel-status-warning')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports -- TODO: migrate global time dispatch off redux
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
|
||||
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
|
||||
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
|
||||
export interface PanelInteractions {
|
||||
/**
|
||||
* Drag-select a time range on a chart → write the window to the URL + global
|
||||
* time so every panel re-fetches against the same range.
|
||||
*/
|
||||
onDragSelect: (start: number, end: number) => void;
|
||||
/**
|
||||
* Dashboard-wide rendering preferences (cursor sync, tooltip filter) keyed
|
||||
* off the dashboard id from the route.
|
||||
*/
|
||||
dashboardPreference: DashboardPreference;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encapsulates the cross-panel interactions shared by every dashboard-view
|
||||
* panel: drag-to-zoom time selection and the cursor-sync / tooltip-filter
|
||||
* preferences. Keeping this out of the `Panel` component keeps the component a
|
||||
* thin render orchestrator and lets the wiring be unit-tested in isolation.
|
||||
*/
|
||||
export function usePanelInteractions(): PanelInteractions {
|
||||
const dispatch = useDispatch();
|
||||
const { pathname } = useLocation();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const urlQuery = useUrlQuery();
|
||||
const { dashboardId } = useParams<{ dashboardId: string }>();
|
||||
|
||||
const [syncMode] = useDashboardCursorSyncMode(
|
||||
dashboardId,
|
||||
PanelMode.DASHBOARD_VIEW,
|
||||
);
|
||||
const [syncFilterMode] = useSyncTooltipFilterMode(dashboardId);
|
||||
|
||||
const dashboardPreference = useMemo<DashboardPreference>(
|
||||
() => ({ syncMode, syncFilterMode, dashboardId }),
|
||||
[syncMode, syncFilterMode, dashboardId],
|
||||
);
|
||||
|
||||
const onDragSelect = useCallback(
|
||||
(start: number, end: number): void => {
|
||||
const startTimestamp = Math.trunc(start);
|
||||
const endTimestamp = Math.trunc(end);
|
||||
|
||||
urlQuery.set(QueryParams.startTime, startTimestamp.toString());
|
||||
urlQuery.set(QueryParams.endTime, endTimestamp.toString());
|
||||
safeNavigate(`${pathname}?${urlQuery.toString()}`);
|
||||
|
||||
if (startTimestamp !== endTimestamp) {
|
||||
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
|
||||
}
|
||||
},
|
||||
[dispatch, pathname, safeNavigate, urlQuery],
|
||||
);
|
||||
|
||||
return { onDragSelect, dashboardPreference };
|
||||
}
|
||||
@@ -8,8 +8,6 @@ import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/pan
|
||||
import ConfirmDeleteDialog from '../../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
import type { AddPanelArgs } from '../../Panel/hooks/useAddPanelToSection';
|
||||
import type { DeletePanelArgs } from '../../Panel/hooks/useDeletePanel';
|
||||
import type { MovePanelArgs } from '../../Panel/hooks/useMovePanelToSection';
|
||||
import PanelTypeSelectionModal from '../../Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import { useDeleteSection } from '../hooks/useDeleteSection';
|
||||
@@ -26,10 +24,8 @@ interface SectionProps {
|
||||
section: DashboardSection;
|
||||
/** Adds a panel to this section; present only in editable sectioned mode. */
|
||||
onAddPanel?: (args: AddPanelArgs) => void;
|
||||
/** All sections + per-panel handlers, for the panel "Move to section" / delete actions. */
|
||||
/** All sections — layout context for the panel menu's move/delete actions. */
|
||||
sections?: DashboardSection[];
|
||||
onMovePanel?: (args: MovePanelArgs) => void;
|
||||
onDeletePanel?: (args: DeletePanelArgs) => void;
|
||||
/** Provided by SortableSection in sectioned mode; absent for untitled/free-flow. */
|
||||
dragHandle?: SectionDragHandle;
|
||||
}
|
||||
@@ -38,8 +34,6 @@ function Section({
|
||||
section,
|
||||
onAddPanel,
|
||||
sections,
|
||||
onMovePanel,
|
||||
onDeletePanel,
|
||||
dragHandle,
|
||||
}: SectionProps): JSX.Element {
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
@@ -92,8 +86,6 @@ function Section({
|
||||
layoutIndex={section.layoutIndex}
|
||||
isVisible={isVisible}
|
||||
sections={sections}
|
||||
onMovePanel={onMovePanel}
|
||||
onDeletePanel={onDeletePanel}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -9,4 +9,11 @@
|
||||
z-index: 2;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
:global(.react-resizable-handle) {
|
||||
&::after {
|
||||
border-right-color: var(--l2-border) !important;
|
||||
border-bottom-color: var(--l2-border) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@ import { useMemo } from 'react';
|
||||
import GridLayout, { WidthProvider, type Layout } from 'react-grid-layout';
|
||||
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
import type { DeletePanelArgs } from '../../Panel/hooks/useDeletePanel';
|
||||
import type { MovePanelArgs } from '../../Panel/hooks/useMovePanelToSection';
|
||||
import Panel from '../../Panel/Panel';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import { usePersistLayout } from '../hooks/usePersistLayout';
|
||||
@@ -16,10 +14,8 @@ interface SectionGridProps {
|
||||
layoutIndex: number;
|
||||
/** Forwarded to panels — true when the parent section is in the viewport. */
|
||||
isVisible?: boolean;
|
||||
/** All sections + handlers — present only in editable sectioned mode (panel "Move to section" / delete). */
|
||||
/** All sections — layout context for the panel menu's move/delete actions. */
|
||||
sections?: DashboardSection[];
|
||||
onMovePanel?: (args: MovePanelArgs) => void;
|
||||
onDeletePanel?: (args: DeletePanelArgs) => void;
|
||||
}
|
||||
|
||||
function SectionGrid({
|
||||
@@ -27,8 +23,6 @@ function SectionGrid({
|
||||
layoutIndex,
|
||||
isVisible,
|
||||
sections,
|
||||
onMovePanel,
|
||||
onDeletePanel,
|
||||
}: SectionGridProps): JSX.Element {
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const rglLayout = useMemo<Layout[]>(
|
||||
@@ -54,6 +48,7 @@ function SectionGrid({
|
||||
useCSSTransforms
|
||||
layout={rglLayout}
|
||||
draggableHandle=".panel-drag-handle"
|
||||
draggableCancel=".panel-no-drag"
|
||||
isDraggable={isEditable}
|
||||
isResizable={isEditable}
|
||||
onDragStop={handleLayoutChange}
|
||||
@@ -67,12 +62,10 @@ function SectionGrid({
|
||||
panelId={item.id}
|
||||
isVisible={isVisible}
|
||||
panelActions={
|
||||
isEditable && onMovePanel && onDeletePanel
|
||||
isEditable
|
||||
? {
|
||||
currentLayoutIndex: layoutIndex,
|
||||
sections: sections ?? [],
|
||||
onMovePanel,
|
||||
onDeletePanel,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -12,8 +12,6 @@ import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.sche
|
||||
|
||||
import type { DashboardSection } from '../../utils';
|
||||
import { useAddPanelToSection } from '../Panel/hooks/useAddPanelToSection';
|
||||
import { useDeletePanel } from '../Panel/hooks/useDeletePanel';
|
||||
import { useMovePanelToSection } from '../Panel/hooks/useMovePanelToSection';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { useSectionDragReorder } from './hooks/useSectionDragReorder';
|
||||
import Section from './Section/Section';
|
||||
@@ -38,8 +36,6 @@ function SectionList({ sections, layouts }: SectionListProps): JSX.Element {
|
||||
} = useSectionDragReorder({ sections, layouts });
|
||||
|
||||
const onAddPanel = useAddPanelToSection({ sections });
|
||||
const onMovePanel = useMovePanelToSection({ sections });
|
||||
const onDeletePanel = useDeletePanel({ sections });
|
||||
|
||||
// Only titled sections participate in reordering; untitled (free-flow)
|
||||
// blocks render in place without a drag handle.
|
||||
@@ -75,8 +71,6 @@ function SectionList({ sections, layouts }: SectionListProps): JSX.Element {
|
||||
section={section}
|
||||
sections={sections}
|
||||
onAddPanel={onAddPanel}
|
||||
onMovePanel={onMovePanel}
|
||||
onDeletePanel={onDeletePanel}
|
||||
/>
|
||||
) : (
|
||||
<Section
|
||||
@@ -84,8 +78,6 @@ function SectionList({ sections, layouts }: SectionListProps): JSX.Element {
|
||||
section={section}
|
||||
sections={sections}
|
||||
onAddPanel={onAddPanel}
|
||||
onMovePanel={onMovePanel}
|
||||
onDeletePanel={onDeletePanel}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
@@ -3,24 +3,18 @@ import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
import type { DashboardSection } from '../../utils';
|
||||
import type { AddPanelArgs } from '../Panel/hooks/useAddPanelToSection';
|
||||
import type { DeletePanelArgs } from '../Panel/hooks/useDeletePanel';
|
||||
import type { MovePanelArgs } from '../Panel/hooks/useMovePanelToSection';
|
||||
import Section from './Section/Section';
|
||||
|
||||
interface SortableSectionProps {
|
||||
section: DashboardSection;
|
||||
sections: DashboardSection[];
|
||||
onAddPanel: (args: AddPanelArgs) => void;
|
||||
onMovePanel: (args: MovePanelArgs) => void;
|
||||
onDeletePanel: (args: DeletePanelArgs) => void;
|
||||
}
|
||||
|
||||
function SortableSection({
|
||||
section,
|
||||
sections,
|
||||
onAddPanel,
|
||||
onMovePanel,
|
||||
onDeletePanel,
|
||||
}: SortableSectionProps): JSX.Element {
|
||||
const {
|
||||
attributes,
|
||||
@@ -48,8 +42,6 @@ function SortableSection({
|
||||
section={section}
|
||||
sections={sections}
|
||||
onAddPanel={onAddPanel}
|
||||
onMovePanel={onMovePanel}
|
||||
onDeletePanel={onDeletePanel}
|
||||
dragHandle={{ attributes, listeners, setActivatorNodeRef }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -48,8 +48,11 @@ function PanelsAndSectionsLayout({
|
||||
return <SectionList sections={sections} layouts={layouts} />;
|
||||
}
|
||||
|
||||
// Free-flow (no titled sections): panels still get the layout context so
|
||||
// the menu's delete action can patch the section's items (previously a
|
||||
// silent noop in this mode).
|
||||
return sections.map((section) => (
|
||||
<Section key={section.id} section={section} />
|
||||
<Section key={section.id} section={section} sections={sections} />
|
||||
));
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { usePanelQuery } from '../usePanelQuery';
|
||||
import { useGetQueryRangeV5 } from '../useGetQueryRangeV5';
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
useSelector: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../useGetQueryRangeV5', () => ({
|
||||
useGetQueryRangeV5: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUseSelector = useSelector as unknown as jest.Mock;
|
||||
const mockUseGetQueryRangeV5 = useGetQueryRangeV5 as unknown as jest.Mock;
|
||||
|
||||
// ---- helpers ---------------------------------------------------------------
|
||||
|
||||
// Test fixtures are cast at the outer boundary; the perses-generated panel and
|
||||
// query plugin unions are too verbose to construct field-typed inline.
|
||||
|
||||
function panelWith(
|
||||
panelKind: string,
|
||||
querySpec: Record<string, unknown>,
|
||||
): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
plugin: { kind: panelKind, spec: {} },
|
||||
queries: [
|
||||
{
|
||||
kind: 'TimeSeriesQuery',
|
||||
spec: {
|
||||
plugin: { kind: 'signoz/BuilderQuery', spec: querySpec },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
function builderPanel(): DashboardtypesPanelDTO {
|
||||
return panelWith('signoz/TimeSeriesPanel', {
|
||||
name: 'A',
|
||||
signal: 'logs',
|
||||
filter: { expression: '' },
|
||||
});
|
||||
}
|
||||
|
||||
function emptyPanel(): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
|
||||
queries: [],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
// Nanoseconds, as redux globalTime stores them. 1e15 ns = 1e9 ms.
|
||||
const DEFAULT_GLOBAL_TIME = {
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
minTime: 1_000_000_000_000_000,
|
||||
maxTime: 2_000_000_000_000_000,
|
||||
isAutoRefreshDisabled: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseSelector.mockImplementation((selector: unknown) => {
|
||||
// usePanelQuery passes a selector `(state) => state.globalTime`.
|
||||
return (
|
||||
selector as (state: { globalTime: typeof DEFAULT_GLOBAL_TIME }) => unknown
|
||||
)({ globalTime: DEFAULT_GLOBAL_TIME });
|
||||
});
|
||||
mockUseGetQueryRangeV5.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
// ---- tests -----------------------------------------------------------------
|
||||
|
||||
describe('usePanelQuery', () => {
|
||||
it('builds the generated V5 request DTO directly from panel.spec.queries', () => {
|
||||
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
|
||||
const [{ requestPayload }] = mockUseGetQueryRangeV5.mock.calls[0];
|
||||
expect(requestPayload.schemaVersion).toBe('v1');
|
||||
expect(requestPayload.compositeQuery.queries).toStrictEqual([
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: { name: 'A', signal: 'logs', filter: { expression: '' } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('converts redux nanosecond time to epoch ms on the request', () => {
|
||||
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
|
||||
const [{ requestPayload }] = mockUseGetQueryRangeV5.mock.calls[0];
|
||||
expect(requestPayload.start).toBe(1_000_000_000);
|
||||
expect(requestPayload.end).toBe(2_000_000_000);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['signoz/TimeSeriesPanel', 'time_series'],
|
||||
['signoz/ListPanel', 'raw'],
|
||||
// HISTOGRAM and BAR panels bin/derive from raw time-series data
|
||||
// client-side, so the backend must receive `time_series` (V1 parity).
|
||||
['signoz/HistogramPanel', 'time_series'],
|
||||
['signoz/BarChartPanel', 'time_series'],
|
||||
['signoz/NumberPanel', 'scalar'],
|
||||
['signoz/PieChartPanel', 'scalar'],
|
||||
])('%s panel sends requestType=%s', (panelKind, requestType) => {
|
||||
renderHook(() =>
|
||||
usePanelQuery({
|
||||
panel: panelWith(panelKind, { name: 'A', signal: 'logs' }),
|
||||
panelId: 'p1',
|
||||
}),
|
||||
);
|
||||
const [{ requestPayload }] = mockUseGetQueryRangeV5.mock.calls[0];
|
||||
expect(requestPayload.requestType).toBe(requestType);
|
||||
});
|
||||
|
||||
it('exposes the raw V5 response, request payload, and legend map on data', () => {
|
||||
const v5Response = { status: 'success', data: { type: 'time_series' } };
|
||||
mockUseGetQueryRangeV5.mockReturnValue({
|
||||
data: v5Response,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
|
||||
);
|
||||
|
||||
expect(result.current.data.response).toBe(v5Response);
|
||||
expect(result.current.data.legendMap).toStrictEqual({ A: '' });
|
||||
expect(result.current.data.requestPayload?.schemaVersion).toBe('v1');
|
||||
});
|
||||
|
||||
it('exposes an undefined response before data arrives', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
|
||||
);
|
||||
expect(result.current.data.response).toBeUndefined();
|
||||
});
|
||||
|
||||
it('exposes error from the fetch hook', () => {
|
||||
mockUseGetQueryRangeV5.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: new Error('boom'),
|
||||
});
|
||||
const { result } = renderHook(() =>
|
||||
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
|
||||
);
|
||||
expect(result.current.error?.message).toBe('boom');
|
||||
});
|
||||
|
||||
it('combines isLoading and isFetching into a single isLoading flag', () => {
|
||||
mockUseGetQueryRangeV5.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isFetching: true,
|
||||
error: null,
|
||||
});
|
||||
const { result } = renderHook(() =>
|
||||
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
|
||||
);
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
});
|
||||
|
||||
it('coerces a missing/undefined error to null', () => {
|
||||
mockUseGetQueryRangeV5.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: undefined,
|
||||
});
|
||||
const { result } = renderHook(() =>
|
||||
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
|
||||
);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it('passes enabled=false to the fetch hook when the caller disables it', () => {
|
||||
renderHook(() =>
|
||||
usePanelQuery({ panel: builderPanel(), panelId: 'p1', enabled: false }),
|
||||
);
|
||||
const [{ enabled }] = mockUseGetQueryRangeV5.mock.calls[0];
|
||||
expect(enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('auto-disables the fetch when the panel has no queries (even with enabled=true)', () => {
|
||||
renderHook(() =>
|
||||
usePanelQuery({ panel: emptyPanel(), panelId: 'p1', enabled: true }),
|
||||
);
|
||||
const [{ enabled }] = mockUseGetQueryRangeV5.mock.calls[0];
|
||||
expect(enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('auto-disables the fetch when every metrics query is missing a metric name', () => {
|
||||
renderHook(() =>
|
||||
usePanelQuery({
|
||||
panel: panelWith('signoz/TimeSeriesPanel', {
|
||||
name: 'A',
|
||||
signal: 'metrics',
|
||||
aggregations: [{}],
|
||||
}),
|
||||
panelId: 'p1',
|
||||
}),
|
||||
);
|
||||
const [{ enabled }] = mockUseGetQueryRangeV5.mock.calls[0];
|
||||
expect(enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('composes a react-query cache key that includes panelId, time range, kind, and queries', () => {
|
||||
const panel = builderPanel();
|
||||
renderHook(() => usePanelQuery({ panel, panelId: 'p1' }));
|
||||
const [{ queryKey }] = mockUseGetQueryRangeV5.mock.calls[0];
|
||||
expect(queryKey).toStrictEqual(
|
||||
expect.arrayContaining([
|
||||
'p1',
|
||||
DEFAULT_GLOBAL_TIME.minTime,
|
||||
DEFAULT_GLOBAL_TIME.maxTime,
|
||||
DEFAULT_GLOBAL_TIME.selectedTime,
|
||||
'signoz/TimeSeriesPanel',
|
||||
panel.spec?.queries,
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the time override (not redux) for the request window and cache key', () => {
|
||||
const panel = builderPanel();
|
||||
renderHook(() =>
|
||||
usePanelQuery({
|
||||
panel,
|
||||
panelId: 'p1',
|
||||
time: { startMs: 1_700_000_000_000, endMs: 1_700_000_600_000 },
|
||||
}),
|
||||
);
|
||||
const [{ requestPayload, queryKey }] = mockUseGetQueryRangeV5.mock.calls[0];
|
||||
// Window comes from the override, not the redux nanosecond time.
|
||||
expect(requestPayload.start).toBe(1_700_000_000_000);
|
||||
expect(requestPayload.end).toBe(1_700_000_600_000);
|
||||
// Cache key keys off the override so the preview refetches independently
|
||||
// of the dashboard and never collides with its redux-keyed entry.
|
||||
expect(queryKey).toStrictEqual(
|
||||
expect.arrayContaining([
|
||||
'p1',
|
||||
'override-1700000000000-1700000600000',
|
||||
'signoz/TimeSeriesPanel',
|
||||
panel.spec?.queries,
|
||||
]),
|
||||
);
|
||||
expect(queryKey).not.toContain(DEFAULT_GLOBAL_TIME.minTime);
|
||||
});
|
||||
|
||||
it('floors fractional override ms — V1 time helpers emit floats but start/end are int64', () => {
|
||||
renderHook(() =>
|
||||
usePanelQuery({
|
||||
panel: builderPanel(),
|
||||
panelId: 'p1',
|
||||
time: { startMs: 1_700_000_000_000.546, endMs: 1_700_000_600_000.999 },
|
||||
}),
|
||||
);
|
||||
const [{ requestPayload, queryKey }] = mockUseGetQueryRangeV5.mock.calls[0];
|
||||
expect(requestPayload.start).toBe(1_700_000_000_000);
|
||||
expect(requestPayload.end).toBe(1_700_000_600_000);
|
||||
// The cache key carries the floored values so it matches the request.
|
||||
expect(queryKey).toStrictEqual(
|
||||
expect.arrayContaining(['override-1700000000000-1700000600000']),
|
||||
);
|
||||
});
|
||||
|
||||
it('builds an empty composite and disables the fetch when panel is undefined (no crash)', () => {
|
||||
renderHook(() => usePanelQuery({ panel: undefined, panelId: 'p-none' }));
|
||||
const [{ requestPayload, enabled }] = mockUseGetQueryRangeV5.mock.calls[0];
|
||||
expect(requestPayload.compositeQuery.queries).toStrictEqual([]);
|
||||
expect(enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useQuery, UseQueryResult } from 'react-query';
|
||||
import { isAxiosError } from 'axios';
|
||||
import { queryRangeV5 } from 'api/generated/services/querier';
|
||||
import type {
|
||||
Querybuildertypesv5QueryRangeRequestDTO,
|
||||
QueryRangeV5200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { MAX_QUERY_RETRIES } from 'constants/reactQuery';
|
||||
|
||||
export interface UseGetQueryRangeV5Args {
|
||||
requestPayload: Querybuildertypesv5QueryRangeRequestDTO | undefined;
|
||||
queryKey: unknown[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// 4xx responses are deterministic (bad query, auth) — retrying re-sends a
|
||||
// request that will fail identically. Same policy as V1's useGetQueryRange.
|
||||
function retryUnlessClientError(failureCount: number, error: Error): boolean {
|
||||
if (isAxiosError(error)) {
|
||||
if (error.code === 'ERR_CANCELED') {
|
||||
return false;
|
||||
}
|
||||
const status = error.response?.status;
|
||||
if (status && status >= 400 && status < 500) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return failureCount < MAX_QUERY_RETRIES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure-V5 query-range fetch: posts the generated request DTO via the
|
||||
* generated `queryRangeV5` call and returns the raw generated response —
|
||||
* no V1 `Query` shape on either leg. Wrapped in `useQuery` (not the
|
||||
* generated `useQueryRangeV5` mutation hook) because panel fetches need
|
||||
* caching, `enabled` gating, and refetch semantics.
|
||||
*/
|
||||
export function useGetQueryRangeV5({
|
||||
requestPayload,
|
||||
queryKey,
|
||||
enabled,
|
||||
}: UseGetQueryRangeV5Args): UseQueryResult<QueryRangeV5200, Error> {
|
||||
return useQuery<QueryRangeV5200, Error>({
|
||||
queryKey,
|
||||
queryFn: ({ signal }) => queryRangeV5(requestPayload, signal),
|
||||
enabled: enabled && !!requestPayload,
|
||||
retry: retryUnlessClientError,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
|
||||
/**
|
||||
* Returns a callback that opens the V2 panel editor overlay for a panel by
|
||||
* setting the `editPanelId` query param on the current dashboard URL.
|
||||
* DashboardContainer reads the param and renders the editor — the dashboard
|
||||
* stays mounted underneath. URL-driven so any entry point (panel actions menu,
|
||||
* future title interactions, empty states) can open the editor without
|
||||
* threading callbacks through the layout tree.
|
||||
*/
|
||||
export function useOpenPanelEditor(): (panelId: string) => void {
|
||||
const { pathname } = useLocation();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
return useCallback(
|
||||
(panelId: string): void => {
|
||||
urlQuery.set(QueryParams.editPanelId, panelId);
|
||||
safeNavigate(`${pathname}?${urlQuery.toString()}`);
|
||||
},
|
||||
[urlQuery, safeNavigate, pathname],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useMemo } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports -- TODO: migrate global time selector off redux
|
||||
import { useSelector } from 'react-redux';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import {
|
||||
buildQueryRangeRequest,
|
||||
extractLegendMap,
|
||||
hasRunnableQueries,
|
||||
} from '../queryV5/buildQueryRangeRequest';
|
||||
import type { PanelQueryData } from '../queryV5/types';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
} from '../Panels/types/panelKind';
|
||||
import { useGetQueryRangeV5 } from './useGetQueryRangeV5';
|
||||
|
||||
export interface UsePanelQueryArgs {
|
||||
panel: DashboardtypesPanelDTO | undefined;
|
||||
panelId: string;
|
||||
/**
|
||||
* Gate the underlying fetch. Defaults to true. PanelV2 sets this false when
|
||||
* no plugin is registered for the panel's kind so the unknown-kind fallback
|
||||
* UI doesn't trigger a wasted API call.
|
||||
*
|
||||
* The hook *also* auto-disables internally when the panel has no runnable
|
||||
* queries — callers don't need to compute that themselves.
|
||||
*/
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* Override the time window instead of reading global Redux time. Used by the
|
||||
* panel editor preview to stay isolated from the dashboard — changing the
|
||||
* preview time neither touches nor re-runs the dashboard behind the overlay.
|
||||
*/
|
||||
time?: PanelQueryTimeOverride;
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor-local time window in epoch milliseconds (the V5 request native unit).
|
||||
* The caller resolves its selection — relative or custom — to an absolute
|
||||
* window so the fetch can ignore global Redux time entirely. Fractional ms are
|
||||
* floored before the request: the V1 time helpers some callers resolve through
|
||||
* (e.g. getStartEndRangeTime → getMicroSeconds) divide without truncating, and
|
||||
* the V5 start/end are int64 — a float breaks the API call.
|
||||
*/
|
||||
export interface PanelQueryTimeOverride {
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
}
|
||||
|
||||
export interface UsePanelQueryResult {
|
||||
/** Raw V5 fetch result — response + the request that produced it. */
|
||||
data: PanelQueryData;
|
||||
/** Combines `isLoading` (first fetch) and `isFetching` (background refresh). */
|
||||
isLoading: boolean;
|
||||
/** Background refresh in flight while data is already present. */
|
||||
isFetching: boolean;
|
||||
error: Error | null;
|
||||
/** Re-run the query (e.g. a retry button on the error state). */
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the query-range data for a V2 panel over the pure-V5 contract.
|
||||
*
|
||||
* 1. Request — `buildQueryRangeRequest` assembles the generated
|
||||
* `Querybuildertypesv5QueryRangeRequestDTO` directly from the panel's
|
||||
* perses queries (a CompositeQuery plugin already nests the V5
|
||||
* envelope list). No V1 `Query` intermediary.
|
||||
* 2. Time + variables — reads the global time selection from Redux
|
||||
* (variables substitution is intentionally deferred until V2 has its
|
||||
* own variable plumbing).
|
||||
* 3. Fetch — `useGetQueryRangeV5` posts via the generated `queryRangeV5`
|
||||
* call with a react-query cache key composed from panel identity +
|
||||
* time range + kind + queries.
|
||||
*
|
||||
* Renderers consume the raw V5 response through the `queryV5` prep utils
|
||||
* (`flattenTimeSeries`, `prepareScalarTables`, …).
|
||||
*
|
||||
* The hook is consumed today by PanelV2 (renderer dispatch) and will be
|
||||
* consumed by PanelEditor (1.8) for "preview as you edit."
|
||||
*/
|
||||
export function usePanelQuery({
|
||||
panel,
|
||||
panelId,
|
||||
enabled = true,
|
||||
time,
|
||||
}: UsePanelQueryArgs): UsePanelQueryResult {
|
||||
const fullKind = panel?.spec?.plugin?.kind;
|
||||
const panelType =
|
||||
(fullKind && PANEL_KIND_TO_PANEL_TYPE[fullKind as PanelKind]) ??
|
||||
PANEL_TYPES.TIME_SERIES;
|
||||
const queries = panel?.spec?.queries;
|
||||
|
||||
const {
|
||||
selectedTime: globalSelectedInterval,
|
||||
maxTime,
|
||||
minTime,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
// Redux global time is in nanoseconds; the V5 API takes epoch ms. An editor
|
||||
// time override (already in ms) wins so the preview fetch is independent of
|
||||
// the dashboard's global selection. Floor both paths: start/end are int64
|
||||
// on the wire, and override values can carry fractional ms (see
|
||||
// PanelQueryTimeOverride).
|
||||
const startMs = Math.floor(time ? time.startMs : minTime / 1e6);
|
||||
const endMs = Math.floor(time ? time.endMs : maxTime / 1e6);
|
||||
|
||||
const requestPayload = useMemo(
|
||||
() =>
|
||||
buildQueryRangeRequest({
|
||||
queries: queries ?? [],
|
||||
panelType,
|
||||
startMs,
|
||||
endMs,
|
||||
}),
|
||||
[queries, panelType, startMs, endMs],
|
||||
);
|
||||
|
||||
const legendMap = useMemo(() => extractLegendMap(queries ?? []), [queries]);
|
||||
|
||||
const runnable = useMemo(() => hasRunnableQueries(queries ?? []), [queries]);
|
||||
|
||||
const response = useGetQueryRangeV5({
|
||||
requestPayload,
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.DASHBOARD_GRID_CARD_QUERY_RANGE,
|
||||
panelId,
|
||||
// Dashboard keys off Redux min/max + interval; the editor passes an
|
||||
// explicit ms window. Keep each distinct so they refetch on their own
|
||||
// time without colliding in the react-query cache. The floored values
|
||||
// key the cache so it matches what was actually requested.
|
||||
...(time
|
||||
? [`override-${startMs}-${endMs}`]
|
||||
: [minTime, maxTime, globalSelectedInterval]),
|
||||
fullKind,
|
||||
queries,
|
||||
],
|
||||
enabled: enabled && runnable,
|
||||
});
|
||||
|
||||
const data = useMemo<PanelQueryData>(
|
||||
() => ({ response: response.data, requestPayload, legendMap }),
|
||||
[response.data, requestPayload, legendMap],
|
||||
);
|
||||
|
||||
return {
|
||||
data,
|
||||
isLoading: response.isLoading || response.isFetching,
|
||||
isFetching: response.isFetching,
|
||||
// Coerce undefined → null so the contract is `Error | null`, not
|
||||
// `Error | null | undefined`. Consumers can rely on a single
|
||||
// "no error" sentinel.
|
||||
error: (response.error as Error | null) ?? null,
|
||||
refetch: response.refetch,
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { FullScreen, useFullScreenHandle } from 'react-full-screen';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import PanelTypeSelectionModal from 'container/DashboardContainer/PanelTypeSelectionModal';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import DashboardDescription from './DashboardDescription';
|
||||
import PanelEditorContainer from './PanelEditor';
|
||||
import PanelsAndSectionsLayout from './PanelsAndSectionsLayout';
|
||||
import { useDashboardStore } from './store/useDashboardStore';
|
||||
import styles from './DashboardContainer.module.scss';
|
||||
@@ -37,6 +42,22 @@ function DashboardContainer({
|
||||
const layouts = useMemo(() => spec?.layouts ?? [], [spec?.layouts]);
|
||||
const panels = useMemo(() => spec?.panels ?? {}, [spec?.panels]);
|
||||
|
||||
// The panel editor renders as an overlay driven by the `editPanelId` query
|
||||
// param — the dashboard stays mounted underneath instead of navigating to a
|
||||
// separate page. Resolve the panel from the already-loaded dashboard so the
|
||||
// overlay needs no extra fetch.
|
||||
const { pathname } = useLocation();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const urlQuery = useUrlQuery();
|
||||
const editPanelId = urlQuery.get(QueryParams.editPanelId) ?? undefined;
|
||||
const editPanel = editPanelId ? panels[editPanelId] : undefined;
|
||||
|
||||
const closeEditor = useCallback((): void => {
|
||||
urlQuery.delete(QueryParams.editPanelId);
|
||||
const search = urlQuery.toString();
|
||||
safeNavigate(search ? `${pathname}?${search}` : pathname);
|
||||
}, [urlQuery, safeNavigate, pathname]);
|
||||
|
||||
return (
|
||||
<FullScreen handle={fullScreenHandle}>
|
||||
<div className={styles.container}>
|
||||
@@ -50,6 +71,15 @@ function DashboardContainer({
|
||||
{/* Shared panel-type picker (V1 component): opened from any "New Panel"
|
||||
trigger; navigates to the widget editor route on selection. */}
|
||||
<PanelTypeSelectionModal />
|
||||
{editPanelId && editPanel && dashboard.id && (
|
||||
<PanelEditorContainer
|
||||
dashboardId={dashboard.id}
|
||||
panelId={editPanelId}
|
||||
panel={editPanel}
|
||||
onClose={closeEditor}
|
||||
onSaved={refetch}
|
||||
/>
|
||||
)}
|
||||
</FullScreen>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
import {
|
||||
buildQueryRangeRequest,
|
||||
extractLegendMap,
|
||||
getBarStepIntervalSeconds,
|
||||
hasRunnableQueries,
|
||||
panelTypeToRequestType,
|
||||
toQueryEnvelopes,
|
||||
} from '../buildQueryRangeRequest';
|
||||
|
||||
// Test fixtures are cast at the outer boundary; the perses-generated query
|
||||
// plugin unions are too verbose to construct field-typed inline.
|
||||
|
||||
function bareBuilderQuery(
|
||||
spec: Record<string, unknown>,
|
||||
): DashboardtypesQueryDTO[] {
|
||||
return [
|
||||
{
|
||||
kind: 'TimeSeriesQuery',
|
||||
spec: { plugin: { kind: 'signoz/BuilderQuery', spec } },
|
||||
},
|
||||
] as unknown as DashboardtypesQueryDTO[];
|
||||
}
|
||||
|
||||
function compositeQuery(
|
||||
envelopes: Record<string, unknown>[],
|
||||
): DashboardtypesQueryDTO[] {
|
||||
return [
|
||||
{
|
||||
kind: 'TimeSeriesQuery',
|
||||
spec: {
|
||||
plugin: { kind: 'signoz/CompositeQuery', spec: { queries: envelopes } },
|
||||
},
|
||||
},
|
||||
] as unknown as DashboardtypesQueryDTO[];
|
||||
}
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
const START_MS = 1_700_000_000_000;
|
||||
|
||||
describe('panelTypeToRequestType', () => {
|
||||
it.each([
|
||||
[PANEL_TYPES.TIME_SERIES, 'time_series'],
|
||||
// HISTOGRAM and BAR bin client-side from time-series data; sending
|
||||
// 'distribution' would return a shape the renderers can't bin.
|
||||
[PANEL_TYPES.BAR, 'time_series'],
|
||||
[PANEL_TYPES.HISTOGRAM, 'time_series'],
|
||||
[PANEL_TYPES.TABLE, 'scalar'],
|
||||
[PANEL_TYPES.PIE, 'scalar'],
|
||||
[PANEL_TYPES.VALUE, 'scalar'],
|
||||
[PANEL_TYPES.LIST, 'raw'],
|
||||
[PANEL_TYPES.TRACE, 'trace'],
|
||||
])('%s → %s', (panelType, requestType) => {
|
||||
expect(panelTypeToRequestType(panelType)).toBe(requestType);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toQueryEnvelopes', () => {
|
||||
it('wraps a bare BuilderQuery into a single builder_query envelope', () => {
|
||||
const envelopes = toQueryEnvelopes(
|
||||
bareBuilderQuery({ name: 'A', signal: 'metrics' }),
|
||||
);
|
||||
expect(envelopes).toStrictEqual([
|
||||
{ type: 'builder_query', spec: { name: 'A', signal: 'metrics' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('passes a CompositeQuery envelope list through verbatim', () => {
|
||||
const subqueries = [
|
||||
{ type: 'builder_query', spec: { name: 'A' } },
|
||||
{ type: 'builder_formula', spec: { name: 'F1', expression: 'A*2' } },
|
||||
];
|
||||
expect(toQueryEnvelopes(compositeQuery(subqueries))).toStrictEqual(
|
||||
subqueries,
|
||||
);
|
||||
});
|
||||
|
||||
it('wraps PromQL and ClickHouse plugins with their envelope types', () => {
|
||||
const prom = [
|
||||
{
|
||||
kind: 'PromQuery',
|
||||
spec: {
|
||||
plugin: { kind: 'signoz/PromQLQuery', spec: { name: 'A', query: 'up' } },
|
||||
},
|
||||
},
|
||||
] as unknown as DashboardtypesQueryDTO[];
|
||||
expect(toQueryEnvelopes(prom)).toStrictEqual([
|
||||
{ type: 'promql', spec: { name: 'A', query: 'up' } },
|
||||
]);
|
||||
|
||||
const ch = [
|
||||
{
|
||||
kind: 'ClickHouseQuery',
|
||||
spec: {
|
||||
plugin: {
|
||||
kind: 'signoz/ClickHouseSQL',
|
||||
spec: { name: 'A', query: 'SELECT 1' },
|
||||
},
|
||||
},
|
||||
},
|
||||
] as unknown as DashboardtypesQueryDTO[];
|
||||
expect(toQueryEnvelopes(ch)).toStrictEqual([
|
||||
{ type: 'clickhouse_sql', spec: { name: 'A', query: 'SELECT 1' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops invalid top-level Formula with a warning instead of crashing', () => {
|
||||
const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||
const formula = [
|
||||
{
|
||||
kind: 'TimeSeriesQuery',
|
||||
spec: {
|
||||
plugin: { kind: 'signoz/Formula', spec: { name: 'F1', expression: 'A' } },
|
||||
},
|
||||
},
|
||||
] as unknown as DashboardtypesQueryDTO[];
|
||||
expect(toQueryEnvelopes(formula)).toStrictEqual([]);
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('returns empty for missing plugin or empty queries', () => {
|
||||
expect(toQueryEnvelopes([])).toStrictEqual([]);
|
||||
expect(
|
||||
toQueryEnvelopes([
|
||||
{ kind: 'TimeSeriesQuery', spec: {} },
|
||||
] as unknown as DashboardtypesQueryDTO[]),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildQueryRangeRequest', () => {
|
||||
it('assembles the full request DTO', () => {
|
||||
const request = buildQueryRangeRequest({
|
||||
queries: bareBuilderQuery({ name: 'A', signal: 'metrics' }),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
startMs: START_MS,
|
||||
endMs: START_MS + HOUR_MS,
|
||||
});
|
||||
expect(request).toStrictEqual({
|
||||
schemaVersion: 'v1',
|
||||
start: START_MS,
|
||||
end: START_MS + HOUR_MS,
|
||||
requestType: 'time_series',
|
||||
compositeQuery: {
|
||||
queries: [
|
||||
{ type: 'builder_query', spec: { name: 'A', signal: 'metrics' } },
|
||||
],
|
||||
},
|
||||
formatOptions: { formatTableResultForUI: false, fillGaps: false },
|
||||
variables: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('sets formatTableResultForUI only for TABLE panels', () => {
|
||||
const request = buildQueryRangeRequest({
|
||||
queries: bareBuilderQuery({ name: 'A' }),
|
||||
panelType: PANEL_TYPES.TABLE,
|
||||
startMs: START_MS,
|
||||
endMs: START_MS + HOUR_MS,
|
||||
});
|
||||
expect(request.formatOptions?.formatTableResultForUI).toBe(true);
|
||||
});
|
||||
|
||||
it('injects the range-derived stepInterval into BAR builder queries without one', () => {
|
||||
const request = buildQueryRangeRequest({
|
||||
queries: bareBuilderQuery({ name: 'A', signal: 'metrics' }),
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
startMs: START_MS,
|
||||
endMs: START_MS + HOUR_MS,
|
||||
});
|
||||
const spec = (request.compositeQuery?.queries?.[0]?.spec ?? {}) as {
|
||||
stepInterval?: number;
|
||||
};
|
||||
expect(spec.stepInterval).toBe(
|
||||
getBarStepIntervalSeconds(START_MS, START_MS + HOUR_MS),
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves a user-set stepInterval on BAR builder queries', () => {
|
||||
const request = buildQueryRangeRequest({
|
||||
queries: bareBuilderQuery({ name: 'A', stepInterval: 300 }),
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
startMs: START_MS,
|
||||
endMs: START_MS + HOUR_MS,
|
||||
});
|
||||
const spec = (request.compositeQuery?.queries?.[0]?.spec ?? {}) as {
|
||||
stepInterval?: number;
|
||||
};
|
||||
expect(spec.stepInterval).toBe(300);
|
||||
});
|
||||
|
||||
it('does not touch stepInterval for non-BAR panels', () => {
|
||||
const request = buildQueryRangeRequest({
|
||||
queries: bareBuilderQuery({ name: 'A' }),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
startMs: START_MS,
|
||||
endMs: START_MS + HOUR_MS,
|
||||
});
|
||||
const spec = (request.compositeQuery?.queries?.[0]?.spec ?? {}) as {
|
||||
stepInterval?: number;
|
||||
};
|
||||
expect(spec.stepInterval).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBarStepIntervalSeconds', () => {
|
||||
// V1 parity: getBarStepIntervalPoints in container/GridCardLayout/utils.ts
|
||||
it.each([
|
||||
[30, 60],
|
||||
[60, 60],
|
||||
[120, 120],
|
||||
[180, 120],
|
||||
[300, 180],
|
||||
])('%s min range → %s s step', (minutes, step) => {
|
||||
expect(getBarStepIntervalSeconds(0, minutes * 60 * 1000)).toBe(step);
|
||||
});
|
||||
|
||||
it('caps long ranges at ~80 bars, rounded to 5-minute steps', () => {
|
||||
// 24h = 1440 min → 1440/80 = 18 → rounded up to 20 min → 1200 s
|
||||
expect(getBarStepIntervalSeconds(0, 24 * HOUR_MS)).toBe(1200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractLegendMap', () => {
|
||||
it('maps query names to legends across composite subqueries', () => {
|
||||
const legendMap = extractLegendMap(
|
||||
compositeQuery([
|
||||
{ type: 'builder_query', spec: { name: 'A', legend: 'CPU {{host}}' } },
|
||||
{ type: 'builder_query', spec: { name: 'B' } },
|
||||
{ type: 'builder_formula', spec: { name: 'F1', legend: 'sum' } },
|
||||
]),
|
||||
);
|
||||
expect(legendMap).toStrictEqual({ A: 'CPU {{host}}', B: '', F1: 'sum' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasRunnableQueries', () => {
|
||||
it('false when the panel has no queries', () => {
|
||||
expect(hasRunnableQueries([])).toBe(false);
|
||||
});
|
||||
|
||||
it('true for non-metrics builder queries', () => {
|
||||
expect(
|
||||
hasRunnableQueries(bareBuilderQuery({ name: 'A', signal: 'logs' })),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('false when every metrics query is missing a metric name', () => {
|
||||
expect(
|
||||
hasRunnableQueries(
|
||||
bareBuilderQuery({
|
||||
name: 'A',
|
||||
signal: 'metrics',
|
||||
aggregations: [{ metricName: ' ' }],
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('true when at least one metrics query has a metric name', () => {
|
||||
expect(
|
||||
hasRunnableQueries(
|
||||
compositeQuery([
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: { name: 'A', signal: 'metrics', aggregations: [{}] },
|
||||
},
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'B',
|
||||
signal: 'metrics',
|
||||
aggregations: [{ metricName: 'system_cpu' }],
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user