mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-06 16:30:42 +00:00
Compare commits
8 Commits
main
...
feat/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd0a7e7e42 | ||
|
|
1098f01895 | ||
|
|
6522156fb9 | ||
|
|
db8bad9e68 | ||
|
|
a74c8b23fd | ||
|
|
a33cf59671 | ||
|
|
44e4ce47a7 | ||
|
|
4ecccc2c59 |
@@ -4377,7 +4377,8 @@ components:
|
||||
description: The dashboard title and description.
|
||||
duration:
|
||||
description: The default time range, as a Prometheus duration such as 1h
|
||||
or 7d. Stored, but not applied by the UI.
|
||||
or 7d. The UI opens the dashboard on it, rounded up to whole minutes,
|
||||
when neither the URL nor the viewer's last pick sets a time range.
|
||||
type: string
|
||||
layouts:
|
||||
description: The Grid layouts that place panels, at most 500. Each panel
|
||||
|
||||
@@ -7704,7 +7704,7 @@ export interface DashboardtypesDashboardSpecDTO {
|
||||
display: DashboardtypesDisplayDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description The default time range, as a Prometheus duration such as 1h or 7d. Stored, but not applied by the UI.
|
||||
* @description The default time range, as a Prometheus duration such as 1h or 7d. The UI opens the dashboard on it, rounded up to whole minutes, when neither the URL nor the viewer's last pick sets a time range.
|
||||
*/
|
||||
duration?: string;
|
||||
/**
|
||||
|
||||
@@ -1,31 +1,18 @@
|
||||
import {
|
||||
PROMETHEUS_DURATION_UNIT_MS,
|
||||
prometheusDurationToMs,
|
||||
} from 'utils/prometheusDuration';
|
||||
|
||||
import { ChannelFormValues, WebhookAuth } from './types';
|
||||
|
||||
// mirrors go's prometheus model.Duration units
|
||||
const JIRA_DURATION_UNIT_MS: Record<string, number> = {
|
||||
ms: 1,
|
||||
s: 1_000,
|
||||
m: 60_000,
|
||||
h: 3_600_000,
|
||||
d: 86_400_000,
|
||||
w: 604_800_000,
|
||||
y: 31_536_000_000,
|
||||
};
|
||||
const JIRA_DURATION_RE = /^(\d+(ms|s|m|h|d|w|y))+$/;
|
||||
const JIRA_DURATION_TOKEN_RE = /(\d+)(ms|s|m|h|d|w|y)/g;
|
||||
const JIRA_MIN_REOPEN_MS = 60_000;
|
||||
|
||||
export const isValidJiraReopenDuration = (value: string): boolean => {
|
||||
if (!value || value === '0') {
|
||||
return true;
|
||||
}
|
||||
if (!JIRA_DURATION_RE.test(value)) {
|
||||
return false;
|
||||
}
|
||||
let totalMs = 0;
|
||||
for (const [, amount, unit] of value.matchAll(JIRA_DURATION_TOKEN_RE)) {
|
||||
totalMs += Number(amount) * JIRA_DURATION_UNIT_MS[unit];
|
||||
}
|
||||
return totalMs >= JIRA_MIN_REOPEN_MS;
|
||||
const totalMs = prometheusDurationToMs(value);
|
||||
return totalMs !== null && totalMs >= JIRA_MIN_REOPEN_MS;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -33,25 +20,23 @@ export const isValidJiraReopenDuration = (value: string): boolean => {
|
||||
* back: "72h" becomes "3d", "7d" becomes "1w".
|
||||
*/
|
||||
const JIRA_DURATION_FORMAT: { unit: string; ms: number; exact: boolean }[] = [
|
||||
{ unit: 'y', ms: JIRA_DURATION_UNIT_MS.y, exact: true },
|
||||
{ unit: 'w', ms: JIRA_DURATION_UNIT_MS.w, exact: true },
|
||||
{ unit: 'd', ms: JIRA_DURATION_UNIT_MS.d, exact: false },
|
||||
{ unit: 'h', ms: JIRA_DURATION_UNIT_MS.h, exact: false },
|
||||
{ unit: 'm', ms: JIRA_DURATION_UNIT_MS.m, exact: false },
|
||||
{ unit: 's', ms: JIRA_DURATION_UNIT_MS.s, exact: false },
|
||||
{ unit: 'ms', ms: JIRA_DURATION_UNIT_MS.ms, exact: false },
|
||||
{ unit: 'y', ms: PROMETHEUS_DURATION_UNIT_MS.y, exact: true },
|
||||
{ unit: 'w', ms: PROMETHEUS_DURATION_UNIT_MS.w, exact: true },
|
||||
{ unit: 'd', ms: PROMETHEUS_DURATION_UNIT_MS.d, exact: false },
|
||||
{ unit: 'h', ms: PROMETHEUS_DURATION_UNIT_MS.h, exact: false },
|
||||
{ unit: 'm', ms: PROMETHEUS_DURATION_UNIT_MS.m, exact: false },
|
||||
{ unit: 's', ms: PROMETHEUS_DURATION_UNIT_MS.s, exact: false },
|
||||
{ unit: 'ms', ms: PROMETHEUS_DURATION_UNIT_MS.ms, exact: false },
|
||||
];
|
||||
|
||||
/** The API rejects a duration it would print differently. */
|
||||
export const canonicalJiraReopenDuration = (value: string): string => {
|
||||
if (!value || value === '0' || !JIRA_DURATION_RE.test(value)) {
|
||||
const totalMs = value && value !== '0' ? prometheusDurationToMs(value) : null;
|
||||
if (totalMs === null) {
|
||||
return value;
|
||||
}
|
||||
|
||||
let remaining = 0;
|
||||
for (const [, amount, unit] of value.matchAll(JIRA_DURATION_TOKEN_RE)) {
|
||||
remaining += Number(amount) * JIRA_DURATION_UNIT_MS[unit];
|
||||
}
|
||||
let remaining = totalMs;
|
||||
if (remaining === 0) {
|
||||
return '0s';
|
||||
}
|
||||
|
||||
@@ -107,9 +107,13 @@ function JsonEditorDrawer({
|
||||
} else if (!readOnly && !validity.valid) {
|
||||
applyDisabledReason = 'Fix the JSON errors first';
|
||||
}
|
||||
const validationText = validity.valid
|
||||
? `Valid JSON · ${validity.lineCount} lines`
|
||||
: `Line ${validity.errorLine ?? '?'} · ${validity.message ?? 'Invalid JSON'}`;
|
||||
const errorText = validity.message ?? 'Invalid JSON';
|
||||
let validationText = `Valid JSON · ${validity.lineCount} lines`;
|
||||
if (!validity.valid) {
|
||||
validationText = validity.errorLine
|
||||
? `Line ${validity.errorLine} · ${errorText}`
|
||||
: errorText;
|
||||
}
|
||||
const plural = (n: number): string => (n === 1 ? '' : 's');
|
||||
const danglingWarning =
|
||||
danglingPanelIds.length > 0
|
||||
@@ -135,15 +139,17 @@ function JsonEditorDrawer({
|
||||
footer={
|
||||
<div className={styles.footer}>
|
||||
<div className={styles.footerStatus}>
|
||||
<Typography.Text
|
||||
className={cx(styles.validation, {
|
||||
[styles.validationValid]: validity.valid,
|
||||
[styles.validationInvalid]: !validity.valid,
|
||||
})}
|
||||
data-testid="json-editor-validation"
|
||||
>
|
||||
{validationText}
|
||||
</Typography.Text>
|
||||
<Tooltip title={validity.valid ? undefined : validationText}>
|
||||
<Typography.Text
|
||||
className={cx(styles.validation, {
|
||||
[styles.validationValid]: validity.valid,
|
||||
[styles.validationInvalid]: !validity.valid,
|
||||
})}
|
||||
data-testid="json-editor-validation"
|
||||
>
|
||||
{validationText}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
{danglingWarning && (
|
||||
<Tooltip title={danglingPanelIds.join(', ')}>
|
||||
<span
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
import { fireEvent, render, screen, userEvent } from 'tests/test-utils';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import JsonEditorDrawer from '../JsonEditorDrawer';
|
||||
@@ -170,6 +170,36 @@ describe('JsonEditorDrawer', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('shows only the message when the error line is unknown', () => {
|
||||
mockUseJsonEditor.mockReturnValue(
|
||||
hookValue({
|
||||
validity: { valid: false, lineCount: 4, message: 'Bad value' },
|
||||
}),
|
||||
);
|
||||
render(<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />);
|
||||
|
||||
expect(screen.getByTestId('json-editor-validation')).toHaveTextContent(
|
||||
/^Bad value$/,
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the whole error on hover', async () => {
|
||||
const message = 'A long error that the footer cuts off';
|
||||
mockUseJsonEditor.mockReturnValue(
|
||||
hookValue({
|
||||
validity: { valid: false, lineCount: 4, errorLine: 2, message },
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />);
|
||||
|
||||
await user.hover(screen.getByTestId('json-editor-validation'));
|
||||
|
||||
await expect(screen.findByRole('tooltip')).resolves.toHaveTextContent(
|
||||
`Line 2 · ${message}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('disables Apply when not dirty, invalid, or saving', () => {
|
||||
mockUseJsonEditor.mockReturnValue(hookValue({ isDirty: false }));
|
||||
const { rerender } = render(
|
||||
|
||||
@@ -95,6 +95,54 @@ describe('useJsonEditor', () => {
|
||||
expect(result.current.isDirty).toBe(true);
|
||||
});
|
||||
|
||||
const withDuration = (duration: unknown): string =>
|
||||
JSON.stringify({ ...redacted, spec: { ...redacted.spec, duration } });
|
||||
|
||||
it.each(['1s', '90s', '500ms', '0m', 'abc', 30])(
|
||||
'rejects a spec.duration of %p the time picker cannot open',
|
||||
(duration) => {
|
||||
const { result } = renderHook(() =>
|
||||
useJsonEditor({ dashboard, isOpen: true, onApplied: jest.fn() }),
|
||||
);
|
||||
|
||||
act(() => result.current.setDraft(withDuration(duration)));
|
||||
|
||||
expect(result.current.validity).toStrictEqual({
|
||||
valid: false,
|
||||
lineCount: 1,
|
||||
errorLine: 1,
|
||||
message:
|
||||
'"spec.duration" must be a whole number of minutes above zero, like 30m, 6h or 1d. The time picker has no seconds.',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['spec.duration', '{\n "spec": {\n "duration": "1s"\n }\n}', 3],
|
||||
['image', '{\n "spec": {},\n "image": "https://x.io/a.png"\n}', 3],
|
||||
])('points at the line of an invalid %s', (_field, draft, errorLine) => {
|
||||
const { result } = renderHook(() =>
|
||||
useJsonEditor({ dashboard, isOpen: true, onApplied: jest.fn() }),
|
||||
);
|
||||
|
||||
act(() => result.current.setDraft(draft));
|
||||
|
||||
expect(result.current.validity.errorLine).toBe(errorLine);
|
||||
});
|
||||
|
||||
it.each(['', '30m', '1h30m', '60s', '1y'])(
|
||||
'accepts a spec.duration of %p',
|
||||
(duration) => {
|
||||
const { result } = renderHook(() =>
|
||||
useJsonEditor({ dashboard, isOpen: true, onApplied: jest.fn() }),
|
||||
);
|
||||
|
||||
act(() => result.current.setDraft(withDuration(duration)));
|
||||
|
||||
expect(result.current.validity.valid).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it('format() pretty-prints valid JSON and leaves invalid JSON untouched', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useJsonEditor({ dashboard, isOpen: true, onApplied: jest.fn() }),
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { escapeRegExp } from 'lodash-es';
|
||||
|
||||
// ponytail: a text match, not a JSON path, so an identical `"key": value` earlier
|
||||
// in the document wins. Switch to a JSON tree parser (jsonc-parser) if that bites.
|
||||
export function lineOfJsonEntry(
|
||||
source: string,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): number | undefined {
|
||||
const entry = new RegExp(
|
||||
`"${escapeRegExp(key)}"\\s*:\\s*${escapeRegExp(JSON.stringify(value))}`,
|
||||
);
|
||||
const index = source.search(entry);
|
||||
return index < 0 ? undefined : source.slice(0, index).split('\n').length;
|
||||
}
|
||||
@@ -12,8 +12,10 @@ import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import { dashboardToUpdatable } from './dashboardToUpdatable';
|
||||
import { findPanelLayoutIssues } from './danglingPanels';
|
||||
import { lineOfJsonEntry } from './lineOfJsonEntry';
|
||||
import { compactSpecLayouts } from '../../layoutCompaction';
|
||||
import { isValidDashboardImage } from '../../dashboardIcons';
|
||||
import { dashboardDurationError } from '../../utils/dashboardDuration';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
|
||||
export interface JsonValidity {
|
||||
@@ -109,7 +111,7 @@ export function useJsonEditor({
|
||||
|
||||
const validity = useMemo<JsonValidity>(() => {
|
||||
const lineCount = draft.split('\n').length;
|
||||
let parsed: { image?: unknown };
|
||||
let parsed: { image?: unknown; spec?: { duration?: unknown } };
|
||||
try {
|
||||
parsed = JSON.parse(draft);
|
||||
} catch (error) {
|
||||
@@ -131,10 +133,21 @@ export function useJsonEditor({
|
||||
return {
|
||||
valid: false,
|
||||
lineCount,
|
||||
errorLine: lineOfJsonEntry(draft, 'image', image),
|
||||
message:
|
||||
'"image" must be an /assets/Icons/<name> or /assets/Logos/<name> path, or a base64 image data URI',
|
||||
};
|
||||
}
|
||||
const duration = parsed.spec?.duration;
|
||||
const durationError = dashboardDurationError(duration);
|
||||
if (durationError) {
|
||||
return {
|
||||
valid: false,
|
||||
lineCount,
|
||||
errorLine: lineOfJsonEntry(draft, 'duration', duration),
|
||||
message: durationError,
|
||||
};
|
||||
}
|
||||
return { valid: true, lineCount };
|
||||
}, [draft]);
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
.infoItemContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.infoTitle {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.defaultTimeRangeInput {
|
||||
[data-radix-popper-content-wrapper] {
|
||||
z-index: 1100 !important;
|
||||
}
|
||||
|
||||
// Radix sets --radix-select-trigger-width on the content element (the wrapper's
|
||||
// child), so match it there to make the dropdown take the trigger's width.
|
||||
[data-radix-popper-content-wrapper] > * {
|
||||
width: var(--radix-select-trigger-width);
|
||||
min-width: var(--radix-select-trigger-width);
|
||||
}
|
||||
}
|
||||
|
||||
.defaultTimeRangeSelect {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.infoHint {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { buildDefaultTimeRangeItems, DEFAULT_TIME_RANGE_UNSET } from '../utils';
|
||||
import settingsStyles from '../../DashboardSettings.module.scss';
|
||||
import styles from './DefaultTimeRange.module.scss';
|
||||
|
||||
interface DefaultTimeRangeProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
function DefaultTimeRange({
|
||||
value,
|
||||
onChange,
|
||||
}: DefaultTimeRangeProps): JSX.Element {
|
||||
return (
|
||||
<div className={settingsStyles.settingsCard}>
|
||||
<div className={styles.infoItemContainer}>
|
||||
<Typography className={styles.infoTitle}>Default time range</Typography>
|
||||
<section className={styles.defaultTimeRangeInput}>
|
||||
<SelectSimple
|
||||
className={styles.defaultTimeRangeSelect}
|
||||
testId="dashboard-default-time-range"
|
||||
items={buildDefaultTimeRangeItems(value)}
|
||||
value={value && value !== '30m' ? value : DEFAULT_TIME_RANGE_UNSET}
|
||||
withPortal={false}
|
||||
onChange={(value): void =>
|
||||
onChange(value === DEFAULT_TIME_RANGE_UNSET ? '' : (value as string))
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
<Typography className={styles.infoHint}>
|
||||
Applies by default when a viewer opens the dashboard for the first time.
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DefaultTimeRange;
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DashboardtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import Overview from '../index';
|
||||
|
||||
const mockPatchAsync = jest.fn();
|
||||
jest.mock('../../../hooks/useOptimisticPatch', () => ({
|
||||
useOptimisticPatch: (): { patchAsync: jest.Mock; isPatching: boolean } => ({
|
||||
patchAsync: mockPatchAsync,
|
||||
isPatching: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../CrossPanelSync/CrossPanelSync', () => ({
|
||||
__esModule: true,
|
||||
default: (): null => null,
|
||||
}));
|
||||
|
||||
function buildDashboard(
|
||||
duration?: string,
|
||||
): DashboardtypesGettableDashboardV2DTO {
|
||||
return {
|
||||
id: 'dash-1',
|
||||
name: 'D',
|
||||
orgId: 'org',
|
||||
source: DashboardtypesSourceDTO.user,
|
||||
locked: false,
|
||||
schemaVersion: 'v6',
|
||||
tags: [],
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
spec: {
|
||||
display: { name: 'D' },
|
||||
panels: {},
|
||||
layouts: [],
|
||||
variables: [],
|
||||
duration,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('Dashboard settings - default time range', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
async function selectOption(label: string): Promise<void> {
|
||||
await user.click(screen.getByTestId('dashboard-default-time-range'));
|
||||
await user.click(await screen.findByText(label));
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
await user.click(await screen.findByTestId('save-dashboard-config'));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockPatchAsync.mockReset();
|
||||
mockPatchAsync.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('shows the stored window and patches a new one', async () => {
|
||||
render(<Overview dashboard={buildDashboard('30m')} />);
|
||||
|
||||
const trigger = await screen.findByTestId('dashboard-default-time-range');
|
||||
expect(trigger).toHaveTextContent('Default (Last 30 minutes)');
|
||||
|
||||
await selectOption('Last 1 hour');
|
||||
|
||||
await expect(screen.findByText('1 unsaved change')).resolves.toBeVisible();
|
||||
|
||||
await save();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPatchAsync).toHaveBeenCalledWith([
|
||||
{ op: 'replace', path: '/spec/duration', value: '1h' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('offers the 30-minute default once', async () => {
|
||||
render(<Overview dashboard={buildDashboard('1h')} />);
|
||||
|
||||
await user.click(await screen.findByTestId('dashboard-default-time-range'));
|
||||
|
||||
expect(
|
||||
screen.getByRole('option', { name: 'Default (Last 30 minutes)' }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('option', { name: 'Last 30 minutes', exact: true }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('patches an empty window when the setting is cleared', async () => {
|
||||
render(<Overview dashboard={buildDashboard('1h')} />);
|
||||
|
||||
await selectOption('Default (Last 30 minutes)');
|
||||
await save();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPatchAsync).toHaveBeenCalledWith([
|
||||
{ op: 'replace', path: '/spec/duration', value: '' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['2h', 'Last 2h'],
|
||||
['1s', 'Last 1s (opens as 1m)'],
|
||||
['1h30m', 'Last 1h30m (opens as 90m)'],
|
||||
['0m', '0m (not applied)'],
|
||||
])(
|
||||
'labels a stored %s that is not one of the options as "%s"',
|
||||
async (duration, label) => {
|
||||
render(<Overview dashboard={buildDashboard(duration)} />);
|
||||
|
||||
const trigger = await screen.findByTestId('dashboard-default-time-range');
|
||||
expect(trigger).toHaveTextContent(label);
|
||||
},
|
||||
);
|
||||
|
||||
it('does not patch the window when it is untouched', async () => {
|
||||
render(<Overview dashboard={buildDashboard('1h')} />);
|
||||
|
||||
await user.type(await screen.findByTestId('dashboard-name'), 'X');
|
||||
await save();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPatchAsync).toHaveBeenCalledWith([
|
||||
{ op: 'replace', path: '/spec/display/name', value: 'DX' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import APIError from 'types/api/error';
|
||||
|
||||
import { useOptimisticPatch } from '../../hooks/useOptimisticPatch';
|
||||
import CrossPanelSync from './CrossPanelSync/CrossPanelSync';
|
||||
import DefaultTimeRange from './DefaultTimeRange/DefaultTimeRange';
|
||||
import DashboardInfoForm from './DashboardInfoForm/DashboardInfoForm';
|
||||
import UnsavedChangesFooter from './UnsavedChangesFooter/UnsavedChangesFooter';
|
||||
import { stringsToTags, tagsToStrings } from './utils';
|
||||
@@ -31,6 +32,7 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
const title = dashboard.spec.display.name;
|
||||
const description = dashboard.spec.display.description ?? '';
|
||||
const image = dashboard.image || DEFAULT_DASHBOARD_ICON_PATH;
|
||||
const defaultTimeRange = dashboard.spec.duration ?? '';
|
||||
const tagsAsStrings = useMemo(
|
||||
() => tagsToStrings(dashboard.tags ?? []),
|
||||
[dashboard.tags],
|
||||
@@ -41,6 +43,8 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
const [updatedDescription, setUpdatedDescription] =
|
||||
useState<string>(description);
|
||||
const [updatedImage, setUpdatedImage] = useState<string>(image);
|
||||
const [updatedDefaultTimeRange, setUpdatedDefaultTimeRange] =
|
||||
useState<string>(defaultTimeRange);
|
||||
const [isSaving, setIsSaving] = useState<boolean>(false);
|
||||
const [numberOfUnsavedChanges, setNumberOfUnsavedChanges] =
|
||||
useState<number>(0);
|
||||
@@ -53,6 +57,7 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
setUpdatedDescription(description);
|
||||
setUpdatedImage(image);
|
||||
setUpdatedTags(tagsAsStrings);
|
||||
setUpdatedDefaultTimeRange(defaultTimeRange);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dashboard.updatedAt]);
|
||||
|
||||
@@ -102,6 +107,9 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
if (!isEqual(updatedTags, tagsAsStrings)) {
|
||||
ops.push(replace('/tags', stringsToTags(updatedTags)));
|
||||
}
|
||||
if (updatedDefaultTimeRange !== defaultTimeRange) {
|
||||
ops.push(replace('/spec/duration', updatedDefaultTimeRange));
|
||||
}
|
||||
return ops;
|
||||
}, [
|
||||
updatedTitle,
|
||||
@@ -113,6 +121,8 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
dashboard.image,
|
||||
updatedTags,
|
||||
tagsAsStrings,
|
||||
updatedDefaultTimeRange,
|
||||
defaultTimeRange,
|
||||
]);
|
||||
|
||||
const onSaveHandler = useCallback(async (): Promise<void> => {
|
||||
@@ -135,12 +145,19 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
|
||||
useEffect(() => {
|
||||
let numberOfUnsavedChanges = 0;
|
||||
const initialValues = [title, description, tagsAsStrings, image];
|
||||
const initialValues = [
|
||||
title,
|
||||
description,
|
||||
tagsAsStrings,
|
||||
image,
|
||||
defaultTimeRange,
|
||||
];
|
||||
const updatedValues = [
|
||||
updatedTitle,
|
||||
updatedDescription,
|
||||
updatedTags,
|
||||
updatedImage,
|
||||
updatedDefaultTimeRange,
|
||||
];
|
||||
initialValues.forEach((val, index) => {
|
||||
if (!isEqual(val, updatedValues[index])) {
|
||||
@@ -153,10 +170,12 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
image,
|
||||
tagsAsStrings,
|
||||
title,
|
||||
defaultTimeRange,
|
||||
updatedDescription,
|
||||
updatedImage,
|
||||
updatedTags,
|
||||
updatedTitle,
|
||||
updatedDefaultTimeRange,
|
||||
]);
|
||||
|
||||
const discardHandler = useCallback((): void => {
|
||||
@@ -164,8 +183,9 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
setUpdatedImage(image);
|
||||
setUpdatedTags(tagsAsStrings);
|
||||
setUpdatedDescription(description);
|
||||
setUpdatedDefaultTimeRange(defaultTimeRange);
|
||||
void logEvent(DashboardDetailEvents.OverviewDiscarded, { dashboardId: id });
|
||||
}, [title, image, tagsAsStrings, description, id]);
|
||||
}, [title, image, tagsAsStrings, description, defaultTimeRange, id]);
|
||||
|
||||
return (
|
||||
<div className={styles.overviewContent}>
|
||||
@@ -179,6 +199,10 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
onImageChange={setUpdatedImage}
|
||||
onTagsChange={setUpdatedTags}
|
||||
/>
|
||||
<DefaultTimeRange
|
||||
value={updatedDefaultTimeRange}
|
||||
onChange={setUpdatedDefaultTimeRange}
|
||||
/>
|
||||
<CrossPanelSync dashboardId={id} />
|
||||
{numberOfUnsavedChanges > 0 && (
|
||||
<UnsavedChangesFooter
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { TagtypesPostableTagDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { RelativeDurationOptions } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
|
||||
import { toPickerRelativeTime } from '../../utils/dashboardDuration';
|
||||
|
||||
export { parseKeyValueTag } from 'components/TagKeyValueInput/utils';
|
||||
|
||||
@@ -22,3 +25,41 @@ export function stringsToTags(tagStrings: string[]): TagtypesPostableTagDTO[] {
|
||||
})
|
||||
.filter((t) => t.key.length > 0);
|
||||
}
|
||||
|
||||
// Radix rejects an empty option value, so "not set" travels through the select
|
||||
// under a sentinel and is mapped back to '' on the way out.
|
||||
export const DEFAULT_TIME_RANGE_UNSET = '__unset__';
|
||||
|
||||
// The default entry represents 30m; the API rejects `1month`, which is not a
|
||||
// Prometheus duration.
|
||||
const SELECTABLE_DURATIONS = RelativeDurationOptions.filter(
|
||||
(option) => option.value !== '1month' && option.value !== '30m',
|
||||
);
|
||||
|
||||
function storedDurationLabel(value: string): string {
|
||||
const relativeTime = toPickerRelativeTime(value);
|
||||
if (!relativeTime) {
|
||||
return `${value} (not applied)`;
|
||||
}
|
||||
return relativeTime === value
|
||||
? `Last ${value}`
|
||||
: `Last ${value} (opens as ${relativeTime})`;
|
||||
}
|
||||
|
||||
// A window set through the API or the JSON editor may sit outside the list;
|
||||
// it's appended so the select shows it instead of rendering blank.
|
||||
export function buildDefaultTimeRangeItems(
|
||||
value: string,
|
||||
): { value: string; label: string }[] {
|
||||
const items = [
|
||||
{ value: DEFAULT_TIME_RANGE_UNSET, label: 'Default (Last 30 minutes)' },
|
||||
...SELECTABLE_DURATIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
})),
|
||||
];
|
||||
if (value && value !== '30m' && !items.some((item) => item.value === value)) {
|
||||
items.push({ value, label: storedDurationLabel(value) });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { StrictMode, type ReactNode } from 'react';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DashboardtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { getCurrentLocation, navigate } from 'lib/router/navigation';
|
||||
import { act, render, screen } from 'tests/test-utils';
|
||||
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
|
||||
|
||||
import DashboardContainer from '../index';
|
||||
|
||||
const mockUpdateTimeInterval = jest.fn();
|
||||
jest.mock('store/actions', () => ({
|
||||
...jest.requireActual('store/actions'),
|
||||
UpdateTimeInterval: (...args: unknown[]): (() => void) => {
|
||||
mockUpdateTimeInterval(...args);
|
||||
return (): void => {};
|
||||
},
|
||||
}));
|
||||
|
||||
const mockSelectorMount = jest.fn();
|
||||
const mockPanelMount = jest.fn();
|
||||
const mockStore = { resetVariableFetch: jest.fn(), setEditContext: jest.fn() };
|
||||
jest.mock('../store/useDashboardStore', () => ({
|
||||
useDashboardStore: (selector: (state: typeof mockStore) => unknown): unknown =>
|
||||
selector(mockStore),
|
||||
}));
|
||||
jest.mock('../hooks/useResolvedVariables', () => ({
|
||||
useResolvedVariables: jest.fn(),
|
||||
}));
|
||||
jest.mock('../hooks/useSyncVariablesForSuggestions', () => ({
|
||||
useSyncVariablesForSuggestions: jest.fn(),
|
||||
}));
|
||||
jest.mock('../hooks/useDashboardStaleCheck', () => ({
|
||||
useDashboardStaleCheck: (): object => ({
|
||||
showPrompt: false,
|
||||
reload: jest.fn(),
|
||||
dismiss: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
jest.mock('../dashboardIcons', () => ({
|
||||
resolveDashboardImage: (): string => '',
|
||||
}));
|
||||
jest.mock('react-full-screen', () => ({
|
||||
FullScreen: ({ children }: { children: ReactNode }): ReactNode => children,
|
||||
useFullScreenHandle: (): object => ({ active: false }),
|
||||
}));
|
||||
jest.mock('../DashboardPageToolbar', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => {
|
||||
mockSelectorMount(getUnstableCurrentSearchParams().toString());
|
||||
return <div data-testid="time-selector" />;
|
||||
},
|
||||
}));
|
||||
jest.mock('../PanelsAndSectionsLayout', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => {
|
||||
mockPanelMount(getUnstableCurrentSearchParams().toString());
|
||||
return <div data-testid="panels" />;
|
||||
},
|
||||
}));
|
||||
jest.mock('../components/DashboardPageHeader/DashboardPageHeader', () => ({
|
||||
__esModule: true,
|
||||
default: (): null => null,
|
||||
}));
|
||||
jest.mock('../components/LockedIndicator/LockedIndicator', () => ({
|
||||
__esModule: true,
|
||||
default: (): null => null,
|
||||
}));
|
||||
jest.mock(
|
||||
'../components/DashboardChangedDialog/DashboardChangedDialog',
|
||||
() => ({ __esModule: true, default: (): null => null }),
|
||||
);
|
||||
jest.mock('container/TopNav/AutoRefreshV2/AutoRefreshTicker', () => ({
|
||||
__esModule: true,
|
||||
default: (): null => null,
|
||||
}));
|
||||
|
||||
const PATH = '/dashboard/abc';
|
||||
const refetch = jest.fn();
|
||||
|
||||
function dashboard(
|
||||
duration = '1h',
|
||||
id = 'abc',
|
||||
): DashboardtypesGettableDashboardV2DTO {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
orgId: 'org',
|
||||
source: DashboardtypesSourceDTO.user,
|
||||
locked: false,
|
||||
schemaVersion: 'v6',
|
||||
tags: [],
|
||||
spec: {
|
||||
display: { name: id },
|
||||
duration,
|
||||
layouts: [],
|
||||
panels: {},
|
||||
variables: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function view(data: DashboardtypesGettableDashboardV2DTO): JSX.Element {
|
||||
return (
|
||||
<StrictMode>
|
||||
<DashboardContainer dashboard={data} refetch={refetch} />
|
||||
</StrictMode>
|
||||
);
|
||||
}
|
||||
|
||||
function mountDashboard(
|
||||
search = '',
|
||||
duration = '1h',
|
||||
): ReturnType<typeof render> & {
|
||||
update: (data: DashboardtypesGettableDashboardV2DTO) => void;
|
||||
} {
|
||||
const result = render(view(dashboard(duration)), undefined, {
|
||||
initialRoute: `${PATH}${search}`,
|
||||
});
|
||||
return {
|
||||
...result,
|
||||
update: (data): void => result.rerender(view(data)),
|
||||
};
|
||||
}
|
||||
|
||||
describe('dashboard default time initialization', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('writes the default before the selector and panels first mount, replacing history', () => {
|
||||
const historyLength = window.history.length;
|
||||
mountDashboard('?variables=selected&tab=overview');
|
||||
expect(screen.getByTestId('time-selector')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('panels')).toBeInTheDocument();
|
||||
for (const mounted of [mockSelectorMount, mockPanelMount]) {
|
||||
expect(mounted).toHaveBeenCalled();
|
||||
for (const [search] of mounted.mock.calls) {
|
||||
expect(new URLSearchParams(search).get('relativeTime')).toBe('1h');
|
||||
}
|
||||
}
|
||||
const params = new URLSearchParams(getCurrentLocation().search);
|
||||
expect(params.get('variables')).toBe('selected');
|
||||
expect(params.get('tab')).toBe('overview');
|
||||
expect(window.history).toHaveLength(historyLength);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'?relativeTime=15m',
|
||||
'?startTime=1000&endTime=2000',
|
||||
'?startTime=1000',
|
||||
'?endTime=2000',
|
||||
])('preserves explicit URL time %s', (search) => {
|
||||
mountDashboard(search);
|
||||
expect(getCurrentLocation().search).toBe(search);
|
||||
expect(mockSelectorMount.mock.calls[0][0]).toBe(search.slice(1));
|
||||
});
|
||||
|
||||
it.each(['1d', JSON.stringify({ startTime: 1000, endTime: 2000 })])(
|
||||
'leaves a saved route preference %s for the shared selector',
|
||||
(saved) => {
|
||||
localStorage.setItem(
|
||||
LOCALSTORAGE.METRICS_TIME_IN_DURATION,
|
||||
JSON.stringify({ [PATH]: saved }),
|
||||
);
|
||||
mountDashboard();
|
||||
expect(getCurrentLocation().search).toBe('');
|
||||
expect(
|
||||
JSON.parse(
|
||||
localStorage.getItem(LOCALSTORAGE.METRICS_TIME_IN_DURATION) ?? '{}',
|
||||
)[PATH],
|
||||
).toBe(saved);
|
||||
expect(mockSelectorMount.mock.calls[0][0]).toBe('');
|
||||
},
|
||||
);
|
||||
|
||||
it('does not use preferences from another dashboard', () => {
|
||||
localStorage.setItem(
|
||||
LOCALSTORAGE.METRICS_TIME_IN_DURATION,
|
||||
JSON.stringify({ '/dashboard/other': '1d' }),
|
||||
);
|
||||
mountDashboard();
|
||||
expect(getCurrentLocation().search).toBe('?relativeTime=1h');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['1h30m', '90m'],
|
||||
['120m', '2h'],
|
||||
['14d', '2w'],
|
||||
['1y', '365d'],
|
||||
['15s', '1m'],
|
||||
['90s', '2m'],
|
||||
['500ms', '1m'],
|
||||
])('opens a %s default as %s, in whole minutes', (duration, relativeTime) => {
|
||||
mountDashboard('', duration);
|
||||
expect(getCurrentLocation().search).toBe(`?relativeTime=${relativeTime}`);
|
||||
});
|
||||
|
||||
it.each(['', '0m', '0s', 'invalid'])(
|
||||
'keeps the normal selector flow for unusable default %s',
|
||||
(duration) => {
|
||||
mountDashboard('', duration);
|
||||
expect(getCurrentLocation().search).toBe('');
|
||||
expect(screen.getByTestId('time-selector')).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it('does not reset a current selection when settings refetch', () => {
|
||||
const { update } = mountDashboard();
|
||||
act(() => navigate(`${PATH}?relativeTime=15m`, { replace: true }));
|
||||
update(dashboard('1w'));
|
||||
expect(getCurrentLocation().search).toBe('?relativeTime=15m');
|
||||
expect(mockUpdateTimeInterval).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('moves a viewer still on the old default to a changed one', () => {
|
||||
const { update } = mountDashboard();
|
||||
update(dashboard('1d'));
|
||||
expect(getCurrentLocation().search).toBe('?relativeTime=1d');
|
||||
expect(mockUpdateTimeInterval).toHaveBeenCalledWith('1d');
|
||||
});
|
||||
|
||||
it('moves a viewer to 30 minutes when the default is cleared', () => {
|
||||
const { update } = mountDashboard();
|
||||
update(dashboard(''));
|
||||
expect(getCurrentLocation().search).toBe('?relativeTime=30m');
|
||||
expect(mockUpdateTimeInterval).toHaveBeenCalledWith('30m');
|
||||
});
|
||||
|
||||
it('moves a viewer off 30 minutes when a default is first set', () => {
|
||||
const { update } = mountDashboard('?relativeTime=30m', '');
|
||||
update(dashboard('1d'));
|
||||
expect(getCurrentLocation().search).toBe('?relativeTime=1d');
|
||||
expect(mockUpdateTimeInterval).toHaveBeenCalledWith('1d');
|
||||
});
|
||||
|
||||
it('keeps the time a viewer picked when the default changes', () => {
|
||||
const { update } = mountDashboard();
|
||||
localStorage.setItem(
|
||||
LOCALSTORAGE.METRICS_TIME_IN_DURATION,
|
||||
JSON.stringify({ [PATH]: '1h' }),
|
||||
);
|
||||
update(dashboard('1d'));
|
||||
expect(getCurrentLocation().search).toBe('?relativeTime=1h');
|
||||
expect(mockUpdateTimeInterval).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('initializes a different dashboard before mounting its children', () => {
|
||||
const { update } = mountDashboard();
|
||||
mockSelectorMount.mockClear();
|
||||
mockPanelMount.mockClear();
|
||||
act(() => {
|
||||
navigate('/dashboard/other');
|
||||
update(dashboard('1w', 'other'));
|
||||
});
|
||||
expect(getCurrentLocation().search).toBe('?relativeTime=1w');
|
||||
expect(mockSelectorMount).toHaveBeenCalled();
|
||||
expect(mockPanelMount).toHaveBeenCalled();
|
||||
expect(
|
||||
mockSelectorMount.mock.calls.every(
|
||||
([search]) => new URLSearchParams(search).get('relativeTime') === '1w',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
mockPanelMount.mock.calls.every(
|
||||
([search]) => new URLSearchParams(search).get('relativeTime') === '1w',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports -- TODO: migrate global time dispatch off redux
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { getDefaultOption } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
import type { Time } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { useAppNavigate } from 'lib/router/useAppNavigate';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
|
||||
import { hasPersistedTimeForRoute } from 'utils/metricsTimeStorageUtils';
|
||||
|
||||
import { toPickerRelativeTime } from '../utils/dashboardDuration';
|
||||
|
||||
/**
|
||||
* Prepares the dashboard URL before its time selector and panels mount, and
|
||||
* moves a viewer still on the previous default to a changed one.
|
||||
*/
|
||||
export function useDashboardDefaultTime(
|
||||
dashboardId: string,
|
||||
duration: string | undefined,
|
||||
): boolean {
|
||||
const { pathname } = useAppLocation();
|
||||
const navigate = useAppNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const previousDuration = useRef(duration);
|
||||
const [initialized, setInitialized] = useState<{
|
||||
dashboardId: string;
|
||||
pathname: string;
|
||||
}>();
|
||||
const isInitialized =
|
||||
initialized?.dashboardId === dashboardId && initialized.pathname === pathname;
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialized) {
|
||||
return;
|
||||
}
|
||||
const params = getUnstableCurrentSearchParams();
|
||||
const hasUrlTime = [
|
||||
QueryParams.relativeTime,
|
||||
QueryParams.startTime,
|
||||
QueryParams.endTime,
|
||||
].some((key) => params.has(key));
|
||||
const relativeTime = toPickerRelativeTime(duration);
|
||||
|
||||
if (!hasUrlTime && !hasPersistedTimeForRoute(pathname) && relativeTime) {
|
||||
params.set(QueryParams.relativeTime, relativeTime);
|
||||
navigate(`${pathname}?${params.toString()}`, { replace: true });
|
||||
}
|
||||
setInitialized({ dashboardId, pathname });
|
||||
}, [dashboardId, duration, pathname, isInitialized, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
const previous = previousDuration.current;
|
||||
previousDuration.current = duration;
|
||||
if (!isInitialized || previous === duration) {
|
||||
return;
|
||||
}
|
||||
const params = getUnstableCurrentSearchParams();
|
||||
const routeDefault = getDefaultOption(pathname);
|
||||
const isOnPreviousDefault =
|
||||
params.get(QueryParams.relativeTime) ===
|
||||
(toPickerRelativeTime(previous) ?? routeDefault) &&
|
||||
!params.has(QueryParams.startTime) &&
|
||||
!params.has(QueryParams.endTime) &&
|
||||
!hasPersistedTimeForRoute(pathname);
|
||||
if (!isOnPreviousDefault) {
|
||||
return;
|
||||
}
|
||||
const next = toPickerRelativeTime(duration) ?? routeDefault;
|
||||
params.set(QueryParams.relativeTime, next);
|
||||
navigate(`${pathname}?${params.toString()}`, { replace: true });
|
||||
dispatch(UpdateTimeInterval(next as Time));
|
||||
}, [duration, isInitialized, pathname, navigate, dispatch]);
|
||||
|
||||
return isInitialized;
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import DashboardPageHeader from './components/DashboardPageHeader/DashboardPageH
|
||||
import LockedIndicator from './components/LockedIndicator/LockedIndicator';
|
||||
import DashboardChangedDialog from './components/DashboardChangedDialog/DashboardChangedDialog';
|
||||
import { useDashboardStaleCheck } from './hooks/useDashboardStaleCheck';
|
||||
import { useDashboardDefaultTime } from './hooks/useDashboardDefaultTime';
|
||||
import { resolveDashboardImage } from 'pages/DashboardPage/DashboardContainer/dashboardIcons';
|
||||
|
||||
interface DashboardContainerProps {
|
||||
@@ -33,8 +34,9 @@ function DashboardContainer({
|
||||
dashboard,
|
||||
refetch,
|
||||
canEditDashboardOverride,
|
||||
}: DashboardContainerProps): JSX.Element {
|
||||
}: DashboardContainerProps): JSX.Element | null {
|
||||
const spec = dashboard.spec;
|
||||
const isTimeInitialized = useDashboardDefaultTime(dashboard.id, spec.duration);
|
||||
const image = resolveDashboardImage(dashboard.image);
|
||||
const name = spec.display.name;
|
||||
|
||||
@@ -68,6 +70,10 @@ function DashboardContainer({
|
||||
|
||||
const staleCheck = useDashboardStaleCheck(dashboard, refetch);
|
||||
|
||||
if (!isTimeInitialized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// In full screen show only the sections and panels — the header/toolbar chrome
|
||||
// is hidden for a clean presentation view (exit with Esc).
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
PROMETHEUS_DURATION_UNIT_MS,
|
||||
prometheusDurationToMs,
|
||||
} from 'utils/prometheusDuration';
|
||||
|
||||
const MINUTE_MS = PROMETHEUS_DURATION_UNIT_MS.m;
|
||||
const PICKER_UNITS: { unit: string; minutes: number }[] = [
|
||||
{ unit: 'w', minutes: 10_080 },
|
||||
{ unit: 'd', minutes: 1_440 },
|
||||
{ unit: 'h', minutes: 60 },
|
||||
];
|
||||
|
||||
/**
|
||||
* The time picker's relative time for a dashboard duration, or null when the
|
||||
* duration is empty, zero or not a duration. The picker has no seconds, so the
|
||||
* window is rounded up to whole minutes: `1h30m` opens as `90m`, `15s` as `1m`.
|
||||
*/
|
||||
export function toPickerRelativeTime(
|
||||
duration: string | undefined,
|
||||
): string | null {
|
||||
const totalMs = duration ? prometheusDurationToMs(duration) : null;
|
||||
if (!totalMs) {
|
||||
return null;
|
||||
}
|
||||
const minutes = Math.ceil(totalMs / MINUTE_MS);
|
||||
const largest = PICKER_UNITS.find((option) => minutes % option.minutes === 0);
|
||||
return largest ? `${minutes / largest.minutes}${largest.unit}` : `${minutes}m`;
|
||||
}
|
||||
|
||||
/** Why a hand-written `spec.duration` can't be saved, or null when it can. */
|
||||
export function dashboardDurationError(duration: unknown): string | null {
|
||||
if (duration === undefined || duration === null || duration === '') {
|
||||
return null;
|
||||
}
|
||||
const totalMs =
|
||||
typeof duration === 'string' ? prometheusDurationToMs(duration) : null;
|
||||
if (!totalMs || totalMs % MINUTE_MS !== 0) {
|
||||
return '"spec.duration" must be a whole number of minutes above zero, like 30m, 6h or 1d. The time picker has no seconds.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -547,6 +547,18 @@ export const openSettings = async (
|
||||
return screen.findByRole('tabpanel');
|
||||
};
|
||||
|
||||
export const clickWhenEnabled = async (testId: string): Promise<void> => {
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(screen.getByTestId(testId)).not.toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
),
|
||||
PAGE_LOAD,
|
||||
);
|
||||
await userEvent.click(screen.getByTestId(testId));
|
||||
};
|
||||
|
||||
export const variableRow = (name: string): Promise<HTMLElement> =>
|
||||
screen.findByTestId(`variable-row-${name}`, {}, PAGE_LOAD);
|
||||
|
||||
|
||||
@@ -7,7 +7,11 @@ import { screen, userEvent, within } from 'storybook/test';
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { dashboardMocks, openSettings } from '../DashboardPage.stories.mocks';
|
||||
import {
|
||||
clickWhenEnabled,
|
||||
dashboardMocks,
|
||||
openSettings,
|
||||
} from '../DashboardPage.stories.mocks';
|
||||
|
||||
import DashboardPage from '../../DashboardPage';
|
||||
|
||||
@@ -136,7 +140,7 @@ export const PublishedToast: Story = {
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement, args }) => {
|
||||
await openSettings(canvasElement, 'Publish');
|
||||
await userEvent.click(await screen.findByTestId('public-dashboard-publish'));
|
||||
await clickWhenEnabled('public-dashboard-publish');
|
||||
if (args.publicWrite === 'success') {
|
||||
await screen.findByText('Dashboard published successfully');
|
||||
}
|
||||
@@ -147,7 +151,7 @@ export const PublishedToast: Story = {
|
||||
export const PublicUpdatedToast: Story = {
|
||||
play: async ({ canvasElement, args }) => {
|
||||
await openSettings(canvasElement, 'Publish');
|
||||
await userEvent.click(await screen.findByTestId('public-dashboard-update'));
|
||||
await clickWhenEnabled('public-dashboard-update');
|
||||
if (args.publicWrite === 'success') {
|
||||
await screen.findByText('Public dashboard updated successfully');
|
||||
}
|
||||
@@ -158,9 +162,7 @@ export const PublicUpdatedToast: Story = {
|
||||
export const UnpublishedToast: Story = {
|
||||
play: async ({ canvasElement, args }) => {
|
||||
await openSettings(canvasElement, 'Publish');
|
||||
await userEvent.click(
|
||||
await screen.findByTestId('public-dashboard-unpublish'),
|
||||
);
|
||||
await clickWhenEnabled('public-dashboard-unpublish');
|
||||
if (args.publicWrite === 'success') {
|
||||
await screen.findByText('Dashboard unpublished successfully');
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { DashboardListEvents } from 'pages/DashboardsListPage/constants/events';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { isValidDashboardImage } from 'pages/DashboardPage/DashboardContainer/dashboardIcons';
|
||||
import { dashboardDurationError } from 'pages/DashboardPage/DashboardContainer/utils/dashboardDuration';
|
||||
|
||||
import { normalizeToPostable } from './importUtils';
|
||||
import JsonEditor from './JsonEditor';
|
||||
@@ -46,10 +47,10 @@ function ImportJsonPanel({ onClose }: Props): JSX.Element {
|
||||
reader.onload = (event): void => {
|
||||
try {
|
||||
const target = event.target?.result;
|
||||
if (!target) {
|
||||
if (typeof target !== 'string') {
|
||||
return;
|
||||
}
|
||||
const parsed = JSON.parse(target.toString());
|
||||
const parsed = JSON.parse(target);
|
||||
setEditorValue(JSON.stringify(parsed, null, 2));
|
||||
setIsUploadError(false);
|
||||
} catch {
|
||||
@@ -63,7 +64,7 @@ function ImportJsonPanel({ onClose }: Props): JSX.Element {
|
||||
const handleImport = async (): Promise<void> => {
|
||||
try {
|
||||
setIsCreating(true);
|
||||
logEvent('Dashboard List V2: Import and next clicked', {});
|
||||
void logEvent('Dashboard List V2: Import and next clicked', {});
|
||||
const parsed = JSON.parse(editorValue) as Record<string, unknown>;
|
||||
const payload = normalizeToPostable(parsed);
|
||||
// Only an icon/logo path or a base64 image is a storable `image`; reject
|
||||
@@ -78,6 +79,15 @@ function ImportJsonPanel({ onClose }: Props): JSX.Element {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const durationError = dashboardDurationError(payload.spec?.duration);
|
||||
if (durationError) {
|
||||
setIsCreateError(true);
|
||||
void logEvent(DashboardListEvents.ImportFailed, {
|
||||
reason: 'invalid-duration',
|
||||
});
|
||||
toast.error(durationError);
|
||||
return;
|
||||
}
|
||||
const response = await createDashboardV2(payload);
|
||||
void logEvent(DashboardListEvents.DashboardCreated, { method: 'import' });
|
||||
onClose();
|
||||
@@ -129,7 +139,7 @@ function ImportJsonPanel({ onClose }: Props): JSX.Element {
|
||||
prefix={<MonitorDot size={14} />}
|
||||
testId="upload-json-file"
|
||||
onClick={(): void => {
|
||||
logEvent('Dashboard List V2: Upload JSON file clicked', {});
|
||||
void logEvent('Dashboard List V2: Upload JSON file clicked', {});
|
||||
}}
|
||||
>
|
||||
{t('upload_json_file')}
|
||||
|
||||
@@ -2,6 +2,19 @@ import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import setLocalStorageKey from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
function readPersistedTimes(): Record<string, string> {
|
||||
const stored = getLocalStorageKey(LOCALSTORAGE.METRICS_TIME_IN_DURATION);
|
||||
try {
|
||||
return stored ? JSON.parse(stored) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function hasPersistedTimeForRoute(pathname: string): boolean {
|
||||
return Boolean(readPersistedTimes()[pathname]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the stored time duration for a route in localStorage.
|
||||
* Used by both DateTimeSelectionV2 (manual time pick) and useZoomOut (zoom out button).
|
||||
@@ -13,14 +26,7 @@ export function persistTimeDurationForRoute(
|
||||
pathname: string,
|
||||
value: string,
|
||||
): void {
|
||||
const preRoutes = getLocalStorageKey(LOCALSTORAGE.METRICS_TIME_IN_DURATION);
|
||||
let preRoutesObject: Record<string, string> = {};
|
||||
try {
|
||||
preRoutesObject = preRoutes ? JSON.parse(preRoutes) : {};
|
||||
} catch {
|
||||
preRoutesObject = {};
|
||||
}
|
||||
const preRoute = { ...preRoutesObject, [pathname]: value };
|
||||
const preRoute = { ...readPersistedTimes(), [pathname]: value };
|
||||
setLocalStorageKey(
|
||||
LOCALSTORAGE.METRICS_TIME_IN_DURATION,
|
||||
JSON.stringify(preRoute),
|
||||
|
||||
24
frontend/src/utils/prometheusDuration.ts
Normal file
24
frontend/src/utils/prometheusDuration.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// Units of Prometheus `model.Duration`, the duration grammar the API accepts.
|
||||
export const PROMETHEUS_DURATION_UNIT_MS: Record<string, number> = {
|
||||
ms: 1,
|
||||
s: 1_000,
|
||||
m: 60_000,
|
||||
h: 3_600_000,
|
||||
d: 86_400_000,
|
||||
w: 604_800_000,
|
||||
y: 31_536_000_000,
|
||||
};
|
||||
const DURATION_RE = /^(\d+(ms|s|m|h|d|w|y))+$/;
|
||||
const DURATION_TOKEN_RE = /(\d+)(ms|s|m|h|d|w|y)/g;
|
||||
|
||||
/** Milliseconds in a Prometheus duration such as `1h30m`, or null when it is not one. */
|
||||
export function prometheusDurationToMs(value: string): number | null {
|
||||
if (!DURATION_RE.test(value)) {
|
||||
return null;
|
||||
}
|
||||
let totalMs = 0;
|
||||
for (const [, amount, unit] of value.matchAll(DURATION_TOKEN_RE)) {
|
||||
totalMs += Number(amount) * PROMETHEUS_DURATION_UNIT_MS[unit];
|
||||
}
|
||||
return totalMs;
|
||||
}
|
||||
@@ -25,7 +25,7 @@ type DashboardSpec struct {
|
||||
Variables []Variable `json:"variables" required:"true" nullable:"false" description:"The dashboard variables, each a ListVariable or TextVariable envelope, referenced in queries as $name. Names must be unique. Use [] for none."`
|
||||
Panels map[string]*Panel `json:"panels" required:"true" nullable:"false" description:"The panels keyed by panel id. An id has up to 75 characters from letters, digits, dot, underscore and hyphen. A panel is shown only where a layout item references it as #/spec/panels/<id>. Use {} for none."`
|
||||
Layouts []Layout `json:"layouts" required:"true" nullable:"false" description:"The Grid layouts that place panels, at most 500. Each panel can be placed at most once. Use [] for none."`
|
||||
Duration common.DurationString `json:"duration" description:"The default time range, as a Prometheus duration such as 1h or 7d. Stored, but not applied by the UI."`
|
||||
Duration common.DurationString `json:"duration" description:"The default time range, as a Prometheus duration such as 1h or 7d. The UI opens the dashboard on it, rounded up to whole minutes, when neither the URL nor the viewer's last pick sets a time range."`
|
||||
RefreshInterval common.DurationString `json:"refreshInterval" description:"The auto refresh interval, as a Prometheus duration such as 30s. Stored, but not applied by the UI."`
|
||||
Links []Link `json:"links,omitzero" description:"External links stored with the dashboard. Not shown by the UI."`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user