Compare commits

...

7 Commits

21 changed files with 864 additions and 64 deletions

View File

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

View File

@@ -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;
/**

View File

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

View File

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

View File

@@ -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(

View File

@@ -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() }),

View File

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

View File

@@ -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]);

View File

@@ -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);
}

View File

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

View File

@@ -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' },
]);
});
});
});

View File

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

View File

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

View File

@@ -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);
});
});

View File

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

View File

@@ -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 (

View File

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

View File

@@ -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')}

View 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),

View 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;
}

View File

@@ -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."`
}