mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-07 15:10:37 +01:00
Compare commits
12 Commits
nv/dashboa
...
feat/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cd89f1091 | ||
|
|
70bc38295d | ||
|
|
d916ba7024 | ||
|
|
d24615feaf | ||
|
|
104ed55757 | ||
|
|
de2212e8b9 | ||
|
|
ff4f18985c | ||
|
|
7d555b643a | ||
|
|
7839e806fd | ||
|
|
2dcd51391d | ||
|
|
6251616d9d | ||
|
|
652a9044f4 |
@@ -155,8 +155,8 @@ querier:
|
||||
cache_ttl: 168h
|
||||
# The interval for recent data that should not be cached.
|
||||
flux_interval: 5m
|
||||
# The maximum number of concurrent queries for missing ranges.
|
||||
max_concurrent_queries: 4
|
||||
# The maximum number of queries a single query range request runs at once.
|
||||
max_concurrent_queries: 8
|
||||
# When filtering logs by trace_id, clamp the query window to the trace time
|
||||
# range with padding to include slightly delayed log exports. Logs only; set
|
||||
# to 0 to disable.
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Button } from 'antd';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
|
||||
interface CheckboxValueRowProps {
|
||||
value: string;
|
||||
@@ -46,9 +47,11 @@ function CheckboxValueRow({
|
||||
{customRendererForValue ? (
|
||||
customRendererForValue(value)
|
||||
) : (
|
||||
<Typography.Text className="value-string" truncate={1}>
|
||||
{String(value)}
|
||||
</Typography.Text>
|
||||
<TooltipSimple title={value} side="top" align="center" arrow>
|
||||
<Typography.Text className="value-string" truncate={1}>
|
||||
{String(value)}
|
||||
</Typography.Text>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
<Button type="text" className="only-btn">
|
||||
{onlyButtonLabel}
|
||||
|
||||
@@ -31,6 +31,7 @@ export enum LOCALSTORAGE {
|
||||
METRICS_LIST_OPTIONS = 'METRICS_LIST_OPTIONS',
|
||||
SHOW_EXCEPTIONS_QUICK_FILTERS = 'SHOW_EXCEPTIONS_QUICK_FILTERS',
|
||||
QUICK_FILTERS_SETTINGS_ANNOUNCEMENT = 'QUICK_FILTERS_SETTINGS_ANNOUNCEMENT',
|
||||
QUICK_FILTERS_WIDTH_LOGS = 'QUICK_FILTERS_WIDTH_LOGS',
|
||||
FUNNEL_STEPS = 'FUNNEL_STEPS',
|
||||
SPAN_DETAILS_PINNED_ATTRIBUTES = 'SPAN_DETAILS_PINNED_ATTRIBUTES',
|
||||
LAST_USED_CUSTOM_TIME_RANGES = 'LAST_USED_CUSTOM_TIME_RANGES',
|
||||
|
||||
@@ -149,6 +149,18 @@
|
||||
line-height: 22px; /* 157.143% */
|
||||
letter-spacing: -0.07px;
|
||||
padding: 20px 16px 0px 16px;
|
||||
/* Preserve author-entered line breaks in the description. */
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
a {
|
||||
color: var(--accent-primary);
|
||||
text-decoration: underline;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-variables {
|
||||
|
||||
@@ -43,6 +43,7 @@ import { sortLayout } from 'providers/Dashboard/util';
|
||||
import { DashboardData } from 'types/api/dashboard/getAll';
|
||||
import { Props } from 'types/api/dashboard/update';
|
||||
import { ROLES, USER_ROLES } from 'types/roles';
|
||||
import { linkifyText } from 'utils/linkifyText';
|
||||
import { ComponentTypes } from 'utils/permission';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
@@ -515,7 +516,9 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
</div>
|
||||
)}
|
||||
{!isEmpty(description) && (
|
||||
<section className="dashboard-description-section">{description}</section>
|
||||
<section className="dashboard-description-section">
|
||||
{linkifyText(description ?? '')}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!isEmpty(dashboardVariables) && (
|
||||
|
||||
@@ -32,6 +32,22 @@
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.descriptionTooltip {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
/* Preserve author-entered line breaks in the description. */
|
||||
white-space: pre-wrap;
|
||||
|
||||
a {
|
||||
color: var(--accent-primary);
|
||||
text-decoration: underline;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.publicLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -14,6 +14,7 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { linkifyText } from 'utils/linkifyText';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import styles from './DashboardInfo.module.scss';
|
||||
@@ -143,7 +144,14 @@ function DashboardInfo({
|
||||
)}
|
||||
|
||||
{hasDescription && (
|
||||
<TooltipSimple title={description} disableHoverableContent>
|
||||
<TooltipSimple
|
||||
side="bottom"
|
||||
title={
|
||||
<span className={styles.descriptionTooltip}>
|
||||
{linkifyText(description)}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<SolidInfoCircle
|
||||
className={styles.descriptionIcon}
|
||||
size={14}
|
||||
|
||||
@@ -48,6 +48,13 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.footerStatus {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.validation {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -57,6 +64,32 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.danglingWarning {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--bg-amber-400);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.warningIcon {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.warningText {
|
||||
overflow: hidden;
|
||||
color: var(--bg-amber-400);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// Above the JSON drawer (antd Drawer sits at ~1100) so the id list is visible.
|
||||
.warningTooltip {
|
||||
z-index: 1101;
|
||||
}
|
||||
|
||||
.validationValid {
|
||||
color: var(--bg-forest-400);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { KeyboardEvent, useCallback } from 'react';
|
||||
import MEditor from '@monaco-editor/react';
|
||||
import { TriangleAlert } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import { Drawer } from 'antd';
|
||||
@@ -26,8 +28,18 @@ function JsonEditorDrawer({
|
||||
}: JsonEditorDrawerProps): JSX.Element {
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const { draft, setDraft, validity, isDirty, isSaving, format, reset, apply } =
|
||||
useJsonEditor({ dashboard, isOpen, onApplied: onClose });
|
||||
const {
|
||||
draft,
|
||||
setDraft,
|
||||
validity,
|
||||
isDirty,
|
||||
isSaving,
|
||||
danglingPanelIds,
|
||||
missingPanelRefs,
|
||||
format,
|
||||
reset,
|
||||
apply,
|
||||
} = useJsonEditor({ dashboard, isOpen, onApplied: onClose });
|
||||
|
||||
const onCopy = useCallback((): void => {
|
||||
copyToClipboard(draft);
|
||||
@@ -48,6 +60,7 @@ function JsonEditorDrawer({
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
event.stopPropagation();
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void apply();
|
||||
@@ -60,6 +73,19 @@ function JsonEditorDrawer({
|
||||
const validationText = validity.valid
|
||||
? `Valid JSON · ${validity.lineCount} lines`
|
||||
: `Line ${validity.errorLine ?? '?'} · ${validity.message ?? 'Invalid JSON'}`;
|
||||
const plural = (n: number): string => (n === 1 ? '' : 's');
|
||||
const danglingWarning =
|
||||
danglingPanelIds.length > 0
|
||||
? `${danglingPanelIds.length} panel${plural(
|
||||
danglingPanelIds.length,
|
||||
)} not present in layout — they won't be shown after saving.`
|
||||
: null;
|
||||
const missingRefWarning =
|
||||
missingPanelRefs.length > 0
|
||||
? `${missingPanelRefs.length} layout item${plural(
|
||||
missingPanelRefs.length,
|
||||
)} ${missingPanelRefs.length === 1 ? 'references' : 'reference'} a panel that no longer exists.`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
@@ -71,15 +97,49 @@ function JsonEditorDrawer({
|
||||
rootClassName={styles.root}
|
||||
footer={
|
||||
<div className={styles.footer}>
|
||||
<Typography.Text
|
||||
className={cx(styles.validation, {
|
||||
[styles.validationValid]: validity.valid,
|
||||
[styles.validationInvalid]: !validity.valid,
|
||||
})}
|
||||
data-testid="json-editor-validation"
|
||||
>
|
||||
{validationText}
|
||||
</Typography.Text>
|
||||
<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>
|
||||
{danglingWarning && (
|
||||
<TooltipSimple
|
||||
title={danglingPanelIds.join(', ')}
|
||||
tooltipContentProps={{ className: styles.warningTooltip }}
|
||||
>
|
||||
<span
|
||||
className={styles.danglingWarning}
|
||||
data-testid="json-editor-dangling-warning"
|
||||
>
|
||||
<TriangleAlert size={12} className={styles.warningIcon} />
|
||||
<Typography.Text className={styles.warningText}>
|
||||
{danglingWarning}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
{missingRefWarning && (
|
||||
<TooltipSimple
|
||||
title={missingPanelRefs.join(', ')}
|
||||
tooltipContentProps={{ className: styles.warningTooltip }}
|
||||
>
|
||||
<span
|
||||
className={styles.danglingWarning}
|
||||
data-testid="json-editor-missing-ref-warning"
|
||||
>
|
||||
<TriangleAlert size={12} className={styles.warningIcon} />
|
||||
<Typography.Text className={styles.warningText}>
|
||||
{missingRefWarning}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.footerActions}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import JsonEditorDrawer from '../JsonEditorDrawer';
|
||||
@@ -48,11 +49,13 @@ function hookValue(
|
||||
validity: { valid: true, lineCount: 3 },
|
||||
isDirty: true,
|
||||
isSaving: false,
|
||||
danglingPanelIds: [],
|
||||
missingPanelRefs: [],
|
||||
format: jest.fn(),
|
||||
reset: jest.fn(),
|
||||
apply: jest.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
};
|
||||
} as ReturnType<typeof useJsonEditor>;
|
||||
}
|
||||
|
||||
describe('JsonEditorDrawer', () => {
|
||||
@@ -81,6 +84,42 @@ describe('JsonEditorDrawer', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('warns about dangling panels, and hides the warning when there are none', () => {
|
||||
mockUseJsonEditor.mockReturnValue(
|
||||
hookValue({ danglingPanelIds: ['p1', 'p2'] }),
|
||||
);
|
||||
const { rerender } = render(
|
||||
<TooltipProvider>
|
||||
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.getByTestId('json-editor-dangling-warning')).toHaveTextContent(
|
||||
'2 panels not present in layout',
|
||||
);
|
||||
|
||||
mockUseJsonEditor.mockReturnValue(hookValue({ danglingPanelIds: [] }));
|
||||
rerender(
|
||||
<TooltipProvider>
|
||||
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByTestId('json-editor-dangling-warning'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('warns about layout refs to missing panels', () => {
|
||||
mockUseJsonEditor.mockReturnValue(hookValue({ missingPanelRefs: ['ghost'] }));
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId('json-editor-missing-ref-warning'),
|
||||
).toHaveTextContent('1 layout item references a panel that no longer exists');
|
||||
});
|
||||
|
||||
it('shows the error line and message when invalid', () => {
|
||||
mockUseJsonEditor.mockReturnValue(
|
||||
hookValue({
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { DashboardtypesDashboardSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { findPanelLayoutIssues } from '../danglingPanels';
|
||||
|
||||
const grid = (refs: string[]): unknown => ({
|
||||
kind: 'Grid',
|
||||
spec: { items: refs.map((r) => ({ content: { $ref: r } })) },
|
||||
});
|
||||
|
||||
// Cast a loose fixture to the spec type — the helper is defensive against the
|
||||
// untrusted, hand-edited JSON it runs on.
|
||||
const spec = (value: unknown): DashboardtypesDashboardSpecDTO =>
|
||||
value as DashboardtypesDashboardSpecDTO;
|
||||
|
||||
describe('findPanelLayoutIssues', () => {
|
||||
it('flags nothing when panels and layouts agree', () => {
|
||||
expect(
|
||||
findPanelLayoutIssues(
|
||||
spec({
|
||||
panels: { a: {}, b: {} },
|
||||
layouts: [grid(['#/spec/panels/a', '#/spec/panels/b'])],
|
||||
}),
|
||||
),
|
||||
).toStrictEqual({ danglingPanelIds: [], missingPanelRefs: [] });
|
||||
});
|
||||
|
||||
it('lists panels placed in no layout as dangling', () => {
|
||||
const result = findPanelLayoutIssues(
|
||||
spec({
|
||||
panels: { a: {}, b: {}, c: {} },
|
||||
layouts: [grid(['#/spec/panels/a'])],
|
||||
}),
|
||||
);
|
||||
expect(result.danglingPanelIds.sort()).toStrictEqual(['b', 'c']);
|
||||
expect(result.missingPanelRefs).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('treats a removed/empty layout as orphaning every panel', () => {
|
||||
expect(
|
||||
findPanelLayoutIssues(
|
||||
spec({ panels: { a: {}, b: {} }, layouts: [] }),
|
||||
).danglingPanelIds.sort(),
|
||||
).toStrictEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('lists layout refs to a panel that no longer exists as missing', () => {
|
||||
const result = findPanelLayoutIssues(
|
||||
spec({
|
||||
panels: { a: {} },
|
||||
layouts: [grid(['#/spec/panels/a', '#/spec/panels/ghost'])],
|
||||
}),
|
||||
);
|
||||
expect(result.danglingPanelIds).toStrictEqual([]);
|
||||
expect(result.missingPanelRefs).toStrictEqual(['ghost']);
|
||||
});
|
||||
|
||||
it('handles empty / malformed specs', () => {
|
||||
expect(
|
||||
findPanelLayoutIssues(spec({ panels: {}, layouts: [] })),
|
||||
).toStrictEqual({
|
||||
danglingPanelIds: [],
|
||||
missingPanelRefs: [],
|
||||
});
|
||||
expect(findPanelLayoutIssues(undefined)).toStrictEqual({
|
||||
danglingPanelIds: [],
|
||||
missingPanelRefs: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -203,4 +203,47 @@ describe('useJsonEditor', () => {
|
||||
rerender({ isOpen: true });
|
||||
expect(result.current.draft).toBe(serialized);
|
||||
});
|
||||
|
||||
it('reports panels not placed in any layout as dangling', () => {
|
||||
const withDangling = {
|
||||
...dashboard,
|
||||
spec: { ...dashboard.spec, panels: { p1: {} }, layouts: [] },
|
||||
} as unknown as DashboardtypesGettableDashboardV2DTO;
|
||||
const { result } = renderHook(() =>
|
||||
useJsonEditor({
|
||||
dashboard: withDangling,
|
||||
isOpen: true,
|
||||
onApplied: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.danglingPanelIds).toStrictEqual(['p1']);
|
||||
expect(result.current.missingPanelRefs).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('reports layout refs to panels that no longer exist as missing', () => {
|
||||
const withMissing = {
|
||||
...dashboard,
|
||||
spec: {
|
||||
...dashboard.spec,
|
||||
panels: {},
|
||||
layouts: [
|
||||
{
|
||||
kind: 'Grid',
|
||||
spec: { items: [{ content: { $ref: '#/spec/panels/ghost' } }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as DashboardtypesGettableDashboardV2DTO;
|
||||
const { result } = renderHook(() =>
|
||||
useJsonEditor({
|
||||
dashboard: withMissing,
|
||||
isOpen: true,
|
||||
onApplied: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.missingPanelRefs).toStrictEqual(['ghost']);
|
||||
expect(result.current.danglingPanelIds).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type {
|
||||
DashboardtypesDashboardSpecDTO,
|
||||
DashboardtypesLayoutDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { extractPanelIdFromRef } from '../../utils';
|
||||
|
||||
export interface PanelLayoutIssues {
|
||||
// Panels defined in `spec.panels` that no layout places — they render nowhere.
|
||||
danglingPanelIds: string[];
|
||||
// Panel ids a layout item references that no longer exist in `spec.panels`.
|
||||
missingPanelRefs: string[];
|
||||
}
|
||||
|
||||
const referencedPanelIds = (
|
||||
layouts: DashboardtypesLayoutDTO[],
|
||||
): Set<string> => {
|
||||
const referenced = new Set<string>();
|
||||
layouts.forEach((layout) => {
|
||||
if (layout?.kind !== 'Grid') {
|
||||
return;
|
||||
}
|
||||
(layout.spec?.items ?? []).forEach((item) => {
|
||||
const id = extractPanelIdFromRef(item?.content?.$ref);
|
||||
if (id) {
|
||||
referenced.add(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
return referenced;
|
||||
};
|
||||
|
||||
// The two ways a hand-edited spec can desync panels and layouts: a panel with no
|
||||
// layout slot (renders nowhere), or a layout slot pointing at a panel that was
|
||||
// removed (broken reference). Guarded for untrusted, user-edited JSON.
|
||||
export function findPanelLayoutIssues(
|
||||
spec: DashboardtypesDashboardSpecDTO | undefined,
|
||||
): PanelLayoutIssues {
|
||||
const panels = spec?.panels ?? {};
|
||||
const panelIds = Object.keys(panels);
|
||||
const referenced = referencedPanelIds(spec?.layouts ?? []);
|
||||
|
||||
return {
|
||||
danglingPanelIds: panelIds.filter((id) => !referenced.has(id)),
|
||||
missingPanelRefs: [...referenced].filter((id) => !(id in panels)),
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { updateDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardtypesDashboardSpecDTO,
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import { dashboardToUpdatable } from './dashboardToUpdatable';
|
||||
import { findPanelLayoutIssues } from './danglingPanels';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
|
||||
export interface JsonValidity {
|
||||
@@ -28,6 +32,10 @@ interface Result {
|
||||
validity: JsonValidity;
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
// Panel ids in the draft's `spec.panels` referenced by no layout — orphaned.
|
||||
danglingPanelIds: string[];
|
||||
// Panel ids a layout references that are missing from the draft's `spec.panels`.
|
||||
missingPanelRefs: string[];
|
||||
format: () => void;
|
||||
reset: () => void;
|
||||
apply: () => Promise<void>;
|
||||
@@ -109,6 +117,23 @@ export function useJsonEditor({
|
||||
|
||||
const isDirty = draft !== appliedText;
|
||||
|
||||
const { danglingPanelIds, missingPanelRefs } = useMemo<{
|
||||
danglingPanelIds: string[];
|
||||
missingPanelRefs: string[];
|
||||
}>(() => {
|
||||
if (!validity.valid) {
|
||||
return { danglingPanelIds: [], missingPanelRefs: [] };
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(draft) as {
|
||||
spec?: DashboardtypesDashboardSpecDTO;
|
||||
};
|
||||
return findPanelLayoutIssues(parsed.spec);
|
||||
} catch {
|
||||
return { danglingPanelIds: [], missingPanelRefs: [] };
|
||||
}
|
||||
}, [draft, validity.valid]);
|
||||
|
||||
const format = useCallback((): void => {
|
||||
try {
|
||||
setDraft(JSON.stringify(JSON.parse(draft), null, 2));
|
||||
@@ -138,7 +163,7 @@ export function useJsonEditor({
|
||||
refetch();
|
||||
onApplied();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
showErrorModal(toAPIError(error as Parameters<typeof toAPIError>[0]));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -159,6 +184,8 @@ export function useJsonEditor({
|
||||
validity,
|
||||
isDirty,
|
||||
isSaving,
|
||||
danglingPanelIds,
|
||||
missingPanelRefs,
|
||||
format,
|
||||
reset,
|
||||
apply,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { DashboardtypesPatchOpDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
@@ -54,20 +55,32 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
|
||||
const buildPatch = useCallback((): DashboardtypesJSONPatchOperationDTO[] => {
|
||||
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
|
||||
const op = (
|
||||
operation: DashboardtypesJSONPatchOperationDTO['op'],
|
||||
path: string,
|
||||
value: unknown,
|
||||
): DashboardtypesJSONPatchOperationDTO => ({ op: operation, path, value });
|
||||
const replace = (
|
||||
path: string,
|
||||
value: unknown,
|
||||
): DashboardtypesJSONPatchOperationDTO => ({
|
||||
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
|
||||
path,
|
||||
value,
|
||||
});
|
||||
): DashboardtypesJSONPatchOperationDTO =>
|
||||
op(DashboardtypesPatchOpDTO.replace, path, value);
|
||||
|
||||
if (updatedTitle !== title && updatedTitle !== '') {
|
||||
ops.push(replace('/spec/display/name', updatedTitle));
|
||||
}
|
||||
if (updatedDescription !== description) {
|
||||
ops.push(replace('/spec/display/description', updatedDescription));
|
||||
// `replace` fails when the description doesn't exist yet, so add it when
|
||||
// the current one is empty (`add` creates or replaces the member).
|
||||
ops.push(
|
||||
op(
|
||||
description
|
||||
? DashboardtypesPatchOpDTO.replace
|
||||
: DashboardtypesPatchOpDTO.add,
|
||||
'/spec/display/description',
|
||||
updatedDescription,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (updatedImage !== image) {
|
||||
ops.push(replace('/image', updatedImage));
|
||||
|
||||
@@ -46,8 +46,12 @@ export interface UseVariableForm {
|
||||
handleSave: () => void;
|
||||
}
|
||||
|
||||
const readDefaultValue = (model: VariableFormModel): string =>
|
||||
((model.defaultValue as { value?: string })?.value ?? '') as string;
|
||||
// `defaultValue` is a string | string[] on the wire; the editor uses a single
|
||||
// string, so take the first when it's an array.
|
||||
const readDefaultValue = (model: VariableFormModel): string => {
|
||||
const dv = model.defaultValue;
|
||||
return Array.isArray(dv) ? (dv[0] ?? '') : (dv ?? '');
|
||||
};
|
||||
|
||||
/** Form state, derivations and handlers for the variable editor. */
|
||||
export function useVariableForm({
|
||||
|
||||
@@ -18,22 +18,22 @@ interface VariableSelectorProps {
|
||||
variable: VariableFormModel;
|
||||
/** All variables (Dynamic uses them to scope options by sibling selections). */
|
||||
variables: VariableFormModel[];
|
||||
/** Names this variable depends on (for Query gating). */
|
||||
parents: string[];
|
||||
/** All current selections (Query passes them as the request payload). */
|
||||
selections: VariableSelectionMap;
|
||||
selection: VariableSelection;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
/** Batched fill applied when options resolve (Query/Dynamic auto-selection). */
|
||||
onAutoSelect: (selection: VariableSelection) => void;
|
||||
}
|
||||
|
||||
/** One labelled variable control; dispatches on the variable type. */
|
||||
function VariableSelector({
|
||||
variable,
|
||||
variables,
|
||||
parents,
|
||||
selections,
|
||||
selection,
|
||||
onChange,
|
||||
onAutoSelect,
|
||||
}: VariableSelectorProps): JSX.Element {
|
||||
const customOptions = useMemo(
|
||||
() =>
|
||||
@@ -61,10 +61,10 @@ function VariableSelector({
|
||||
return (
|
||||
<QuerySelector
|
||||
variable={variable}
|
||||
parents={parents}
|
||||
selections={selections}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
onAutoSelect={onAutoSelect}
|
||||
/>
|
||||
);
|
||||
case 'DYNAMIC':
|
||||
@@ -75,6 +75,7 @@ function VariableSelector({
|
||||
selections={selections}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
onAutoSelect={onAutoSelect}
|
||||
/>
|
||||
);
|
||||
case 'CUSTOM':
|
||||
|
||||
@@ -112,3 +112,13 @@
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.overflowTooltip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.overflowName {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,33 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronLeft } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import cx from 'classnames';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useInlineOverflowCount } from 'hooks/useInlineOverflowCount';
|
||||
|
||||
import type { VariableSelection } from './selectionTypes';
|
||||
import { useVariableSelection } from './useVariableSelection';
|
||||
import VariableSelector from './VariableSelector';
|
||||
import styles from './VariablesBar.module.scss';
|
||||
|
||||
// Short display of a variable's current selection, for the collapsed +N tooltip.
|
||||
function formatSelection(selection: VariableSelection | undefined): string {
|
||||
if (!selection) {
|
||||
return '—';
|
||||
}
|
||||
if (selection.allSelected) {
|
||||
return 'ALL';
|
||||
}
|
||||
const { value } = selection;
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0 ? value.join(', ') : '—';
|
||||
}
|
||||
return value === '' || value === null || value === undefined
|
||||
? '—'
|
||||
: String(value);
|
||||
}
|
||||
|
||||
interface VariablesBarProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
}
|
||||
@@ -23,7 +42,7 @@ interface VariablesBarProps {
|
||||
* either way so auto-selection and option fetching keep driving the panels.
|
||||
*/
|
||||
function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
const { variables, dependencyData, selection, setSelection } =
|
||||
const { variables, selection, setSelection, autoSelect } =
|
||||
useVariableSelection(dashboard);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const { containerRef, visibleCount, overflowCount } = useInlineOverflowCount({
|
||||
@@ -38,6 +57,22 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
}
|
||||
|
||||
const hasOverflow = overflowCount > 0;
|
||||
const hiddenVariables =
|
||||
!expanded && hasOverflow ? variables.slice(visibleCount) : [];
|
||||
|
||||
const moreButton = (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
prefix={expanded ? <ChevronLeft size={14} /> : undefined}
|
||||
aria-expanded={expanded}
|
||||
testId="dashboard-variables-more"
|
||||
onClick={(): void => setExpanded((prev) => !prev)}
|
||||
>
|
||||
{expanded ? 'Less' : `+${overflowCount}`}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.bar} data-testid="dashboard-variables-bar">
|
||||
@@ -57,7 +92,6 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
<VariableSelector
|
||||
variable={variable}
|
||||
variables={variables}
|
||||
parents={dependencyData.parentGraph[variable.name] ?? []}
|
||||
selections={selection}
|
||||
selection={
|
||||
selection[variable.name] ?? {
|
||||
@@ -66,23 +100,32 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
}
|
||||
}
|
||||
onChange={(next): void => setSelection(variable.name, next)}
|
||||
onAutoSelect={(next): void => autoSelect(variable.name, next)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{hasOverflow && (
|
||||
<span className={styles.moreButton}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
prefix={expanded ? <ChevronLeft size={14} /> : undefined}
|
||||
aria-expanded={expanded}
|
||||
testId="dashboard-variables-more"
|
||||
onClick={(): void => setExpanded((prev) => !prev)}
|
||||
>
|
||||
{expanded ? 'Less' : `+${overflowCount}`}
|
||||
</Button>
|
||||
{expanded ? (
|
||||
moreButton
|
||||
) : (
|
||||
<TooltipSimple
|
||||
side="top"
|
||||
title={
|
||||
<div className={styles.overflowTooltip}>
|
||||
{hiddenVariables.map((variable) => (
|
||||
<div key={variable.name}>
|
||||
<span className={styles.overflowName}>{variable.name}</span>:{' '}
|
||||
{formatSelection(selection[variable.name])}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{moreButton}
|
||||
</TooltipSimple>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
|
||||
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
|
||||
import type { AppState } from 'store/reducers';
|
||||
import type { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
@@ -10,12 +11,14 @@ import {
|
||||
sortValuesByOrder,
|
||||
} from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { buildExistingDynamicVariableQuery } from '../dynamicFilter';
|
||||
import type {
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from '../selectionTypes';
|
||||
import { useAutoSelect } from '../useAutoSelect';
|
||||
import { useVariableFetchState } from '../useVariableFetchState';
|
||||
import ValueSelector from './ValueSelector';
|
||||
|
||||
interface DynamicSelectorProps {
|
||||
@@ -25,12 +28,16 @@ interface DynamicSelectorProps {
|
||||
selections: VariableSelectionMap;
|
||||
selection: VariableSelection;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
/** Batched auto-selection fill applied when options resolve. */
|
||||
onAutoSelect: (selection: VariableSelection) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic-variable options sourced from live telemetry field values for the
|
||||
* chosen signal + attribute, scoped by the other dynamic variables' selections
|
||||
* (so e.g. `pod` narrows to the chosen `namespace`).
|
||||
* (so e.g. `pod` narrows to the chosen `namespace`). WHEN to fetch is owned by
|
||||
* the runtime fetch engine: dynamics fetch together once the query variables have
|
||||
* values, and refetch (via a `cycleId` bump) whenever any variable value changes.
|
||||
*/
|
||||
function DynamicSelector({
|
||||
variable,
|
||||
@@ -38,6 +45,7 @@ function DynamicSelector({
|
||||
selections,
|
||||
selection,
|
||||
onChange,
|
||||
onAutoSelect,
|
||||
}: DynamicSelectorProps): JSX.Element {
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
@@ -48,14 +56,51 @@ function DynamicSelector({
|
||||
[variables, selections, variable.name],
|
||||
);
|
||||
|
||||
const { data, isFetching } = useGetFieldValues({
|
||||
signal: signalForApi(variable.dynamicSignal),
|
||||
name: variable.dynamicAttribute,
|
||||
startUnixMilli: minTime,
|
||||
endUnixMilli: maxTime,
|
||||
existingQuery: existingQuery || undefined,
|
||||
enabled: !!variable.dynamicAttribute,
|
||||
});
|
||||
const {
|
||||
variableFetchCycleId,
|
||||
isVariableFetching,
|
||||
isVariableSettled,
|
||||
isVariableWaiting,
|
||||
hasVariableFetchedOnce,
|
||||
} = useVariableFetchState(variable.name);
|
||||
const onVariableFetchComplete = useDashboardStore(
|
||||
(s) => s.onVariableFetchComplete,
|
||||
);
|
||||
const onVariableFetchFailure = useDashboardStore(
|
||||
(s) => s.onVariableFetchFailure,
|
||||
);
|
||||
|
||||
const { data, isFetching } = useQuery(
|
||||
[
|
||||
'dashboard-variable-dynamic',
|
||||
variable.name,
|
||||
variable.dynamicSignal,
|
||||
variable.dynamicAttribute,
|
||||
existingQuery,
|
||||
minTime,
|
||||
maxTime,
|
||||
variableFetchCycleId,
|
||||
],
|
||||
() =>
|
||||
getFieldValues(
|
||||
signalForApi(variable.dynamicSignal),
|
||||
variable.dynamicAttribute,
|
||||
undefined,
|
||||
minTime,
|
||||
maxTime,
|
||||
existingQuery || undefined,
|
||||
),
|
||||
{
|
||||
enabled:
|
||||
!!variable.dynamicAttribute &&
|
||||
(isVariableFetching || (isVariableSettled && hasVariableFetchedOnce)),
|
||||
refetchOnWindowFocus: false,
|
||||
onSettled: (_, error) =>
|
||||
error
|
||||
? onVariableFetchFailure(variable.name)
|
||||
: onVariableFetchComplete(variable.name),
|
||||
},
|
||||
);
|
||||
|
||||
const options = useMemo(() => {
|
||||
const payload = data?.data;
|
||||
@@ -64,14 +109,14 @@ function DynamicSelector({
|
||||
return sortValuesByOrder(values, variable.sort).map(String);
|
||||
}, [data, variable.sort]);
|
||||
|
||||
useAutoSelect(variable, options, selection, onChange);
|
||||
useAutoSelect(variable, options, selection, onAutoSelect);
|
||||
|
||||
return (
|
||||
<ValueSelector
|
||||
options={options}
|
||||
multiSelect={variable.multiSelect}
|
||||
showAllOption={variable.showAllOption}
|
||||
loading={isFetching}
|
||||
loading={isFetching || isVariableWaiting}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
testId={`variable-select-${variable.name}`}
|
||||
|
||||
@@ -8,58 +8,82 @@ import type { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { sortValuesByOrder } from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import type {
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from '../selectionTypes';
|
||||
import { isResolved, selectionToPayload } from '../selectionUtils';
|
||||
import { selectionToPayload } from '../selectionUtils';
|
||||
import { useAutoSelect } from '../useAutoSelect';
|
||||
import { useVariableFetchState } from '../useVariableFetchState';
|
||||
import ValueSelector from './ValueSelector';
|
||||
|
||||
interface QuerySelectorProps {
|
||||
variable: VariableFormModel;
|
||||
/** Names this variable's query references; it waits until they're resolved. */
|
||||
parents: string[];
|
||||
/** All current selections, fed to the query as `{ name: value }`. */
|
||||
selections: VariableSelectionMap;
|
||||
selection: VariableSelection;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
/** Batched auto-selection fill applied when options resolve. */
|
||||
onAutoSelect: (selection: VariableSelection) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query-driven options. Dependency orchestration is declarative: the query is
|
||||
* `enabled` only once every parent is resolved, and the parent values are in the
|
||||
* query key — so it refetches automatically when a parent changes (and a cyclic
|
||||
* dependency is simply never enabled).
|
||||
* Query-driven options. WHEN to fetch is owned by the runtime fetch engine
|
||||
* (`variableFetchSlice`): the query is `enabled` while this variable is fetching
|
||||
* (or settled-after-a-first-fetch, so a cycle bump re-runs it), and the engine's
|
||||
* per-variable `cycleId` keys the request — so a parent's value change refetches
|
||||
* only the dependent variables, in dependency order. The current selections feed
|
||||
* the request payload but are deliberately NOT in the key (V1 parity).
|
||||
*/
|
||||
function QuerySelector({
|
||||
variable,
|
||||
parents,
|
||||
selections,
|
||||
selection,
|
||||
onChange,
|
||||
onAutoSelect,
|
||||
}: QuerySelectorProps): JSX.Element {
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
const payload = useMemo(() => selectionToPayload(selections), [selections]);
|
||||
const enabled = parents.every((parent) => isResolved(selections[parent]));
|
||||
|
||||
const {
|
||||
variableFetchCycleId,
|
||||
isVariableFetching,
|
||||
isVariableSettled,
|
||||
isVariableWaiting,
|
||||
hasVariableFetchedOnce,
|
||||
} = useVariableFetchState(variable.name);
|
||||
const onVariableFetchComplete = useDashboardStore(
|
||||
(s) => s.onVariableFetchComplete,
|
||||
);
|
||||
const onVariableFetchFailure = useDashboardStore(
|
||||
(s) => s.onVariableFetchFailure,
|
||||
);
|
||||
|
||||
const { data, isFetching } = useQuery(
|
||||
[
|
||||
'dashboard-variable',
|
||||
variable.name,
|
||||
variable.queryValue,
|
||||
payload,
|
||||
minTime,
|
||||
maxTime,
|
||||
variableFetchCycleId,
|
||||
],
|
||||
() =>
|
||||
dashboardVariablesQuery({
|
||||
query: variable.queryValue,
|
||||
variables: payload,
|
||||
}),
|
||||
{ enabled, refetchOnWindowFocus: false },
|
||||
{
|
||||
enabled: isVariableFetching || (isVariableSettled && hasVariableFetchedOnce),
|
||||
refetchOnWindowFocus: false,
|
||||
onSettled: (_, error) =>
|
||||
error
|
||||
? onVariableFetchFailure(variable.name)
|
||||
: onVariableFetchComplete(variable.name),
|
||||
},
|
||||
);
|
||||
|
||||
const options = useMemo(() => {
|
||||
@@ -72,14 +96,14 @@ function QuerySelector({
|
||||
).map(String);
|
||||
}, [data, variable.sort]);
|
||||
|
||||
useAutoSelect(variable, options, selection, onChange);
|
||||
useAutoSelect(variable, options, selection, onAutoSelect);
|
||||
|
||||
return (
|
||||
<ValueSelector
|
||||
options={options}
|
||||
multiSelect={variable.multiSelect}
|
||||
showAllOption={variable.showAllOption}
|
||||
loading={isFetching}
|
||||
loading={isFetching || isVariableWaiting}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
testId={`variable-select-${variable.name}`}
|
||||
|
||||
@@ -12,7 +12,7 @@ export function useAutoSelect(
|
||||
variable: VariableFormModel,
|
||||
options: string[],
|
||||
selection: VariableSelection,
|
||||
onChange: (selection: VariableSelection) => void,
|
||||
onAutoSelect: (selection: VariableSelection) => void,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (options.length === 0 || selection.allSelected) {
|
||||
@@ -28,11 +28,11 @@ export function useAutoSelect(
|
||||
if (isValid) {
|
||||
return;
|
||||
}
|
||||
const fallback = (variable.defaultValue as { value?: string } | undefined)
|
||||
?.value;
|
||||
const dv = variable.defaultValue;
|
||||
const fallback = Array.isArray(dv) ? dv[0] : dv;
|
||||
const initial =
|
||||
fallback && options.includes(fallback) ? fallback : options[0];
|
||||
onChange({
|
||||
onAutoSelect({
|
||||
value: variable.multiSelect ? [initial] : initial,
|
||||
allSelected: false,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
selectVariableCycleId,
|
||||
selectVariableFetchedOnce,
|
||||
selectVariableFetchState,
|
||||
type VariableFetchState,
|
||||
} from '../store/slices/variableFetchSlice';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
|
||||
export interface VariableFetchStateResult {
|
||||
variableFetchState: VariableFetchState;
|
||||
/** Include in the selector's react-query key to auto-cancel stale requests. */
|
||||
variableFetchCycleId: number;
|
||||
/** Actively fetching (first load or revalidating). */
|
||||
isVariableFetching: boolean;
|
||||
/** Stable — the fetch completed (or errored). */
|
||||
isVariableSettled: boolean;
|
||||
/** Blocked on parent dependencies (query order) or query variables (dynamics). */
|
||||
isVariableWaiting: boolean;
|
||||
/** Completed at least one fetch — keeps the query subscribed so a cycle bump refetches. */
|
||||
hasVariableFetchedOnce: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-variable view of the runtime fetch engine (`variableFetchSlice`), consumed
|
||||
* by the Query/Dynamic selectors to gate their fetch and key it by cycle id.
|
||||
* V2-native equivalent of V1's `useVariableFetchState`.
|
||||
*/
|
||||
export function useVariableFetchState(name: string): VariableFetchStateResult {
|
||||
const variableFetchState = useDashboardStore(selectVariableFetchState(name));
|
||||
const variableFetchCycleId = useDashboardStore(selectVariableCycleId(name));
|
||||
const hasVariableFetchedOnce = useDashboardStore(
|
||||
selectVariableFetchedOnce(name),
|
||||
);
|
||||
|
||||
return {
|
||||
variableFetchState,
|
||||
variableFetchCycleId,
|
||||
isVariableFetching:
|
||||
variableFetchState === 'loading' || variableFetchState === 'revalidating',
|
||||
isVariableSettled: variableFetchState === 'idle',
|
||||
isVariableWaiting: variableFetchState === 'waiting',
|
||||
hasVariableFetchedOnce,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { parseAsJson, useQueryState } from 'nuqs';
|
||||
// eslint-disable-next-line no-restricted-imports -- global time selector still on redux
|
||||
import { useSelector } from 'react-redux';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { AppState } from 'store/reducers';
|
||||
import type { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { dtoToFormModel } from '../DashboardSettings/Variables/variableAdapters';
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
|
||||
@@ -12,8 +16,8 @@ import type {
|
||||
VariableSelectionMap,
|
||||
} from './selectionTypes';
|
||||
import {
|
||||
computeVariableDependencies,
|
||||
type VariableDependencyData,
|
||||
deriveFetchContext,
|
||||
doAllQueryVariablesHaveValues,
|
||||
} from './variableDependencies';
|
||||
|
||||
/** URL sentinel for an "ALL values selected" state (matches V1). */
|
||||
@@ -29,12 +33,14 @@ export const variablesUrlParser = parseAsJson<
|
||||
);
|
||||
|
||||
function defaultSelection(model: VariableFormModel): VariableSelection {
|
||||
const def = (
|
||||
model.defaultValue as { value?: SelectedVariableValue } | undefined
|
||||
)?.value;
|
||||
if (def !== undefined && def !== null && def !== '') {
|
||||
// `defaultValue` is a string | string[] on the wire.
|
||||
const def = model.defaultValue;
|
||||
if (Array.isArray(def) && def.length > 0) {
|
||||
return { value: def, allSelected: false };
|
||||
}
|
||||
if (typeof def === 'string' && def !== '') {
|
||||
return { value: model.multiSelect ? [def] : def, allSelected: false };
|
||||
}
|
||||
return { value: model.multiSelect ? [] : '', allSelected: false };
|
||||
}
|
||||
|
||||
@@ -46,9 +52,14 @@ function fromUrlValue(raw: SelectedVariableValue): VariableSelection {
|
||||
|
||||
interface UseVariableSelection {
|
||||
variables: VariableFormModel[];
|
||||
dependencyData: VariableDependencyData;
|
||||
selection: VariableSelectionMap;
|
||||
setSelection: (name: string, selection: VariableSelection) => void;
|
||||
/**
|
||||
* Auto-selection fill (default/first-option) applied when options arrive. Unlike
|
||||
* {@link UseVariableSelection.setSelection}, fills from the initial load burst are
|
||||
* coalesced into one store write + one downstream refresh.
|
||||
*/
|
||||
autoSelect: (name: string, selection: VariableSelection) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,27 +76,37 @@ export function useVariableSelection(
|
||||
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
|
||||
[dashboard.spec?.variables],
|
||||
);
|
||||
const dependencyData = useMemo(
|
||||
() => computeVariableDependencies(variables),
|
||||
[variables],
|
||||
);
|
||||
const fetchContext = useMemo(() => deriveFetchContext(variables), [variables]);
|
||||
|
||||
const selection = useDashboardStore(selectVariableValues(dashboardId));
|
||||
const setVariableValue = useDashboardStore((s) => s.setVariableValue);
|
||||
const setVariableValues = useDashboardStore((s) => s.setVariableValues);
|
||||
const initVariableFetch = useDashboardStore((s) => s.initVariableFetch);
|
||||
const enqueueFetchAll = useDashboardStore((s) => s.enqueueFetchAll);
|
||||
const enqueueDescendants = useDashboardStore((s) => s.enqueueDescendants);
|
||||
const enqueueDescendantsBatch = useDashboardStore(
|
||||
(s) => s.enqueueDescendantsBatch,
|
||||
);
|
||||
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
// Latest selection, read by the fetch-cycle effect without subscribing to it
|
||||
// (so a value change doesn't re-trigger a full fetch cycle).
|
||||
const selectionRef = useRef(selection);
|
||||
selectionRef.current = selection;
|
||||
|
||||
const [urlValues, setUrlValues] = useQueryState(
|
||||
'variables',
|
||||
variablesUrlParser.withOptions({ history: 'replace' }),
|
||||
);
|
||||
|
||||
// Seed selections for this dashboard: URL wins, then persisted store, then default.
|
||||
// Seed selections: URL wins, then persisted store, then default.
|
||||
useEffect(() => {
|
||||
if (!dashboardId || variables.length === 0) {
|
||||
return;
|
||||
}
|
||||
// `selection` here is the persisted (localStorage) map on mount — the
|
||||
// effect deliberately doesn't depend on it, so seeding runs once per set.
|
||||
const stored = selection;
|
||||
const seeded: VariableSelectionMap = {};
|
||||
variables.forEach((variable) => {
|
||||
@@ -102,16 +123,83 @@ export function useVariableSelection(
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dashboardId, variables]);
|
||||
|
||||
// Start a full fetch cycle on load / dependency-order / time change. Runs after
|
||||
// the seeding effect above, so it reads the seeded selection from the store; a
|
||||
// value change instead goes through `enqueueDescendants`, not this effect.
|
||||
const orderKey = `${fetchContext.queryVariableOrder.join(
|
||||
',',
|
||||
)}|${fetchContext.dynamicVariableOrder.join(',')}`;
|
||||
useEffect(() => {
|
||||
if (!dashboardId || variables.length === 0) {
|
||||
return;
|
||||
}
|
||||
const names = variables
|
||||
.map((v) => v.name)
|
||||
.filter((name): name is string => !!name);
|
||||
initVariableFetch(names, fetchContext);
|
||||
enqueueFetchAll(
|
||||
doAllQueryVariablesHaveValues(variables, selectionRef.current),
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dashboardId, orderKey, minTime, maxTime]);
|
||||
|
||||
const setSelection = useCallback(
|
||||
(name: string, next: VariableSelection): void => {
|
||||
setVariableValue(dashboardId, name, next);
|
||||
enqueueDescendants(name);
|
||||
void setUrlValues((prev) => ({
|
||||
...(prev ?? {}),
|
||||
[name]: next.allSelected ? ALL_SELECTED : next.value,
|
||||
}));
|
||||
},
|
||||
[dashboardId, setVariableValue, setUrlValues],
|
||||
[dashboardId, setVariableValue, enqueueDescendants, setUrlValues],
|
||||
);
|
||||
|
||||
return { variables, dependencyData, selection, setSelection };
|
||||
// Coalesce the initial load burst of auto-selections: each selector fills its
|
||||
// value as its options resolve (at different times). Collecting them into one
|
||||
// store write + one `enqueueDescendantsBatch` means dependents re-fetch once
|
||||
// with the settled parent values, instead of once per fill.
|
||||
const pendingAutoFillRef = useRef<VariableSelectionMap>({});
|
||||
const autoFillFrameRef = useRef<number | null>(null);
|
||||
|
||||
const flushAutoFills = useCallback((): void => {
|
||||
autoFillFrameRef.current = null;
|
||||
const fills = pendingAutoFillRef.current;
|
||||
pendingAutoFillRef.current = {};
|
||||
const names = Object.keys(fills);
|
||||
if (names.length === 0 || !dashboardId) {
|
||||
return;
|
||||
}
|
||||
setVariableValues(dashboardId, { ...selectionRef.current, ...fills });
|
||||
void setUrlValues((prev) => {
|
||||
const next = { ...(prev ?? {}) };
|
||||
names.forEach((name) => {
|
||||
const sel = fills[name];
|
||||
next[name] = sel.allSelected ? ALL_SELECTED : sel.value;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
enqueueDescendantsBatch(names);
|
||||
}, [dashboardId, setVariableValues, setUrlValues, enqueueDescendantsBatch]);
|
||||
|
||||
const autoSelect = useCallback(
|
||||
(name: string, next: VariableSelection): void => {
|
||||
pendingAutoFillRef.current[name] = next;
|
||||
if (autoFillFrameRef.current == null) {
|
||||
autoFillFrameRef.current = requestAnimationFrame(flushAutoFills);
|
||||
}
|
||||
},
|
||||
[flushAutoFills],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => (): void => {
|
||||
if (autoFillFrameRef.current != null) {
|
||||
cancelAnimationFrame(autoFillFrameRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { variables, selection, setSelection, autoSelect };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
|
||||
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
|
||||
import type {
|
||||
VariableFormModel,
|
||||
VariableType,
|
||||
} from '../DashboardSettings/Variables/variableFormModel';
|
||||
import type { VariableSelectionMap } from './selectionTypes';
|
||||
import { isResolved } from './selectionUtils';
|
||||
|
||||
/**
|
||||
* Inter-variable dependency graph for runtime selection. A QUERY variable
|
||||
@@ -197,3 +202,57 @@ export function computeVariableDependencies(
|
||||
): VariableDependencyData {
|
||||
return buildDependencyData(buildDependencies(variables));
|
||||
}
|
||||
|
||||
/**
|
||||
* Static context the runtime fetch engine (`variableFetchSlice`) needs to order
|
||||
* fetches: the dependency graph plus the per-name type index and the QUERY /
|
||||
* DYNAMIC fetch orders. Derived from the variable definitions; stable until the
|
||||
* spec's variables change. Mirrors V1's `getVariableDependencyContext`.
|
||||
*/
|
||||
export interface VariableFetchContext {
|
||||
dependencyData: VariableDependencyData;
|
||||
/** variable name → its type. */
|
||||
variableTypes: Record<string, VariableType>;
|
||||
/** QUERY variables in topological (parent-before-child) order. */
|
||||
queryVariableOrder: string[];
|
||||
/** DYNAMIC variable names (they implicitly depend on all QUERY values). */
|
||||
dynamicVariableOrder: string[];
|
||||
}
|
||||
|
||||
export function deriveFetchContext(
|
||||
variables: VariableFormModel[],
|
||||
): VariableFetchContext {
|
||||
const dependencyData = computeVariableDependencies(variables);
|
||||
const variableTypes: Record<string, VariableType> = {};
|
||||
variables.forEach((v) => {
|
||||
if (v.name) {
|
||||
variableTypes[v.name] = v.type;
|
||||
}
|
||||
});
|
||||
const queryVariableOrder = dependencyData.order.filter(
|
||||
(name) => variableTypes[name] === 'QUERY',
|
||||
);
|
||||
const dynamicVariableOrder = variables
|
||||
.filter((v) => v.type === 'DYNAMIC' && !!v.name)
|
||||
.map((v) => v.name);
|
||||
return {
|
||||
dependencyData,
|
||||
variableTypes,
|
||||
queryVariableOrder,
|
||||
dynamicVariableOrder,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether every QUERY variable already has a usable selection — decides at load
|
||||
* time whether dynamic variables may fetch immediately or must wait for the
|
||||
* query variables to settle first (V1 parity).
|
||||
*/
|
||||
export function doAllQueryVariablesHaveValues(
|
||||
variables: VariableFormModel[],
|
||||
selection: VariableSelectionMap,
|
||||
): boolean {
|
||||
return variables
|
||||
.filter((v) => v.type === 'QUERY')
|
||||
.every((v) => isResolved(selection[v.name]));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
emptyVariableFormModel,
|
||||
type VariableFormModel,
|
||||
} from '../../../DashboardSettings/Variables/variableFormModel';
|
||||
import { deriveFetchContext } from '../../../VariablesBar/variableDependencies';
|
||||
import { useDashboardStore } from '../../useDashboardStore';
|
||||
|
||||
function model(overrides: Partial<VariableFormModel>): VariableFormModel {
|
||||
return { ...emptyVariableFormModel(), ...overrides };
|
||||
}
|
||||
|
||||
// q1 (root query) → q2 (query referencing $q1) ; d1 (dynamic).
|
||||
const q1 = model({ name: 'q1', type: 'QUERY', queryValue: 'SELECT 1' });
|
||||
const q2 = model({ name: 'q2', type: 'QUERY', queryValue: 'SELECT $q1' });
|
||||
const d1 = model({ name: 'd1', type: 'DYNAMIC', dynamicAttribute: 'pod' });
|
||||
const context = deriveFetchContext([q1, q2, d1]);
|
||||
|
||||
function store(): ReturnType<typeof useDashboardStore.getState> {
|
||||
return useDashboardStore.getState();
|
||||
}
|
||||
function states(): Record<string, string> {
|
||||
return store().variableFetchStates;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useDashboardStore.setState({
|
||||
variableFetchStates: {},
|
||||
variableLastUpdated: {},
|
||||
variableCycleIds: {},
|
||||
variableFetchContext: null,
|
||||
});
|
||||
store().initVariableFetch(['q1', 'q2', 'd1'], context);
|
||||
});
|
||||
|
||||
describe('variableFetchSlice', () => {
|
||||
it('initializes every variable to idle', () => {
|
||||
expect(states()).toStrictEqual({ q1: 'idle', q2: 'idle', d1: 'idle' });
|
||||
});
|
||||
|
||||
it('enqueueFetchAll loads roots, waits dependents and (ungated) dynamics', () => {
|
||||
store().enqueueFetchAll(false);
|
||||
expect(states()).toStrictEqual({
|
||||
q1: 'loading',
|
||||
q2: 'waiting',
|
||||
d1: 'waiting',
|
||||
});
|
||||
expect(store().variableCycleIds).toStrictEqual({ q1: 1, q2: 1, d1: 1 });
|
||||
});
|
||||
|
||||
it('enqueueFetchAll loads dynamics immediately when query values exist', () => {
|
||||
store().enqueueFetchAll(true);
|
||||
expect(states().d1).toBe('loading');
|
||||
});
|
||||
|
||||
it('completing a parent unblocks its query child, then unlocks dynamics', () => {
|
||||
store().enqueueFetchAll(false);
|
||||
store().onVariableFetchComplete('q1');
|
||||
expect(states()).toMatchObject({ q1: 'idle', q2: 'loading', d1: 'waiting' });
|
||||
|
||||
store().onVariableFetchComplete('q2');
|
||||
expect(states()).toMatchObject({ q1: 'idle', q2: 'idle', d1: 'loading' });
|
||||
});
|
||||
|
||||
it('enqueueDescendants revalidates only descendants + dynamics', () => {
|
||||
store().enqueueFetchAll(false);
|
||||
store().onVariableFetchComplete('q1');
|
||||
store().onVariableFetchComplete('q2');
|
||||
store().onVariableFetchComplete('d1');
|
||||
|
||||
store().enqueueDescendants('q1');
|
||||
// q2 depends on q1 (settled) → revalidates; d1 waits (q2 no longer settled).
|
||||
expect(states().q2).toBe('revalidating');
|
||||
expect(states().d1).toBe('waiting');
|
||||
});
|
||||
|
||||
it('enqueueDescendantsBatch bumps each descendant + dynamic exactly once', () => {
|
||||
store().enqueueFetchAll(false);
|
||||
store().onVariableFetchComplete('q1');
|
||||
store().onVariableFetchComplete('q2');
|
||||
store().onVariableFetchComplete('d1');
|
||||
const before = { ...store().variableCycleIds };
|
||||
|
||||
// q1 and q2 auto-select together: q2 is a descendant of q1 but is also in
|
||||
// the batch — it should still bump only once, as should the dynamic.
|
||||
store().enqueueDescendantsBatch(['q1', 'q2']);
|
||||
const after = store().variableCycleIds;
|
||||
expect(after.q2).toBe(before.q2 + 1);
|
||||
expect(after.d1).toBe(before.d1 + 1);
|
||||
});
|
||||
|
||||
it('a failed parent idles its query descendants', () => {
|
||||
store().enqueueFetchAll(false);
|
||||
store().onVariableFetchFailure('q1');
|
||||
expect(states().q1).toBe('error');
|
||||
expect(states().q2).toBe('idle');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
import type { StateCreator } from 'zustand';
|
||||
|
||||
import type { VariableFetchContext } from '../../VariablesBar/variableDependencies';
|
||||
import type { DashboardStore } from '../useDashboardStore';
|
||||
import {
|
||||
areAllQueryVariablesSettled,
|
||||
type FetchMaps,
|
||||
isSettled,
|
||||
resolveFetchState,
|
||||
unlockWaitingDynamicVariables,
|
||||
type VariableFetchState,
|
||||
} from './variableFetchSlice.utils';
|
||||
|
||||
export type { VariableFetchState } from './variableFetchSlice.utils';
|
||||
|
||||
/**
|
||||
* Runtime fetch orchestration for dashboard variables — native port of V1's
|
||||
* `variableFetchStore`. Decides WHEN each variable's options fetch: query
|
||||
* variables in dependency order, dynamics together once query values exist,
|
||||
* text/custom never. `cycleIds` is a per-variable request nonce keyed into each
|
||||
* selector's react-query key (bump = fresh fetch, auto-cancel stale). Transient.
|
||||
* `enqueueFetchAll` = load/time change; `enqueueDescendants` = one value changed.
|
||||
*/
|
||||
export interface VariableFetchSlice {
|
||||
variableFetchStates: Record<string, VariableFetchState>;
|
||||
variableLastUpdated: Record<string, number>;
|
||||
variableCycleIds: Record<string, number>;
|
||||
/** Static dependency context, set by `initVariableFetch` (null before init). */
|
||||
variableFetchContext: VariableFetchContext | null;
|
||||
|
||||
/** Seed state entries for the current variable set and store the context. */
|
||||
initVariableFetch: (names: string[], context: VariableFetchContext) => void;
|
||||
/** Start a full fetch cycle for every fetchable variable (load / time change). */
|
||||
enqueueFetchAll: (doAllQueryVariablesHaveValuesSelected: boolean) => void;
|
||||
/** Mark a variable's fetch as done; unblock its waiting children / dynamics. */
|
||||
onVariableFetchComplete: (name: string) => void;
|
||||
/** Mark a variable's fetch as failed; idle its query descendants. */
|
||||
onVariableFetchFailure: (name: string) => void;
|
||||
/** Cascade a value change to a variable's query descendants + the dynamics. */
|
||||
enqueueDescendants: (name: string) => void;
|
||||
/**
|
||||
* Batched value-change cascade: refresh the union of the given variables'
|
||||
* query descendants plus the dynamics, each exactly once. Used to collapse the
|
||||
* initial burst of auto-selections into a single downstream fetch.
|
||||
*/
|
||||
enqueueDescendantsBatch: (names: string[]) => void;
|
||||
}
|
||||
|
||||
/** Snapshot the three fetch maps into mutable clones for a single action. */
|
||||
function cloneMaps(state: DashboardStore): FetchMaps {
|
||||
return {
|
||||
states: { ...state.variableFetchStates },
|
||||
lastUpdated: { ...state.variableLastUpdated },
|
||||
cycleIds: { ...state.variableCycleIds },
|
||||
};
|
||||
}
|
||||
|
||||
export const createVariableFetchSlice: StateCreator<
|
||||
DashboardStore,
|
||||
[['zustand/persist', unknown]],
|
||||
[],
|
||||
VariableFetchSlice
|
||||
> = (set, get) => ({
|
||||
variableFetchStates: {},
|
||||
variableLastUpdated: {},
|
||||
variableCycleIds: {},
|
||||
variableFetchContext: null,
|
||||
|
||||
initVariableFetch: (names, context): void => {
|
||||
const maps = cloneMaps(get());
|
||||
// Initialize new variables to idle, preserving existing states.
|
||||
names.forEach((name) => {
|
||||
if (!maps.states[name]) {
|
||||
maps.states[name] = 'idle';
|
||||
}
|
||||
});
|
||||
// Drop entries for variables that no longer exist.
|
||||
const nameSet = new Set(names);
|
||||
Object.keys(maps.states).forEach((name) => {
|
||||
if (!nameSet.has(name)) {
|
||||
delete maps.states[name];
|
||||
delete maps.lastUpdated[name];
|
||||
delete maps.cycleIds[name];
|
||||
}
|
||||
});
|
||||
set({
|
||||
variableFetchStates: maps.states,
|
||||
variableLastUpdated: maps.lastUpdated,
|
||||
variableCycleIds: maps.cycleIds,
|
||||
variableFetchContext: context,
|
||||
});
|
||||
},
|
||||
|
||||
enqueueFetchAll: (doAllQueryVariablesHaveValuesSelected): void => {
|
||||
const { variableFetchContext } = get();
|
||||
if (!variableFetchContext) {
|
||||
return;
|
||||
}
|
||||
const {
|
||||
dependencyData,
|
||||
variableTypes,
|
||||
queryVariableOrder,
|
||||
dynamicVariableOrder,
|
||||
} = variableFetchContext;
|
||||
const maps = cloneMaps(get());
|
||||
|
||||
// Query variables: roots start immediately, dependents wait for parents.
|
||||
queryVariableOrder.forEach((name) => {
|
||||
maps.cycleIds[name] = (maps.cycleIds[name] || 0) + 1;
|
||||
const parents = dependencyData.parentGraph[name] || [];
|
||||
const hasQueryParents = parents.some((p) => variableTypes[p] === 'QUERY');
|
||||
maps.states[name] = hasQueryParents
|
||||
? 'waiting'
|
||||
: resolveFetchState(maps, name);
|
||||
});
|
||||
|
||||
// Dynamic variables: start now if query variables already have values,
|
||||
// otherwise wait until the query variables settle.
|
||||
dynamicVariableOrder.forEach((name) => {
|
||||
maps.cycleIds[name] = (maps.cycleIds[name] || 0) + 1;
|
||||
maps.states[name] = doAllQueryVariablesHaveValuesSelected
|
||||
? resolveFetchState(maps, name)
|
||||
: 'waiting';
|
||||
});
|
||||
|
||||
set({
|
||||
variableFetchStates: maps.states,
|
||||
variableLastUpdated: maps.lastUpdated,
|
||||
variableCycleIds: maps.cycleIds,
|
||||
});
|
||||
},
|
||||
|
||||
onVariableFetchComplete: (name): void => {
|
||||
const { variableFetchContext } = get();
|
||||
const maps = cloneMaps(get());
|
||||
maps.states[name] = 'idle';
|
||||
maps.lastUpdated[name] = Date.now();
|
||||
|
||||
if (variableFetchContext) {
|
||||
const { dependencyData, variableTypes, dynamicVariableOrder } =
|
||||
variableFetchContext;
|
||||
// Unblock waiting query-type children.
|
||||
(dependencyData.graph[name] || []).forEach((child) => {
|
||||
if (variableTypes[child] === 'QUERY' && maps.states[child] === 'waiting') {
|
||||
maps.states[child] = resolveFetchState(maps, child);
|
||||
}
|
||||
});
|
||||
// Once all query variables settle, unlock any waiting dynamics.
|
||||
if (
|
||||
variableTypes[name] === 'QUERY' &&
|
||||
areAllQueryVariablesSettled(maps.states, variableTypes)
|
||||
) {
|
||||
unlockWaitingDynamicVariables(maps, dynamicVariableOrder);
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
variableFetchStates: maps.states,
|
||||
variableLastUpdated: maps.lastUpdated,
|
||||
variableCycleIds: maps.cycleIds,
|
||||
});
|
||||
},
|
||||
|
||||
onVariableFetchFailure: (name): void => {
|
||||
const { variableFetchContext } = get();
|
||||
const maps = cloneMaps(get());
|
||||
maps.states[name] = 'error';
|
||||
|
||||
if (variableFetchContext) {
|
||||
const { dependencyData, variableTypes, dynamicVariableOrder } =
|
||||
variableFetchContext;
|
||||
// Query descendants can't proceed without this parent — idle them.
|
||||
(dependencyData.transitiveDescendants[name] || []).forEach((desc) => {
|
||||
if (variableTypes[desc] === 'QUERY') {
|
||||
maps.states[desc] = 'idle';
|
||||
}
|
||||
});
|
||||
if (
|
||||
variableTypes[name] === 'QUERY' &&
|
||||
areAllQueryVariablesSettled(maps.states, variableTypes)
|
||||
) {
|
||||
unlockWaitingDynamicVariables(maps, dynamicVariableOrder);
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
variableFetchStates: maps.states,
|
||||
variableLastUpdated: maps.lastUpdated,
|
||||
variableCycleIds: maps.cycleIds,
|
||||
});
|
||||
},
|
||||
|
||||
enqueueDescendants: (name): void => {
|
||||
get().enqueueDescendantsBatch([name]);
|
||||
},
|
||||
|
||||
enqueueDescendantsBatch: (names): void => {
|
||||
const { variableFetchContext } = get();
|
||||
if (!variableFetchContext || names.length === 0) {
|
||||
return;
|
||||
}
|
||||
const { dependencyData, variableTypes, dynamicVariableOrder } =
|
||||
variableFetchContext;
|
||||
const maps = cloneMaps(get());
|
||||
|
||||
// Union of the changed variables' query descendants, refreshed once each:
|
||||
// refetch when all their parents are settled, else wait.
|
||||
const queryDescendants = new Set<string>();
|
||||
names.forEach((name) => {
|
||||
(dependencyData.transitiveDescendants[name] || []).forEach((desc) => {
|
||||
if (variableTypes[desc] === 'QUERY') {
|
||||
queryDescendants.add(desc);
|
||||
}
|
||||
});
|
||||
});
|
||||
queryDescendants.forEach((desc) => {
|
||||
maps.cycleIds[desc] = (maps.cycleIds[desc] || 0) + 1;
|
||||
const parents = dependencyData.parentGraph[desc] || [];
|
||||
const allParentsSettled = parents.every((p) => isSettled(maps.states[p]));
|
||||
maps.states[desc] = allParentsSettled
|
||||
? resolveFetchState(maps, desc)
|
||||
: 'waiting';
|
||||
});
|
||||
|
||||
// Dynamics implicitly depend on all query values: refetch now if the query
|
||||
// variables are settled, otherwise wait for them.
|
||||
dynamicVariableOrder.forEach((dynName) => {
|
||||
maps.cycleIds[dynName] = (maps.cycleIds[dynName] || 0) + 1;
|
||||
maps.states[dynName] = areAllQueryVariablesSettled(
|
||||
maps.states,
|
||||
variableTypes,
|
||||
)
|
||||
? resolveFetchState(maps, dynName)
|
||||
: 'waiting';
|
||||
});
|
||||
|
||||
set({
|
||||
variableFetchStates: maps.states,
|
||||
variableLastUpdated: maps.lastUpdated,
|
||||
variableCycleIds: maps.cycleIds,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/** Selector: the fetch state for a single variable (defaults to idle). */
|
||||
export const selectVariableFetchState =
|
||||
(name: string) =>
|
||||
(state: DashboardStore): VariableFetchState =>
|
||||
state.variableFetchStates[name] ?? 'idle';
|
||||
|
||||
/** Selector: the current fetch cycle id for a single variable (defaults to 0). */
|
||||
export const selectVariableCycleId =
|
||||
(name: string) =>
|
||||
(state: DashboardStore): number =>
|
||||
state.variableCycleIds[name] ?? 0;
|
||||
|
||||
/** Selector: whether a variable has completed at least one fetch. */
|
||||
export const selectVariableFetchedOnce =
|
||||
(name: string) =>
|
||||
(state: DashboardStore): boolean =>
|
||||
(state.variableLastUpdated[name] ?? 0) > 0;
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { VariableType } from '../../DashboardSettings/Variables/variableFormModel';
|
||||
|
||||
/** Per-variable fetch lifecycle (ported from V1's `variableFetchStore`). */
|
||||
export type VariableFetchState =
|
||||
| 'idle'
|
||||
| 'loading'
|
||||
| 'revalidating'
|
||||
| 'waiting'
|
||||
| 'error';
|
||||
|
||||
/** Mutable clones a fetch action works over before committing back in one `set`. */
|
||||
export interface FetchMaps {
|
||||
states: Record<string, VariableFetchState>;
|
||||
lastUpdated: Record<string, number>;
|
||||
cycleIds: Record<string, number>;
|
||||
}
|
||||
|
||||
/** Settled = can make no further progress (idle or error). */
|
||||
export function isSettled(state: VariableFetchState | undefined): boolean {
|
||||
return state === 'idle' || state === 'error';
|
||||
}
|
||||
|
||||
/** Fetch-start state: `revalidating` if fetched before, else `loading`. */
|
||||
export function resolveFetchState(
|
||||
maps: FetchMaps,
|
||||
name: string,
|
||||
): VariableFetchState {
|
||||
return (maps.lastUpdated[name] || 0) > 0 ? 'revalidating' : 'loading';
|
||||
}
|
||||
|
||||
/** True once every QUERY variable is settled. */
|
||||
export function areAllQueryVariablesSettled(
|
||||
states: Record<string, VariableFetchState>,
|
||||
variableTypes: Record<string, VariableType>,
|
||||
): boolean {
|
||||
return Object.entries(variableTypes)
|
||||
.filter(([, type]) => type === 'QUERY')
|
||||
.every(([name]) => isSettled(states[name]));
|
||||
}
|
||||
|
||||
/** Move any `waiting` dynamic variables into loading/revalidating. */
|
||||
export function unlockWaitingDynamicVariables(
|
||||
maps: FetchMaps,
|
||||
dynamicVariableOrder: string[],
|
||||
): void {
|
||||
dynamicVariableOrder.forEach((dynName) => {
|
||||
if (maps.states[dynName] === 'waiting') {
|
||||
maps.states[dynName] = resolveFetchState(maps, dynName);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -13,10 +13,15 @@ import {
|
||||
createVariableSelectionSlice,
|
||||
type VariableSelectionSlice,
|
||||
} from './slices/variableSelectionSlice';
|
||||
import {
|
||||
createVariableFetchSlice,
|
||||
type VariableFetchSlice,
|
||||
} from './slices/variableFetchSlice';
|
||||
|
||||
export type DashboardStore = EditContextSlice &
|
||||
CollapseSlice &
|
||||
VariableSelectionSlice;
|
||||
VariableSelectionSlice &
|
||||
VariableFetchSlice;
|
||||
|
||||
/**
|
||||
* V2 dashboard session store. Holds cross-cutting client state only — never the
|
||||
@@ -31,6 +36,7 @@ export const useDashboardStore = create<DashboardStore>()(
|
||||
...createEditContextSlice(...a),
|
||||
...createCollapseSlice(...a),
|
||||
...createVariableSelectionSlice(...a),
|
||||
...createVariableFetchSlice(...a),
|
||||
}),
|
||||
{
|
||||
name: '@signoz/dashboard-v2',
|
||||
|
||||
@@ -7,6 +7,7 @@ import styles from './DashboardsList.module.scss';
|
||||
interface Props {
|
||||
label: string;
|
||||
count: number;
|
||||
isModified: boolean;
|
||||
canCreate: boolean;
|
||||
onCreate: () => void;
|
||||
}
|
||||
@@ -14,6 +15,7 @@ interface Props {
|
||||
function CommandHeader({
|
||||
label,
|
||||
count,
|
||||
isModified,
|
||||
canCreate,
|
||||
onCreate,
|
||||
}: Props): JSX.Element {
|
||||
@@ -21,6 +23,7 @@ function CommandHeader({
|
||||
<div className={styles.commandHeader}>
|
||||
<div className={styles.headingBlock}>
|
||||
<Typography.Title className={styles.title}>{label}</Typography.Title>
|
||||
{isModified && <span className={styles.dirtyDot} title="Unsaved changes" />}
|
||||
<span className={styles.countPill}>{count}</span>
|
||||
</div>
|
||||
<div className={styles.grow} />
|
||||
|
||||
@@ -93,6 +93,14 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dirtyDot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--warning-background);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.countPill {
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
|
||||
@@ -10,9 +10,9 @@ import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import { combineQueries } from '../../utils/filterQuery';
|
||||
import { useAccumulatedTags } from '../../hooks/useAccumulatedTags';
|
||||
import { useActiveView } from '../../hooks/useActiveView';
|
||||
import { useCreatorOptions } from '../../hooks/useCreatorOptions';
|
||||
import { useDashboardFilters } from '../../hooks/useDashboardFilters';
|
||||
import {
|
||||
usePage,
|
||||
@@ -25,7 +25,6 @@ import { BuiltinViewId } from '../../types';
|
||||
import type { SelectedTag, UpdatedWindow } from '../../types';
|
||||
import type { DashboardListItem } from '../../utils/helpers';
|
||||
import { applyClientView } from '../../utils/views';
|
||||
import type { CreatorOption } from '../FilterZone/FilterChips';
|
||||
import FilterZone from '../FilterZone/FilterZone';
|
||||
import NewDashboardModal from '../NewDashboardModal/NewDashboardModal';
|
||||
import StatusBar from '../StatusBar/StatusBar';
|
||||
@@ -72,13 +71,13 @@ function DashboardsList(): JSX.Element {
|
||||
customViewsLoading,
|
||||
isCustomActive,
|
||||
isModified,
|
||||
viewQuery,
|
||||
clientView,
|
||||
selectView,
|
||||
saveView,
|
||||
saveActiveView,
|
||||
resetView,
|
||||
removeView,
|
||||
renameView,
|
||||
} = useActiveView({
|
||||
filters,
|
||||
applyFilters,
|
||||
@@ -153,13 +152,13 @@ function DashboardsList(): JSX.Element {
|
||||
|
||||
const listParams = useMemo(
|
||||
() => ({
|
||||
query: combineQueries(viewQuery, query) || undefined,
|
||||
query: query || undefined,
|
||||
sort: sortColumn,
|
||||
order: sortOrder,
|
||||
limit: clientView ? CLIENT_VIEW_LIMIT : PAGE_SIZE,
|
||||
offset: clientView ? 0 : (page - 1) * PAGE_SIZE,
|
||||
}),
|
||||
[viewQuery, query, sortColumn, sortOrder, page, clientView],
|
||||
[query, sortColumn, sortOrder, page, clientView],
|
||||
);
|
||||
|
||||
const {
|
||||
@@ -195,24 +194,19 @@ function DashboardsList(): JSX.Element {
|
||||
);
|
||||
const total = clientView ? dashboards.length : (response?.data?.total ?? 0);
|
||||
|
||||
// Creator filter options: distinct authors on the loaded page plus the
|
||||
// current user (so "me" is always selectable). Page-scoped until a members
|
||||
// source backs this.
|
||||
const creatorOptions = useMemo<CreatorOption[]>(() => {
|
||||
const emails = new Set<string>();
|
||||
if (user.email) {
|
||||
emails.add(user.email);
|
||||
}
|
||||
rawDashboards.forEach((d) => {
|
||||
if (d.createdBy) {
|
||||
emails.add(d.createdBy);
|
||||
}
|
||||
});
|
||||
return [...emails].sort().map((email) => ({
|
||||
email,
|
||||
label: email === user.email ? `${email} (me)` : email,
|
||||
}));
|
||||
}, [rawDashboards, user.email]);
|
||||
// Authors present on the loaded page — a fallback for the creator filter until
|
||||
// the org-wide user list resolves.
|
||||
const pageAuthorEmails = useMemo<string[]>(
|
||||
() =>
|
||||
rawDashboards
|
||||
.map((d) => d.createdBy)
|
||||
.filter((email): email is string => !!email),
|
||||
[rawDashboards],
|
||||
);
|
||||
const creatorOptions = useCreatorOptions({
|
||||
currentUserEmail: user.email,
|
||||
fallbackEmails: pageAuthorEmails,
|
||||
});
|
||||
|
||||
// All key:value tags the API reports for the org's dashboards, powering the
|
||||
// Tags filter chip and DSL key suggestions. Accumulated across refetches so
|
||||
@@ -289,8 +283,8 @@ function DashboardsList(): JSX.Element {
|
||||
onSave={saveView}
|
||||
onSaveChanges={saveActiveView}
|
||||
onReset={handleResetView}
|
||||
onClearFilters={handleClearAll}
|
||||
onDelete={handleRemoveView}
|
||||
onRename={renameView}
|
||||
/>
|
||||
<div className={styles.main}>
|
||||
<div className={styles.mainScroll}>
|
||||
@@ -305,6 +299,7 @@ function DashboardsList(): JSX.Element {
|
||||
<CommandHeader
|
||||
label={activeLabel}
|
||||
count={total}
|
||||
isModified={isModified}
|
||||
canCreate={canCreateNewDashboard}
|
||||
onCreate={openCreate}
|
||||
/>
|
||||
|
||||
@@ -6,32 +6,45 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './ViewsRail.module.scss';
|
||||
|
||||
export const VIEW_NAME_MAX_LENGTH = 128;
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave: (name: string) => void;
|
||||
onSubmit: (name: string) => void;
|
||||
trigger: ReactNode;
|
||||
title: string;
|
||||
confirmLabel: string;
|
||||
initialName?: string;
|
||||
testIdPrefix?: string;
|
||||
}
|
||||
|
||||
function SaveViewPopover({
|
||||
// Name-input popover shared by "save as view" and "rename view"; enforces the
|
||||
// view-name length cap in one place.
|
||||
function ViewNamePopover({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
onSubmit,
|
||||
trigger,
|
||||
title,
|
||||
confirmLabel,
|
||||
initialName = '',
|
||||
testIdPrefix = 'view-name',
|
||||
}: Props): JSX.Element {
|
||||
const [name, setName] = useState('');
|
||||
const [name, setName] = useState(initialName);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName('');
|
||||
setName(initialName);
|
||||
}
|
||||
}, [open]);
|
||||
}, [open, initialName]);
|
||||
|
||||
const canSave = name.trim().length > 0;
|
||||
const trimmed = name.trim();
|
||||
const canSave = trimmed.length > 0 && trimmed.length <= VIEW_NAME_MAX_LENGTH;
|
||||
|
||||
const handleSave = (): void => {
|
||||
if (canSave) {
|
||||
onSave(name);
|
||||
onSubmit(trimmed);
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
@@ -44,13 +57,14 @@ function SaveViewPopover({
|
||||
trigger={trigger}
|
||||
>
|
||||
<div className={styles.savePopover}>
|
||||
<div className={styles.saveTitle}>Save as view</div>
|
||||
<div className={styles.saveTitle}>{title}</div>
|
||||
<Typography.Text className={styles.saveLabel}>Name</Typography.Text>
|
||||
<Input
|
||||
value={name}
|
||||
autoFocus
|
||||
maxLength={VIEW_NAME_MAX_LENGTH}
|
||||
placeholder="e.g. Prod alerts"
|
||||
testId="save-view-name"
|
||||
testId={`${testIdPrefix}-name`}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
|
||||
setName(e.target.value)
|
||||
}
|
||||
@@ -74,10 +88,10 @@ function SaveViewPopover({
|
||||
color="primary"
|
||||
size="sm"
|
||||
disabled={!canSave}
|
||||
testId="save-view-confirm"
|
||||
testId={`${testIdPrefix}-confirm`}
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save view
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -85,4 +99,4 @@ function SaveViewPopover({
|
||||
);
|
||||
}
|
||||
|
||||
export default SaveViewPopover;
|
||||
export default ViewNamePopover;
|
||||
@@ -145,30 +145,40 @@
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.itemAction {
|
||||
// Blended ghost icon overlaid on the row's right edge — absolutely positioned
|
||||
// so it never reserves layout space or affects the row height. Transparent so
|
||||
// the row's hover background shows through; turns red only on its own hover.
|
||||
// Ghost action icons overlaid on the row's right edge — absolutely positioned so
|
||||
// they never reserve layout space or affect row height. Hidden until row hover,
|
||||
// and inert while hidden so they can't intercept clicks meant for the row.
|
||||
.itemActions {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
--button-height: 20px;
|
||||
--button-padding: 0;
|
||||
--button-border-radius: 4px;
|
||||
--button-variant-ghost-color: var(--l3-foreground);
|
||||
--button-variant-ghost-hover-background-color: var(--danger-background);
|
||||
--button-variant-ghost-hover-color: var(--danger-color, #fff);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
// Hidden until row hover, and inert while hidden so it can't intercept clicks
|
||||
// meant for the row.
|
||||
pointer-events: none;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
|
||||
.row:hover .itemAction {
|
||||
.itemAction {
|
||||
--button-height: 20px;
|
||||
--button-padding: 0;
|
||||
--button-border-radius: 4px;
|
||||
--button-variant-ghost-color: var(--l3-foreground);
|
||||
--button-variant-ghost-hover-background-color: var(--l1-background);
|
||||
--button-variant-ghost-hover-color: var(--l1-foreground);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.itemActionDanger {
|
||||
--button-variant-ghost-hover-background-color: var(--danger-background);
|
||||
--button-variant-ghost-hover-color: var(--danger-color, #fff);
|
||||
}
|
||||
|
||||
.row:hover .itemActions {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -3,12 +3,19 @@ import { Modal } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Bookmark, CircleAlert, Plus, Search, Trash2 } from '@signozhq/icons';
|
||||
import {
|
||||
Bookmark,
|
||||
CircleAlert,
|
||||
PenLine,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
} from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
|
||||
import type { SavedView } from '../../types';
|
||||
import { type BuiltinView } from '../../utils/views';
|
||||
import SaveViewPopover from './SaveViewPopover';
|
||||
import ViewNamePopover from './ViewNamePopover';
|
||||
|
||||
import styles from './ViewsRail.module.scss';
|
||||
|
||||
@@ -24,8 +31,8 @@ interface Props {
|
||||
onSave: (name: string) => void;
|
||||
onSaveChanges: () => void;
|
||||
onReset: () => void;
|
||||
onClearFilters: () => void;
|
||||
onDelete: (id: string) => void;
|
||||
onRename: (id: string, name: string) => void;
|
||||
}
|
||||
|
||||
interface ViewRow {
|
||||
@@ -49,10 +56,11 @@ function ViewsRail({
|
||||
onSave,
|
||||
onSaveChanges,
|
||||
onReset,
|
||||
onClearFilters,
|
||||
onDelete,
|
||||
onRename,
|
||||
}: Props): JSX.Element {
|
||||
const [saveOpen, setSaveOpen] = useState(false);
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [modal, contextHolder] = Modal.useModal();
|
||||
|
||||
@@ -122,20 +130,44 @@ function ViewsRail({
|
||||
)}
|
||||
</Button>
|
||||
{row.deletable && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.itemAction}
|
||||
aria-label="Delete view"
|
||||
title="Delete view"
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
confirmDelete(row.id, row.label);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
<div className={styles.itemActions}>
|
||||
<ViewNamePopover
|
||||
open={renamingId === row.id}
|
||||
onOpenChange={(open): void => setRenamingId(open ? row.id : null)}
|
||||
onSubmit={(name): void => onRename(row.id, name)}
|
||||
title="Rename view"
|
||||
confirmLabel="Rename"
|
||||
initialName={row.label}
|
||||
testIdPrefix="rename-view"
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.itemAction}
|
||||
aria-label="Rename view"
|
||||
title="Rename view"
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
>
|
||||
<PenLine size={12} />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={cx(styles.itemAction, styles.itemActionDanger)}
|
||||
aria-label="Delete view"
|
||||
title="Delete view"
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
confirmDelete(row.id, row.label);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -145,10 +177,13 @@ function ViewsRail({
|
||||
<aside className={cx(styles.rail, { [styles.collapsed]: collapsed })}>
|
||||
<div className={styles.header}>
|
||||
<h4 className={styles.headerTitle}>Views</h4>
|
||||
<SaveViewPopover
|
||||
<ViewNamePopover
|
||||
open={saveOpen}
|
||||
onOpenChange={setSaveOpen}
|
||||
onSave={onSave}
|
||||
onSubmit={onSave}
|
||||
title="Save as view"
|
||||
confirmLabel="Save view"
|
||||
testIdPrefix="save-view"
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -268,13 +303,8 @@ function ViewsRail({
|
||||
>
|
||||
Save as new view
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={onClearFilters}
|
||||
>
|
||||
Clear
|
||||
<Button variant="ghost" color="secondary" size="sm" onClick={onReset}>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,6 @@ import type {
|
||||
|
||||
import {
|
||||
areFilterStatesEqual,
|
||||
combineQueries,
|
||||
DEFAULT_FILTER_STATE,
|
||||
filterStateToQuery,
|
||||
} from '../utils/filterQuery';
|
||||
@@ -15,7 +14,6 @@ import { BuiltinViewId } from '../types';
|
||||
import type { DashboardFilterState, SavedView } from '../types';
|
||||
import {
|
||||
BUILTIN_VIEWS,
|
||||
builtinViewQuery,
|
||||
builtinViewSnapshot,
|
||||
type BuiltinView,
|
||||
isClientView,
|
||||
@@ -42,15 +40,14 @@ export interface UseActiveViewResult {
|
||||
isCustomActive: boolean;
|
||||
// Current filters diverge from the active view's canonical snapshot.
|
||||
isModified: boolean;
|
||||
// Extra server-query fragment the active view contributes, and whether it
|
||||
// constrains the list client-side (pinned/recent).
|
||||
viewQuery: string;
|
||||
// Whether the active view constrains the list client-side (pinned/recent).
|
||||
clientView: boolean;
|
||||
selectView: (id: string) => void;
|
||||
saveView: (name: string) => void;
|
||||
saveActiveView: () => void;
|
||||
resetView: () => void;
|
||||
removeView: (id: string) => void;
|
||||
renameView: (id: string, name: string) => void;
|
||||
}
|
||||
|
||||
// The canonical filter snapshot a saved view "is": the backend stores a flat
|
||||
@@ -127,11 +124,9 @@ export function useActiveView({
|
||||
|
||||
const saveView = useCallback(
|
||||
(name: string): void => {
|
||||
// Fold the current built-in clause + chips into a single query string.
|
||||
const query = combineQueries(
|
||||
builtinViewQuery(activeViewId),
|
||||
filterStateToQuery(filters),
|
||||
);
|
||||
// The active view's clause already lives in the filter state (e.g. Locked
|
||||
// seeds `locked = true` into search), so the chips fold into one query.
|
||||
const query = filterStateToQuery(filters);
|
||||
void (async (): Promise<void> => {
|
||||
const created = await createView({
|
||||
name,
|
||||
@@ -147,15 +142,7 @@ export function useActiveView({
|
||||
}
|
||||
})();
|
||||
},
|
||||
[
|
||||
activeViewId,
|
||||
filters,
|
||||
createView,
|
||||
sortColumn,
|
||||
sortOrder,
|
||||
setActiveViewId,
|
||||
applyFilters,
|
||||
],
|
||||
[filters, createView, sortColumn, sortOrder, setActiveViewId, applyFilters],
|
||||
);
|
||||
|
||||
const saveActiveView = useCallback((): void => {
|
||||
@@ -200,6 +187,23 @@ export function useActiveView({
|
||||
[deleteView, activeViewId, setActiveViewId, applyFilters],
|
||||
);
|
||||
|
||||
// Rename only touches the view's name; its stored query/sort/order are preserved.
|
||||
const renameView = useCallback(
|
||||
(id: string, name: string): void => {
|
||||
const view = customViews.find((v) => v.id === id);
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
updateView(id, {
|
||||
name,
|
||||
query: view.query,
|
||||
sort: view.sort,
|
||||
order: view.order,
|
||||
});
|
||||
},
|
||||
[customViews, updateView],
|
||||
);
|
||||
|
||||
return {
|
||||
activeViewId,
|
||||
builtinViews: BUILTIN_VIEWS,
|
||||
@@ -207,12 +211,12 @@ export function useActiveView({
|
||||
customViewsLoading,
|
||||
isCustomActive: !!activeCustom,
|
||||
isModified,
|
||||
viewQuery: builtinViewQuery(activeViewId),
|
||||
clientView: isClientView(activeViewId),
|
||||
selectView,
|
||||
saveView,
|
||||
saveActiveView,
|
||||
resetView,
|
||||
removeView,
|
||||
renameView,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useListUsers } from 'api/generated/services/users';
|
||||
|
||||
import type { CreatorOption } from '../components/FilterZone/FilterChips';
|
||||
|
||||
interface Args {
|
||||
currentUserEmail: string;
|
||||
// Authors on the loaded page — kept selectable until the org list resolves.
|
||||
fallbackEmails: string[];
|
||||
}
|
||||
|
||||
// Creator-filter options sourced from the org's full user list, so authors who
|
||||
// aren't on the current page are still selectable (v2 "List users" API).
|
||||
export function useCreatorOptions({
|
||||
currentUserEmail,
|
||||
fallbackEmails,
|
||||
}: Args): CreatorOption[] {
|
||||
const { data } = useListUsers();
|
||||
|
||||
return useMemo<CreatorOption[]>(() => {
|
||||
const users = data?.data ?? [];
|
||||
const emails = new Set<string>();
|
||||
if (currentUserEmail) {
|
||||
emails.add(currentUserEmail);
|
||||
}
|
||||
users.forEach((u) => u.email && emails.add(u.email));
|
||||
// Until the org list resolves (or if it comes back empty), keep the page's
|
||||
// authors selectable so the filter never regresses to just "me".
|
||||
if (users.length === 0) {
|
||||
fallbackEmails.forEach((e) => emails.add(e));
|
||||
}
|
||||
|
||||
const labelFor = (email: string): string => {
|
||||
if (email === currentUserEmail) {
|
||||
return `${email} (me)`;
|
||||
}
|
||||
const match = users.find((u) => u.email === email);
|
||||
return match?.displayName ? `${match.displayName} (${email})` : email;
|
||||
};
|
||||
|
||||
return [...emails].sort().map((email) => ({ email, label: labelFor(email) }));
|
||||
}, [data, currentUserEmail, fallbackEmails]);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useMemo } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
getListDashboardViewsQueryKey,
|
||||
invalidateListDashboardViews,
|
||||
useCreateDashboardView,
|
||||
useDeleteDashboardView,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
type DashboardtypesDashboardViewDTO,
|
||||
DashboardtypesListOrderDTO,
|
||||
DashboardtypesListSortDTO,
|
||||
type ListDashboardViews200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { SavedView, SavedViewInput } from '../types';
|
||||
@@ -70,9 +72,27 @@ export function useSavedViews(): UseSavedViewsResult {
|
||||
},
|
||||
},
|
||||
});
|
||||
// Rename/save-changes returns the updated view, so patch it into the cached
|
||||
// list inline instead of refetching the whole list.
|
||||
const updateMutation = useUpdateDashboardView({
|
||||
mutation: {
|
||||
onSuccess: invalidate,
|
||||
onSuccess: (response): void => {
|
||||
const updated = response?.data;
|
||||
const key = getListDashboardViewsQueryKey();
|
||||
const prev = queryClient.getQueryData<ListDashboardViews200>(key);
|
||||
if (!updated || !prev) {
|
||||
return;
|
||||
}
|
||||
queryClient.setQueryData<ListDashboardViews200>(key, {
|
||||
...prev,
|
||||
data: {
|
||||
...prev.data,
|
||||
views: (prev.data.views ?? []).map((v) =>
|
||||
v.id === updated.id ? updated : v,
|
||||
),
|
||||
},
|
||||
});
|
||||
},
|
||||
onError: (): void => {
|
||||
toast.error('Failed to update view.');
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Built-in view catalogue + the pure logic that maps a view to how it
|
||||
// constrains the list. Views fall into three mechanisms:
|
||||
// - snapshot: selecting applies a filter snapshot (All, My dashboards, custom)
|
||||
// - query: contributes an extra server clause AND-ed with the chips (Locked)
|
||||
// constrains the list. Views fall into two mechanisms:
|
||||
// - snapshot: selecting applies a filter snapshot — some seed the search box
|
||||
// with DSL (My dashboards → created_by; Locked → locked = true)
|
||||
// - client: constrains by a client-side id set (Favorites, Recently viewed)
|
||||
import { Clock, Layers, Lock, Pin, User } from '@signozhq/icons';
|
||||
|
||||
@@ -49,9 +49,9 @@ export const BUILTIN_VIEWS: BuiltinView[] = [
|
||||
export const isClientView = (id: string): boolean =>
|
||||
id === BuiltinViewId.Pinned || id === BuiltinViewId.Recent;
|
||||
|
||||
// Extra server query fragment a built-in view contributes (AND-ed with chips).
|
||||
export const builtinViewQuery = (id: string): string =>
|
||||
id === BuiltinViewId.Locked ? 'locked = true' : '';
|
||||
// DSL the Locked view seeds into the search box, so the constraint is visible
|
||||
// (and editable) rather than applied invisibly behind the scenes.
|
||||
export const LOCKED_QUERY = 'locked = true';
|
||||
|
||||
// The canonical filter snapshot a built-in view applies when selected. `null`
|
||||
// for ids that aren't built-in (custom views carry their own snapshot).
|
||||
@@ -65,10 +65,11 @@ export const builtinViewSnapshot = (
|
||||
...DEFAULT_FILTER_STATE,
|
||||
createdBy: userEmail ? [userEmail] : [],
|
||||
};
|
||||
case BuiltinViewId.Locked:
|
||||
return { ...DEFAULT_FILTER_STATE, search: LOCKED_QUERY };
|
||||
case BuiltinViewId.All:
|
||||
case BuiltinViewId.Pinned:
|
||||
case BuiltinViewId.Recent:
|
||||
case BuiltinViewId.Locked:
|
||||
return { ...DEFAULT_FILTER_STATE };
|
||||
default:
|
||||
return null;
|
||||
|
||||
@@ -33,15 +33,19 @@
|
||||
}
|
||||
|
||||
&.filter-visible {
|
||||
// Width is owned by ResizableBox (inline style); this section is the
|
||||
// ResizableBox root, so it stays position: relative for the drag handle.
|
||||
.log-quick-filter-left-section {
|
||||
width: 260px;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.resizable-box__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.quick-filters-container {
|
||||
flex: 1;
|
||||
@@ -50,7 +54,9 @@
|
||||
}
|
||||
|
||||
.log-module-right-section {
|
||||
width: calc(100% - 260px);
|
||||
flex: 1;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { defaultTo, isEmpty, isNull } from 'lodash-es';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
|
||||
import { EventSourceProvider } from 'providers/EventSource';
|
||||
import { Warning } from 'types/api';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -44,9 +46,23 @@ import { ExplorerViews } from './utils';
|
||||
|
||||
import './LogsExplorer.styles.scss';
|
||||
|
||||
const QUICK_FILTERS_DEFAULT_WIDTH = 260;
|
||||
const QUICK_FILTERS_MIN_WIDTH = 240;
|
||||
const QUICK_FILTERS_MAX_WIDTH = 500;
|
||||
|
||||
function LogsExplorer(): JSX.Element {
|
||||
const [showLiveLogs, setShowLiveLogs] = useState<boolean>(false);
|
||||
|
||||
const {
|
||||
initialWidth: quickFiltersInitialWidth,
|
||||
persistWidth: persistQuickFiltersWidth,
|
||||
} = usePanelWidth({
|
||||
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_LOGS,
|
||||
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
|
||||
minWidth: QUICK_FILTERS_MIN_WIDTH,
|
||||
maxWidth: QUICK_FILTERS_MAX_WIDTH,
|
||||
});
|
||||
|
||||
// Get panel type from URL
|
||||
const panelTypesFromUrl = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
|
||||
|
||||
@@ -226,14 +242,25 @@ function LogsExplorer(): JSX.Element {
|
||||
className={cx('logs-module-page', showFilters ? 'filter-visible' : '')}
|
||||
>
|
||||
{showFilters && (
|
||||
<section className={cx('log-quick-filter-left-section')}>
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
|
||||
initialWidth={quickFiltersInitialWidth}
|
||||
minWidth={QUICK_FILTERS_MIN_WIDTH}
|
||||
maxWidth={QUICK_FILTERS_MAX_WIDTH}
|
||||
onResize={persistQuickFiltersWidth}
|
||||
resetToDefaultOnDoubleClick
|
||||
withHandle
|
||||
className="log-quick-filter-left-section"
|
||||
handleTestId="quick-filters-resize-handle"
|
||||
>
|
||||
<QuickFilters
|
||||
className="qf-logs-explorer"
|
||||
signal={SignalType.LOGS}
|
||||
source={QuickFiltersSource.LOGS_EXPLORER}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
/>
|
||||
</section>
|
||||
</ResizableBox>
|
||||
)}
|
||||
<section className={cx('log-module-right-section')}>
|
||||
<Toolbar
|
||||
|
||||
@@ -18,6 +18,17 @@
|
||||
z-index: 10;
|
||||
background: var(--l2-border);
|
||||
|
||||
// Extend the interactive area beyond the 1px visual line so the handle
|
||||
// is easy to grab and double-click, without changing its appearance.
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
bottom: -4px;
|
||||
left: -4px;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
background: var(--primary);
|
||||
@@ -55,4 +66,29 @@
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Visible grip indicator (opt-in via the `withHandle` prop). Purely visual —
|
||||
// pointer events fall through to the handle so it still owns drag + reset.
|
||||
&__grip {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 26px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
background: var(--l1-background);
|
||||
color: var(--l2-foreground);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&__handle:hover &__grip,
|
||||
&__handle:active &__grip {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { GripVertical } from '@signozhq/icons';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import './ResizableBox.styles.scss';
|
||||
@@ -11,15 +12,28 @@ export interface ResizableBoxProps {
|
||||
// resize (width). Dragging the handle away from the content grows the box;
|
||||
// dragging it toward the content shrinks it.
|
||||
handle?: ResizableBoxHandle;
|
||||
// Canonical default size, and the target that double-click reset restores to.
|
||||
defaultHeight?: number;
|
||||
minHeight?: number;
|
||||
maxHeight?: number;
|
||||
defaultWidth?: number;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
// Starting size when different from the default (e.g. a persisted value).
|
||||
// Falls back to defaultWidth/defaultHeight when omitted, preserving the
|
||||
// behavior of callers that don't opt in.
|
||||
initialWidth?: number;
|
||||
initialHeight?: number;
|
||||
// When true, double-clicking the handle resets the size to
|
||||
// defaultWidth/defaultHeight and fires onResize with that value.
|
||||
resetToDefaultOnDoubleClick?: boolean;
|
||||
// When true, renders a visible grip indicator on the handle so it is
|
||||
// discoverable as a draggable affordance.
|
||||
withHandle?: boolean;
|
||||
onResize?: (size: number) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
handleTestId?: string;
|
||||
}
|
||||
|
||||
function ResizableBox({
|
||||
@@ -31,13 +45,22 @@ function ResizableBox({
|
||||
defaultWidth = 200,
|
||||
minWidth = 50,
|
||||
maxWidth = Infinity,
|
||||
initialWidth,
|
||||
initialHeight,
|
||||
resetToDefaultOnDoubleClick = false,
|
||||
withHandle = false,
|
||||
onResize,
|
||||
disabled = false,
|
||||
className,
|
||||
handleTestId,
|
||||
}: ResizableBoxProps): JSX.Element {
|
||||
const isHorizontal = handle === 'left' || handle === 'right';
|
||||
const isStartHandle = handle === 'top' || handle === 'left';
|
||||
const [size, setSize] = useState(isHorizontal ? defaultWidth : defaultHeight);
|
||||
const [size, setSize] = useState(
|
||||
isHorizontal
|
||||
? (initialWidth ?? defaultWidth)
|
||||
: (initialHeight ?? defaultHeight),
|
||||
);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
@@ -83,6 +106,21 @@ function ResizableBox({
|
||||
],
|
||||
);
|
||||
|
||||
const handleDoubleClick = useCallback((): void => {
|
||||
if (!resetToDefaultOnDoubleClick) {
|
||||
return;
|
||||
}
|
||||
const nextSize = isHorizontal ? defaultWidth : defaultHeight;
|
||||
setSize(nextSize);
|
||||
onResize?.(nextSize);
|
||||
}, [
|
||||
resetToDefaultOnDoubleClick,
|
||||
isHorizontal,
|
||||
defaultWidth,
|
||||
defaultHeight,
|
||||
onResize,
|
||||
]);
|
||||
|
||||
const containerStyle = disabled
|
||||
? undefined
|
||||
: isHorizontal
|
||||
@@ -99,7 +137,22 @@ function ResizableBox({
|
||||
style={containerStyle}
|
||||
>
|
||||
<div className="resizable-box__content">{children}</div>
|
||||
{!disabled && <div className={handleClass} onMouseDown={handleMouseDown} />}
|
||||
{!disabled && (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation={isHorizontal ? 'vertical' : 'horizontal'}
|
||||
className={handleClass}
|
||||
onMouseDown={handleMouseDown}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
data-testid={handleTestId}
|
||||
>
|
||||
{withHandle && (
|
||||
<span className="resizable-box__grip">
|
||||
<GripVertical size={12} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import ResizableBox from '../ResizableBox';
|
||||
|
||||
const HANDLE_TEST_ID = 'resize-handle';
|
||||
|
||||
describe('ResizableBox', () => {
|
||||
it('starts at defaultWidth when initialWidth is omitted', () => {
|
||||
render(
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={260}
|
||||
handleTestId={HANDLE_TEST_ID}
|
||||
>
|
||||
<div>content</div>
|
||||
</ResizableBox>,
|
||||
);
|
||||
|
||||
const box = screen.getByTestId(HANDLE_TEST_ID).parentElement as HTMLElement;
|
||||
expect(box.style.width).toBe('260px');
|
||||
});
|
||||
|
||||
it('starts at initialWidth when provided', () => {
|
||||
render(
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={260}
|
||||
initialWidth={340}
|
||||
handleTestId={HANDLE_TEST_ID}
|
||||
>
|
||||
<div>content</div>
|
||||
</ResizableBox>,
|
||||
);
|
||||
|
||||
const box = screen.getByTestId(HANDLE_TEST_ID).parentElement as HTMLElement;
|
||||
expect(box.style.width).toBe('340px');
|
||||
});
|
||||
|
||||
it('resets to defaultWidth and fires onResize on double-click when enabled', () => {
|
||||
const onResize = jest.fn();
|
||||
render(
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={260}
|
||||
initialWidth={480}
|
||||
onResize={onResize}
|
||||
resetToDefaultOnDoubleClick
|
||||
handleTestId={HANDLE_TEST_ID}
|
||||
>
|
||||
<div>content</div>
|
||||
</ResizableBox>,
|
||||
);
|
||||
|
||||
const handle = screen.getByTestId(HANDLE_TEST_ID);
|
||||
const box = handle.parentElement as HTMLElement;
|
||||
expect(box.style.width).toBe('480px');
|
||||
|
||||
fireEvent.doubleClick(handle);
|
||||
|
||||
expect(box.style.width).toBe('260px');
|
||||
expect(onResize).toHaveBeenCalledWith(260);
|
||||
});
|
||||
|
||||
it('does nothing on double-click when reset is not enabled', () => {
|
||||
const onResize = jest.fn();
|
||||
render(
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={260}
|
||||
initialWidth={480}
|
||||
onResize={onResize}
|
||||
handleTestId={HANDLE_TEST_ID}
|
||||
>
|
||||
<div>content</div>
|
||||
</ResizableBox>,
|
||||
);
|
||||
|
||||
const handle = screen.getByTestId(HANDLE_TEST_ID);
|
||||
const box = handle.parentElement as HTMLElement;
|
||||
|
||||
fireEvent.doubleClick(handle);
|
||||
|
||||
expect(box.style.width).toBe('480px');
|
||||
expect(onResize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders a visible grip only when withHandle is set', () => {
|
||||
const { rerender, container } = render(
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={260}
|
||||
handleTestId={HANDLE_TEST_ID}
|
||||
>
|
||||
<div>content</div>
|
||||
</ResizableBox>,
|
||||
);
|
||||
expect(container.querySelector('.resizable-box__grip')).toBeNull();
|
||||
|
||||
rerender(
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={260}
|
||||
withHandle
|
||||
handleTestId={HANDLE_TEST_ID}
|
||||
>
|
||||
<div>content</div>
|
||||
</ResizableBox>,
|
||||
);
|
||||
expect(container.querySelector('.resizable-box__grip')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('clamps drag to maxWidth and reports the clamped size via onResize', () => {
|
||||
const onResize = jest.fn();
|
||||
render(
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={260}
|
||||
minWidth={240}
|
||||
maxWidth={500}
|
||||
onResize={onResize}
|
||||
handleTestId={HANDLE_TEST_ID}
|
||||
>
|
||||
<div>content</div>
|
||||
</ResizableBox>,
|
||||
);
|
||||
|
||||
const handle = screen.getByTestId(HANDLE_TEST_ID);
|
||||
const box = handle.parentElement as HTMLElement;
|
||||
|
||||
fireEvent.mouseDown(handle, { clientX: 0 });
|
||||
fireEvent.mouseMove(document, { clientX: 1000 });
|
||||
fireEvent.mouseUp(document);
|
||||
|
||||
expect(box.style.width).toBe('500px');
|
||||
expect(onResize).toHaveBeenLastCalledWith(500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import setLocalStorageKey from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
import usePanelWidth from '../usePanelWidth';
|
||||
|
||||
jest.mock('api/browser/localstorage/get');
|
||||
jest.mock('api/browser/localstorage/set');
|
||||
|
||||
const mockedGet = getLocalStorageKey as jest.MockedFunction<
|
||||
typeof getLocalStorageKey
|
||||
>;
|
||||
const mockedSet = setLocalStorageKey as jest.MockedFunction<
|
||||
typeof setLocalStorageKey
|
||||
>;
|
||||
|
||||
const ARGS = {
|
||||
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_LOGS,
|
||||
defaultWidth: 260,
|
||||
minWidth: 240,
|
||||
maxWidth: 500,
|
||||
};
|
||||
|
||||
describe('usePanelWidth', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns defaultWidth when nothing is persisted', () => {
|
||||
mockedGet.mockReturnValue(null);
|
||||
const { result } = renderHook(() => usePanelWidth(ARGS));
|
||||
expect(result.current.initialWidth).toBe(260);
|
||||
});
|
||||
|
||||
it('returns the persisted width when present', () => {
|
||||
mockedGet.mockReturnValue('340');
|
||||
const { result } = renderHook(() => usePanelWidth(ARGS));
|
||||
expect(result.current.initialWidth).toBe(340);
|
||||
});
|
||||
|
||||
it('clamps an out-of-bounds persisted width on read', () => {
|
||||
mockedGet.mockReturnValue('9999');
|
||||
const { result } = renderHook(() => usePanelWidth(ARGS));
|
||||
expect(result.current.initialWidth).toBe(500);
|
||||
});
|
||||
|
||||
it('falls back to defaultWidth for an invalid persisted value', () => {
|
||||
mockedGet.mockReturnValue('not-a-number');
|
||||
const { result } = renderHook(() => usePanelWidth(ARGS));
|
||||
expect(result.current.initialWidth).toBe(260);
|
||||
});
|
||||
|
||||
it('persists a clamped width (debounced)', () => {
|
||||
mockedGet.mockReturnValue(null);
|
||||
const { result } = renderHook(() => usePanelWidth(ARGS));
|
||||
|
||||
act(() => {
|
||||
result.current.persistWidth(320);
|
||||
jest.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
expect(mockedSet).toHaveBeenCalledWith(
|
||||
LOCALSTORAGE.QUICK_FILTERS_WIDTH_LOGS,
|
||||
'320',
|
||||
);
|
||||
});
|
||||
|
||||
it('clamps below-min widths before persisting', () => {
|
||||
mockedGet.mockReturnValue(null);
|
||||
const { result } = renderHook(() => usePanelWidth(ARGS));
|
||||
|
||||
act(() => {
|
||||
result.current.persistWidth(10);
|
||||
jest.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
expect(mockedSet).toHaveBeenCalledWith(
|
||||
LOCALSTORAGE.QUICK_FILTERS_WIDTH_LOGS,
|
||||
'240',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import setLocalStorageKey from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import debounce from 'lodash-es/debounce';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
|
||||
const PERSIST_DEBOUNCE_MS = 150;
|
||||
|
||||
interface UsePanelWidthArgs {
|
||||
/** Per-page localStorage key the width is persisted under. */
|
||||
storageKey: LOCALSTORAGE;
|
||||
/** Canonical default width, used when nothing is persisted. */
|
||||
defaultWidth: number;
|
||||
minWidth: number;
|
||||
maxWidth: number;
|
||||
}
|
||||
|
||||
interface UsePanelWidthReturn {
|
||||
/** Width to start from: the persisted value (clamped) or the default. */
|
||||
initialWidth: number;
|
||||
/** Clamp and persist a width. Debounced to avoid a write per mousemove. */
|
||||
persistWidth: (width: number) => void;
|
||||
}
|
||||
|
||||
const clamp = (value: number, min: number, max: number): number =>
|
||||
Math.min(max, Math.max(min, value));
|
||||
|
||||
/**
|
||||
* Per-page localStorage persistence for a resizable panel width. Mirrors the
|
||||
* getLocalStorageKey/setLocalStorageKey idiom used for the trace span-details
|
||||
* panel position. Pairs with ResizableBox: feed initialWidth into its
|
||||
* initialWidth prop and persistWidth into its onResize.
|
||||
*/
|
||||
function usePanelWidth({
|
||||
storageKey,
|
||||
defaultWidth,
|
||||
minWidth,
|
||||
maxWidth,
|
||||
}: UsePanelWidthArgs): UsePanelWidthReturn {
|
||||
// Read once on mount. Kept in a ref so a re-render doesn't re-read storage.
|
||||
const initialWidthRef = useRef<number | null>(null);
|
||||
if (initialWidthRef.current === null) {
|
||||
const stored = getLocalStorageKey(storageKey);
|
||||
const parsed = stored !== null && stored !== '' ? Number(stored) : NaN;
|
||||
initialWidthRef.current = Number.isFinite(parsed)
|
||||
? clamp(parsed, minWidth, maxWidth)
|
||||
: defaultWidth;
|
||||
}
|
||||
|
||||
const debouncedWrite = useMemo(
|
||||
() =>
|
||||
debounce((width: number): void => {
|
||||
setLocalStorageKey(storageKey, String(width));
|
||||
}, PERSIST_DEBOUNCE_MS),
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
const persistWidth = useCallback(
|
||||
(width: number): void => {
|
||||
debouncedWrite(clamp(width, minWidth, maxWidth));
|
||||
},
|
||||
[debouncedWrite, minWidth, maxWidth],
|
||||
);
|
||||
|
||||
return { initialWidth: initialWidthRef.current, persistWidth };
|
||||
}
|
||||
|
||||
export default usePanelWidth;
|
||||
66
frontend/src/utils/__tests__/linkifyText.test.tsx
Normal file
66
frontend/src/utils/__tests__/linkifyText.test.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { linkifyText } from '../linkifyText';
|
||||
|
||||
describe('linkifyText', () => {
|
||||
it('returns plain text unchanged when there are no links', () => {
|
||||
render(<div>{linkifyText('just a plain description')}</div>);
|
||||
|
||||
expect(screen.getByText('just a plain description')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('wraps an http(s) URL in an anchor that opens in a new tab', () => {
|
||||
render(<div>{linkifyText('see https://signoz.io/docs for more')}</div>);
|
||||
|
||||
const link = screen.getByRole('link', { name: 'https://signoz.io/docs' });
|
||||
expect(link).toHaveAttribute('href', 'https://signoz.io/docs');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
|
||||
it('prefixes bare www. links with https://', () => {
|
||||
render(<div>{linkifyText('visit www.signoz.io')}</div>);
|
||||
|
||||
const link = screen.getByRole('link', { name: 'www.signoz.io' });
|
||||
expect(link).toHaveAttribute('href', 'https://www.signoz.io');
|
||||
});
|
||||
|
||||
it('keeps trailing punctuation outside the link', () => {
|
||||
render(<div>{linkifyText('read https://signoz.io.')}</div>);
|
||||
|
||||
const link = screen.getByRole('link', { name: 'https://signoz.io' });
|
||||
expect(link).toHaveAttribute('href', 'https://signoz.io');
|
||||
});
|
||||
|
||||
it('linkifies multiple URLs in the same string', () => {
|
||||
render(
|
||||
<div>{linkifyText('a https://one.com and b https://two.com end')}</div>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByRole('link')).toHaveLength(2);
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'https://one.com' }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'https://two.com' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('preserves newlines around a link', () => {
|
||||
const { container } = render(
|
||||
<div>{linkifyText('line one\nsee https://signoz.io\nline three')}</div>,
|
||||
);
|
||||
|
||||
expect(container.textContent).toBe(
|
||||
'line one\nsee https://signoz.io\nline three',
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'https://signoz.io' }),
|
||||
).toHaveAttribute('href', 'https://signoz.io');
|
||||
});
|
||||
|
||||
it('returns an empty string unchanged', () => {
|
||||
expect(linkifyText('')).toBe('');
|
||||
});
|
||||
});
|
||||
71
frontend/src/utils/linkifyText.tsx
Normal file
71
frontend/src/utils/linkifyText.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Fragment, MouseEvent, ReactNode } from 'react';
|
||||
|
||||
/** Matches http(s) URLs and bare www. links up to the next whitespace. */
|
||||
const URL_REGEX = /((?:https?:\/\/|www\.)[^\s]+)/gi;
|
||||
/** Trailing punctuation that is almost never part of the intended URL. */
|
||||
const TRAILING_PUNCTUATION = /[.,;:!?)\]}'"]+$/;
|
||||
|
||||
const stopPropagation = (
|
||||
event: MouseEvent<HTMLAnchorElement, globalThis.MouseEvent>,
|
||||
): void => {
|
||||
// Prevent parent click listeners (e.g. title edit) from firing.
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
/**
|
||||
* Splits `text` into plain-text and anchor segments, wrapping any detected
|
||||
* URL in an anchor that opens in a new tab. Trailing punctuation is kept
|
||||
* outside the link so sentences like "see https://signoz.io." stay clean.
|
||||
*/
|
||||
export function linkifyText(text: string): ReactNode {
|
||||
if (!text) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const segments: ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let key = 0;
|
||||
|
||||
const matches = text.matchAll(URL_REGEX);
|
||||
for (const match of matches) {
|
||||
const matchStart = match.index ?? 0;
|
||||
const rawUrl = match[0];
|
||||
|
||||
const trailing = rawUrl.match(TRAILING_PUNCTUATION)?.[0] ?? '';
|
||||
const url = trailing ? rawUrl.slice(0, -trailing.length) : rawUrl;
|
||||
const href = url.startsWith('www.') ? `https://${url}` : url;
|
||||
|
||||
if (matchStart > lastIndex) {
|
||||
segments.push(
|
||||
<Fragment key={key}>{text.slice(lastIndex, matchStart)}</Fragment>,
|
||||
);
|
||||
key += 1;
|
||||
}
|
||||
|
||||
segments.push(
|
||||
<a
|
||||
key={key}
|
||||
href={href}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
onClick={stopPropagation}
|
||||
>
|
||||
{url}
|
||||
</a>,
|
||||
);
|
||||
key += 1;
|
||||
|
||||
if (trailing) {
|
||||
segments.push(<Fragment key={key}>{trailing}</Fragment>);
|
||||
key += 1;
|
||||
}
|
||||
|
||||
lastIndex = matchStart + rawUrl.length;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
segments.push(<Fragment key={key}>{text.slice(lastIndex)}</Fragment>);
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
type module struct {
|
||||
@@ -213,23 +214,37 @@ func (m *module) ListHosts(ctx context.Context, orgID valuer.UUID, req *inframon
|
||||
}
|
||||
|
||||
fullQueryReq := buildFullQueryRequest(req.Start, req.End, hostsFilterExpr, req.GroupBy, pageGroups, m.newListHostsQuery())
|
||||
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Compute per-group active/inactive host counts.
|
||||
// When host.name is in groupBy, each row = one host, so counts are derived
|
||||
// directly from activeHostsMap in buildHostRecords (no extra query needed).
|
||||
// When host.name is not in groupBy, we need to run an additional query to get the counts per group for the current page,
|
||||
// using the same filter expression as the main query (including user filters + page groups IN clause).
|
||||
hostCounts := make(map[string]groupHostStatusCounts)
|
||||
isHostNameInGroupBy := isKeyInGroupByAttrs(req.GroupBy, inframonitoringtypes.HostNameAttrKey)
|
||||
|
||||
var (
|
||||
queryResp *qbtypes.QueryRangeResponse
|
||||
hostCounts = make(map[string]groupHostStatusCounts)
|
||||
)
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
|
||||
return err
|
||||
})
|
||||
|
||||
if !isHostNameInGroupBy {
|
||||
hostCounts, err = m.getPerGroupHostStatusCounts(ctx, orgID, req, hostsTableMetricNamesList, pageGroups, sinceUnixMilli)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
hostCounts, err = m.getPerGroupHostStatusCounts(gCtx, orgID, req, hostsTableMetricNamesList, pageGroups, sinceUnixMilli)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp.Records = buildHostRecords(isHostNameInGroupBy, queryResp, pageGroups, req.GroupBy, metadataMap, activeHostsMap, hostCounts)
|
||||
@@ -299,23 +314,39 @@ func (m *module) ListPods(ctx context.Context, orgID valuer.UUID, req *inframoni
|
||||
}
|
||||
|
||||
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newPodsTableListQuery())
|
||||
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var (
|
||||
queryResp *qbtypes.QueryRangeResponse
|
||||
phaseCounts map[string]podPhaseCounts
|
||||
statusCounts map[string]podStatusCounts
|
||||
statusWarning *qbtypes.QueryWarnData
|
||||
restartCounts map[string]int64
|
||||
)
|
||||
|
||||
statusCounts, statusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
restartCounts, err := m.getPerGroupPodRestartCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
phaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
statusCounts, statusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
restartCounts, err = m.getPerGroupPodRestartCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -387,23 +418,39 @@ func (m *module) ListNodes(ctx context.Context, orgID valuer.UUID, req *inframon
|
||||
}
|
||||
|
||||
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newNodesTableListQuery())
|
||||
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nodeConditionCounts, err := m.getPerGroupNodeConditionCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var (
|
||||
queryResp *qbtypes.QueryRangeResponse
|
||||
nodeConditionCounts map[string]nodeConditionCounts
|
||||
podPhaseCounts map[string]podPhaseCounts
|
||||
podStatusCounts map[string]podStatusCounts
|
||||
podStatusWarning *qbtypes.QueryWarnData
|
||||
)
|
||||
|
||||
podPhaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
podStatusCounts, podStatusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
nodeConditionCounts, err = m.getPerGroupNodeConditionCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
podPhaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -475,18 +522,33 @@ func (m *module) ListNamespaces(ctx context.Context, orgID valuer.UUID, req *inf
|
||||
}
|
||||
|
||||
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newNamespacesTableListQuery())
|
||||
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var (
|
||||
queryResp *qbtypes.QueryRangeResponse
|
||||
phaseCounts map[string]podPhaseCounts
|
||||
podStatusCounts map[string]podStatusCounts
|
||||
podStatusWarning *qbtypes.QueryWarnData
|
||||
)
|
||||
|
||||
podStatusCounts, podStatusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
phaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -557,25 +619,41 @@ func (m *module) ListClusters(ctx context.Context, orgID valuer.UUID, req *infra
|
||||
}
|
||||
|
||||
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newClustersTableListQuery())
|
||||
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// With default groupBy [k8s.cluster.name], counts are bucketed per cluster;
|
||||
// with a custom groupBy, they aggregate across clusters in that group.
|
||||
nodeConditionCountsMap, err := m.getPerGroupNodeConditionCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var (
|
||||
queryResp *qbtypes.QueryRangeResponse
|
||||
nodeConditionCountsMap map[string]nodeConditionCounts
|
||||
podPhaseCountsMap map[string]podPhaseCounts
|
||||
podStatusCounts map[string]podStatusCounts
|
||||
podStatusWarning *qbtypes.QueryWarnData
|
||||
)
|
||||
|
||||
podPhaseCountsMap, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
podStatusCounts, podStatusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
nodeConditionCountsMap, err = m.getPerGroupNodeConditionCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
podPhaseCountsMap, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -730,18 +808,33 @@ func (m *module) ListDeployments(ctx context.Context, orgID valuer.UUID, req *in
|
||||
}
|
||||
|
||||
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newDeploymentsTableListQuery())
|
||||
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var (
|
||||
queryResp *qbtypes.QueryRangeResponse
|
||||
phaseCounts map[string]podPhaseCounts
|
||||
podStatusCounts map[string]podStatusCounts
|
||||
podStatusWarning *qbtypes.QueryWarnData
|
||||
)
|
||||
|
||||
podStatusCounts, podStatusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
phaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -818,20 +911,35 @@ func (m *module) ListStatefulSets(ctx context.Context, orgID valuer.UUID, req *i
|
||||
}
|
||||
|
||||
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newStatefulSetsTableListQuery())
|
||||
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Pods owned by a StatefulSet carry k8s.statefulset.name as a resource attribute,
|
||||
// so default-groupBy gives per-statefulset phase counts automatically.
|
||||
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var (
|
||||
queryResp *qbtypes.QueryRangeResponse
|
||||
phaseCounts map[string]podPhaseCounts
|
||||
podStatusCounts map[string]podStatusCounts
|
||||
podStatusWarning *qbtypes.QueryWarnData
|
||||
)
|
||||
|
||||
podStatusCounts, podStatusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
phaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -908,20 +1016,35 @@ func (m *module) ListJobs(ctx context.Context, orgID valuer.UUID, req *inframoni
|
||||
}
|
||||
|
||||
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newJobsTableListQuery())
|
||||
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Pods owned by a Job carry k8s.job.name as a resource attribute, so default-groupBy
|
||||
// gives per-job phase counts automatically.
|
||||
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var (
|
||||
queryResp *qbtypes.QueryRangeResponse
|
||||
phaseCounts map[string]podPhaseCounts
|
||||
podStatusCounts map[string]podStatusCounts
|
||||
podStatusWarning *qbtypes.QueryWarnData
|
||||
)
|
||||
|
||||
podStatusCounts, podStatusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
phaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -998,20 +1121,35 @@ func (m *module) ListDaemonSets(ctx context.Context, orgID valuer.UUID, req *inf
|
||||
}
|
||||
|
||||
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newDaemonSetsTableListQuery())
|
||||
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Pods owned by a DaemonSet carry k8s.daemonset.name as a resource attribute,
|
||||
// so default-groupBy gives per-daemonset phase counts automatically.
|
||||
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var (
|
||||
queryResp *qbtypes.QueryRangeResponse
|
||||
phaseCounts map[string]podPhaseCounts
|
||||
podStatusCounts map[string]podStatusCounts
|
||||
podStatusWarning *qbtypes.QueryWarnData
|
||||
)
|
||||
|
||||
podStatusCounts, podStatusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
if err != nil {
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
phaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
)
|
||||
|
||||
const DefaultMaxConcurrentQueries = 8
|
||||
|
||||
type SkipResourceFingerprint struct {
|
||||
Enabled bool `yaml:"enabled" mapstructure:"enabled"`
|
||||
// If count of fingerprint is above threshold, skip the fingerprint subquery and filter on main table instead.
|
||||
@@ -37,7 +39,7 @@ func newConfig() factory.Config {
|
||||
// Default values
|
||||
CacheTTL: 168 * time.Hour,
|
||||
FluxInterval: 5 * time.Minute,
|
||||
MaxConcurrentQueries: 4,
|
||||
MaxConcurrentQueries: DefaultMaxConcurrentQueries,
|
||||
SkipResourceFingerprint: SkipResourceFingerprint{
|
||||
Enabled: false,
|
||||
Threshold: 100000,
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"golang.org/x/exp/maps"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
@@ -35,20 +36,21 @@ var (
|
||||
)
|
||||
|
||||
type querier struct {
|
||||
logger *slog.Logger
|
||||
fl flagger.Flagger
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
metadataStore telemetrytypes.MetadataStore
|
||||
promEngine prometheus.Prometheus
|
||||
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
|
||||
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
|
||||
auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
|
||||
metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation]
|
||||
meterStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation]
|
||||
traceOperatorStmtBuilder qbtypes.TraceOperatorStatementBuilder
|
||||
bucketCache BucketCache
|
||||
liveDataRefresh time.Duration
|
||||
builderConfig builderConfig
|
||||
logger *slog.Logger
|
||||
fl flagger.Flagger
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
metadataStore telemetrytypes.MetadataStore
|
||||
promEngine prometheus.Prometheus
|
||||
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
|
||||
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
|
||||
auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
|
||||
metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation]
|
||||
meterStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation]
|
||||
traceOperatorStmtBuilder qbtypes.TraceOperatorStatementBuilder
|
||||
bucketCache BucketCache
|
||||
liveDataRefresh time.Duration
|
||||
builderConfig builderConfig
|
||||
maxConcurrentQueries int
|
||||
}
|
||||
|
||||
var _ Querier = (*querier)(nil)
|
||||
@@ -67,25 +69,30 @@ func New(
|
||||
bucketCache BucketCache,
|
||||
flagger flagger.Flagger,
|
||||
logTraceIDWindowPadding time.Duration,
|
||||
maxConcurrentQueries int,
|
||||
) *querier {
|
||||
querierSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/querier")
|
||||
if maxConcurrentQueries <= 0 {
|
||||
maxConcurrentQueries = DefaultMaxConcurrentQueries
|
||||
}
|
||||
return &querier{
|
||||
logger: querierSettings.Logger(),
|
||||
fl: flagger,
|
||||
telemetryStore: telemetryStore,
|
||||
metadataStore: metadataStore,
|
||||
promEngine: promEngine,
|
||||
traceStmtBuilder: traceStmtBuilder,
|
||||
logStmtBuilder: logStmtBuilder,
|
||||
auditStmtBuilder: auditStmtBuilder,
|
||||
metricStmtBuilder: metricStmtBuilder,
|
||||
meterStmtBuilder: meterStmtBuilder,
|
||||
traceOperatorStmtBuilder: traceOperatorStmtBuilder,
|
||||
bucketCache: bucketCache,
|
||||
liveDataRefresh: 5 * time.Second,
|
||||
logger: querierSettings.Logger(),
|
||||
fl: flagger,
|
||||
telemetryStore: telemetryStore,
|
||||
metadataStore: metadataStore,
|
||||
promEngine: promEngine,
|
||||
traceStmtBuilder: traceStmtBuilder,
|
||||
logStmtBuilder: logStmtBuilder,
|
||||
auditStmtBuilder: auditStmtBuilder,
|
||||
metricStmtBuilder: metricStmtBuilder,
|
||||
meterStmtBuilder: meterStmtBuilder,
|
||||
traceOperatorStmtBuilder: traceOperatorStmtBuilder,
|
||||
bucketCache: bucketCache,
|
||||
liveDataRefresh: 5 * time.Second,
|
||||
builderConfig: builderConfig{
|
||||
logTraceIDWindowPaddingMS: uint64(logTraceIDWindowPadding.Milliseconds()),
|
||||
},
|
||||
maxConcurrentQueries: maxConcurrentQueries,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -607,30 +614,40 @@ func (q *querier) run(
|
||||
return false
|
||||
}
|
||||
|
||||
for name, query := range qs {
|
||||
// Skip cache if NoCache is set, or if cache is not available
|
||||
if req.NoCache || q.bucketCache == nil || query.Fingerprint() == "" {
|
||||
if req.NoCache {
|
||||
q.logger.DebugContext(ctx, "NoCache flag set, bypassing cache", slog.String("query", name))
|
||||
} else {
|
||||
q.logger.InfoContext(ctx, "no bucket cache or fingerprint, executing query", slog.String("fingerprint", query.Fingerprint()))
|
||||
names := maps.Keys(qs)
|
||||
slices.Sort(names)
|
||||
queryResults := make([]*qbtypes.Result, len(names))
|
||||
|
||||
// sem limits how many queries run at once for this request. The same
|
||||
// limit covers the missing-range queries in executeWithCache. sem is held
|
||||
// only while a query is running, never while waiting for other
|
||||
// goroutines, so the two levels cannot deadlock.
|
||||
sem := make(chan struct{}, q.maxConcurrentQueries)
|
||||
|
||||
eg, egCtx := errgroup.WithContext(ctx)
|
||||
for i, name := range names {
|
||||
query := qs[name]
|
||||
eg.Go(func() error {
|
||||
// Skip cache if NoCache is set, or if cache is not available
|
||||
if req.NoCache || q.bucketCache == nil || query.Fingerprint() == "" {
|
||||
if req.NoCache {
|
||||
q.logger.DebugContext(egCtx, "NoCache flag set, bypassing cache", slog.String("query", name))
|
||||
} else {
|
||||
q.logger.InfoContext(egCtx, "no bucket cache or fingerprint, executing query", slog.String("fingerprint", query.Fingerprint()))
|
||||
}
|
||||
sem <- struct{}{}
|
||||
result, err := query.Execute(egCtx)
|
||||
<-sem
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
queryResults[i] = result
|
||||
return nil
|
||||
}
|
||||
result, err := query.Execute(ctx)
|
||||
qbEvent.HasData = qbEvent.HasData || hasData(result)
|
||||
|
||||
result, err := q.executeWithCache(egCtx, orgID, query, steps[name], sem)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results[name] = result.Value
|
||||
warnings = append(warnings, result.Warnings...)
|
||||
warningsDocURL = result.WarningsDocURL
|
||||
stats.RowsScanned += result.Stats.RowsScanned
|
||||
stats.BytesScanned += result.Stats.BytesScanned
|
||||
stats.DurationMS += result.Stats.DurationMS
|
||||
} else {
|
||||
result, err := q.executeWithCache(ctx, orgID, query, steps[name], req.NoCache)
|
||||
qbEvent.HasData = qbEvent.HasData || hasData(result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
switch v := result.Value.(type) {
|
||||
case *qbtypes.TimeSeriesData:
|
||||
@@ -640,14 +657,23 @@ func (q *querier) run(
|
||||
case *qbtypes.RawData:
|
||||
v.QueryName = name
|
||||
}
|
||||
queryResults[i] = result
|
||||
return nil
|
||||
})
|
||||
}
|
||||
if err := eg.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
results[name] = result.Value
|
||||
warnings = append(warnings, result.Warnings...)
|
||||
warningsDocURL = result.WarningsDocURL
|
||||
stats.RowsScanned += result.Stats.RowsScanned
|
||||
stats.BytesScanned += result.Stats.BytesScanned
|
||||
stats.DurationMS += result.Stats.DurationMS
|
||||
}
|
||||
for i, name := range names {
|
||||
result := queryResults[i]
|
||||
qbEvent.HasData = qbEvent.HasData || hasData(result)
|
||||
results[name] = result.Value
|
||||
warnings = append(warnings, result.Warnings...)
|
||||
warningsDocURL = result.WarningsDocURL
|
||||
stats.RowsScanned += result.Stats.RowsScanned
|
||||
stats.BytesScanned += result.Stats.BytesScanned
|
||||
stats.DurationMS += result.Stats.DurationMS
|
||||
}
|
||||
|
||||
gomaps.Copy(results, preseededResults)
|
||||
@@ -707,8 +733,9 @@ func (q *querier) run(
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// executeWithCache executes a query using the bucket cache.
|
||||
func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query qbtypes.Query, step qbtypes.Step, _ bool) (*qbtypes.Result, error) {
|
||||
// executeWithCache executes a query using the bucket cache. sem limits how
|
||||
// many queries run at once for the whole request.
|
||||
func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query qbtypes.Query, step qbtypes.Step, sem chan struct{}) (*qbtypes.Result, error) {
|
||||
// Get cached data and missing ranges
|
||||
cachedResult, missingRanges := q.bucketCache.GetMissRanges(ctx, orgID, query, step)
|
||||
|
||||
@@ -721,7 +748,9 @@ func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query
|
||||
if cachedResult == nil && len(missingRanges) == 1 {
|
||||
startMs, endMs := query.Window()
|
||||
if missingRanges[0].From == startMs && missingRanges[0].To == endMs {
|
||||
sem <- struct{}{}
|
||||
result, err := query.Execute(ctx)
|
||||
<-sem
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -740,7 +769,6 @@ func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query
|
||||
slog.Int("missing_ranges_count", len(missingRanges)),
|
||||
slog.Any("ranges", missingRanges))
|
||||
|
||||
sem := make(chan struct{}, 4)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i, timeRange := range missingRanges {
|
||||
@@ -777,7 +805,9 @@ func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query
|
||||
if err != nil {
|
||||
// If any query failed, fall back to full execution
|
||||
q.logger.ErrorContext(ctx, "parallel query execution failed", errors.Attr(err))
|
||||
sem <- struct{}{}
|
||||
result, err := query.Execute(ctx)
|
||||
<-sem
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@ package querier
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
cmock "github.com/SigNoz/clickhouse-go-mock"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
@@ -54,7 +56,8 @@ func TestQueryRange_MetricTypeMissing(t *testing.T) {
|
||||
nil, // traceOperatorStmtBuilder
|
||||
nil, // bucketCache
|
||||
flaggertest.New(t), // flagger
|
||||
0,
|
||||
0, // logTraceIDWindowPadding
|
||||
0, // maxConcurrentQueries
|
||||
)
|
||||
|
||||
req := &qbtypes.QueryRangeRequest{
|
||||
@@ -125,7 +128,8 @@ func TestQueryRange_MetricTypeFromStore(t *testing.T) {
|
||||
nil, // traceOperatorStmtBuilder
|
||||
nil, // bucketCache
|
||||
flaggertest.New(t), // flagger
|
||||
0,
|
||||
0, // logTraceIDWindowPadding
|
||||
0, // maxConcurrentQueries
|
||||
)
|
||||
|
||||
req := &qbtypes.QueryRangeRequest{
|
||||
@@ -155,3 +159,149 @@ func TestQueryRange_MetricTypeFromStore(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
}
|
||||
|
||||
type fakeQuery struct {
|
||||
execute func(ctx context.Context) (*qbtypes.Result, error)
|
||||
}
|
||||
|
||||
func (f *fakeQuery) Fingerprint() string { return "" }
|
||||
func (f *fakeQuery) Window() (uint64, uint64) { return 0, 0 }
|
||||
func (f *fakeQuery) Execute(ctx context.Context) (*qbtypes.Result, error) { return f.execute(ctx) }
|
||||
|
||||
func chQueryEnvelopes(names []string) []qbtypes.QueryEnvelope {
|
||||
envelopes := make([]qbtypes.QueryEnvelope, 0, len(names))
|
||||
for _, name := range names {
|
||||
envelopes = append(envelopes, qbtypes.QueryEnvelope{
|
||||
Type: qbtypes.QueryTypeClickHouseSQL,
|
||||
Spec: qbtypes.ClickHouseQuery{Name: name},
|
||||
})
|
||||
}
|
||||
return envelopes
|
||||
}
|
||||
|
||||
func TestRunExecutesQueriesConcurrently(t *testing.T) {
|
||||
names := []string{"A", "B", "C", "D", "E"}
|
||||
numQueries := len(names)
|
||||
|
||||
q := &querier{
|
||||
logger: instrumentationtest.New().Logger(),
|
||||
maxConcurrentQueries: numQueries,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var started atomic.Int32
|
||||
allStarted := make(chan struct{})
|
||||
|
||||
qs := make(map[string]qbtypes.Query, numQueries)
|
||||
for _, name := range names {
|
||||
qs[name] = &fakeQuery{execute: func(ctx context.Context) (*qbtypes.Result, error) {
|
||||
if int(started.Add(1)) == numQueries {
|
||||
close(allStarted)
|
||||
}
|
||||
select {
|
||||
case <-allStarted:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return &qbtypes.Result{
|
||||
Type: qbtypes.RequestTypeScalar,
|
||||
Value: &qbtypes.ScalarData{QueryName: name},
|
||||
Stats: qbtypes.ExecStats{RowsScanned: 1, BytesScanned: 2, DurationMS: 3},
|
||||
}, nil
|
||||
}}
|
||||
}
|
||||
|
||||
req := &qbtypes.QueryRangeRequest{
|
||||
RequestType: qbtypes.RequestTypeScalar,
|
||||
CompositeQuery: qbtypes.CompositeQuery{Queries: chQueryEnvelopes(names)},
|
||||
}
|
||||
resp, err := q.run(ctx, valuer.GenerateUUID(), qs, req, nil, &qbtypes.QBEvent{}, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assert.Len(t, resp.Data.Results, numQueries)
|
||||
assert.Equal(t, uint64(numQueries), resp.Meta.RowsScanned)
|
||||
assert.Equal(t, uint64(2*numQueries), resp.Meta.BytesScanned)
|
||||
assert.Equal(t, uint64(3*numQueries), resp.Meta.DurationMS)
|
||||
}
|
||||
|
||||
func TestRunRespectsMaxConcurrentQueries(t *testing.T) {
|
||||
const limit = 2
|
||||
names := []string{"A", "B", "C", "D", "E", "F", "G", "H"}
|
||||
|
||||
q := &querier{
|
||||
logger: instrumentationtest.New().Logger(),
|
||||
maxConcurrentQueries: limit,
|
||||
}
|
||||
|
||||
var running, maxRunning atomic.Int32
|
||||
qs := make(map[string]qbtypes.Query, len(names))
|
||||
for _, name := range names {
|
||||
qs[name] = &fakeQuery{execute: func(ctx context.Context) (*qbtypes.Result, error) {
|
||||
cur := running.Add(1)
|
||||
defer running.Add(-1)
|
||||
for {
|
||||
m := maxRunning.Load()
|
||||
if cur <= m || maxRunning.CompareAndSwap(m, cur) {
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
return &qbtypes.Result{
|
||||
Type: qbtypes.RequestTypeScalar,
|
||||
Value: &qbtypes.ScalarData{QueryName: name},
|
||||
}, nil
|
||||
}}
|
||||
}
|
||||
|
||||
req := &qbtypes.QueryRangeRequest{
|
||||
RequestType: qbtypes.RequestTypeScalar,
|
||||
CompositeQuery: qbtypes.CompositeQuery{Queries: chQueryEnvelopes(names)},
|
||||
}
|
||||
resp, err := q.run(context.Background(), valuer.GenerateUUID(), qs, req, nil, &qbtypes.QBEvent{}, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assert.Len(t, resp.Data.Results, len(names))
|
||||
assert.LessOrEqual(t, maxRunning.Load(), int32(limit), "running queries must not exceed maxConcurrentQueries")
|
||||
}
|
||||
|
||||
func TestRunQueryErrorCancelsSiblings(t *testing.T) {
|
||||
q := &querier{
|
||||
logger: instrumentationtest.New().Logger(),
|
||||
maxConcurrentQueries: 4,
|
||||
}
|
||||
|
||||
bStarted := make(chan struct{})
|
||||
var bCanceled atomic.Bool
|
||||
|
||||
qs := map[string]qbtypes.Query{
|
||||
// fails once B is running.
|
||||
"A": &fakeQuery{execute: func(ctx context.Context) (*qbtypes.Result, error) {
|
||||
select {
|
||||
case <-bStarted:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
return nil, errors.NewInternalf(errors.CodeInternal, "query A failed")
|
||||
}},
|
||||
// blocks until its context is canceled by A's failure.
|
||||
"B": &fakeQuery{execute: func(ctx context.Context) (*qbtypes.Result, error) {
|
||||
close(bStarted)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
bCanceled.Store(true)
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(10 * time.Second):
|
||||
return nil, errors.NewInternalf(errors.CodeInternal, "query B was never canceled")
|
||||
}
|
||||
}},
|
||||
}
|
||||
|
||||
req := &qbtypes.QueryRangeRequest{
|
||||
RequestType: qbtypes.RequestTypeScalar,
|
||||
CompositeQuery: qbtypes.CompositeQuery{Queries: chQueryEnvelopes([]string{"A", "B"})},
|
||||
}
|
||||
_, err := q.run(context.Background(), valuer.GenerateUUID(), qs, req, nil, &qbtypes.QBEvent{}, nil)
|
||||
require.ErrorContains(t, err, "query A failed")
|
||||
assert.True(t, bCanceled.Load(), "query B should be canceled once query A fails")
|
||||
}
|
||||
|
||||
@@ -193,5 +193,6 @@ func newProvider(
|
||||
bucketCache,
|
||||
flagger,
|
||||
cfg.LogTraceIDWindowPadding,
|
||||
cfg.MaxConcurrentQueries,
|
||||
), nil
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ func prepareQuerierForMetrics(t *testing.T, telemetryStore telemetrystore.Teleme
|
||||
nil, // bucketCache
|
||||
flagger,
|
||||
0,
|
||||
0, // maxConcurrentQueries (0 means default)
|
||||
), metadataStore
|
||||
}
|
||||
|
||||
@@ -110,6 +111,7 @@ func prepareQuerierForLogs(t *testing.T, telemetryStore telemetrystore.Telemetry
|
||||
nil, // bucketCache
|
||||
fl,
|
||||
5*time.Minute, // logTraceIDWindowPadding
|
||||
0, // maxConcurrentQueries (0 means default)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -158,5 +160,6 @@ func prepareQuerierForTraces(t *testing.T, telemetryStore telemetrystore.Telemet
|
||||
nil, // bucketCache
|
||||
fl,
|
||||
0,
|
||||
0, // maxConcurrentQueries (0 means default)
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user