Compare commits

..

4 Commits

Author SHA1 Message Date
dependabot[bot]
46e092b2c9 chore(deps): bump cryptography from 46.0.7 to 48.0.1 in /tests (#11736)
Some checks are pending
build-staging / js-build (push) Blocked by required conditions
build-staging / prepare (push) Waiting to run
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
Bumps [cryptography](https://github.com/pyca/cryptography) from 46.0.7 to 48.0.1.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/46.0.7...48.0.1)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 48.0.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-08 05:24:02 +00:00
Ashwin Bhatkal
fe660e27a2 feat(dashboard-v2): gate list-page edit actions on edit permission (#12021) 2026-07-08 05:13:03 +00:00
Ashwin Bhatkal
465620fbbb feat(dashboard-v2): enforce read-only behavior when a dashboard is locked (#12017)
* feat(dashboard-v2): lock-aware edit context, mutation guard, tooltip primitives & locked indicator, skip chart refetch on lock toggle

* feat(dashboard-v2): hide toolbar edit actions (incl. clone) in view mode, disable with a hover reason when locked

* feat(dashboard-v2): hide panel & section edit actions in view mode, disable when locked

* feat(dashboard-v2): open the JSON editor read-only when not editable

* feat(dashboard-v2): disable panel editor save when not editable
2026-07-08 05:12:31 +00:00
Aditya Singh
db2e29dde9 feat(trace-details): Pretty View interaction polish (hover, search, actions, z-index) (#11940)
* feat(trace-details): restyle Pretty View attributes search bar

* feat(trace-details): full-width row hover in Pretty View

* feat(trace-details): keep Pretty View row active and brighten value on hover/menu-open

* feat(trace-details): move parent-row ellipsis to the right edge

* fix(cmdk): raise command palette above the floating span-details panel

* feat: use toggle group for data viewer instead of dropdown

* fix(trace-details): disable Monaco sticky scroll in JSON view to stop overlap
2026-07-08 04:55:24 +00:00
76 changed files with 1182 additions and 2060 deletions

View File

@@ -1,12 +1,12 @@
/* Overlay stays below content */
[data-slot='dialog-overlay'] {
z-index: 50;
z-index: 1000 !important;
}
/* Dialog content always above overlay */
[data-slot='dialog-content'] {
position: fixed;
z-index: 60;
z-index: 1001 !important;
background: var(--l1-background);
color: var(--l1-foreground);

View File

@@ -30,6 +30,4 @@ export const useGetFieldKeys = ({
queryKey: ['fieldKeys', signal, name],
queryFn: () => getFieldKeys(signal, name),
enabled,
// Keep prior keys during a search refetch so the dropdown doesn't blank out.
keepPreviousData: true,
});

View File

@@ -5,9 +5,3 @@
justify-content: flex-end;
gap: 12px;
}
/* Actions / Configure / Edit-as-JSON: a flat l3 outline — no depth/shadow. */
.toolbarButton {
border: 1px solid var(--l3-border) !important;
box-shadow: none !important;
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { type ReactNode, useCallback, useMemo, useState } from 'react';
import { FullScreenHandle } from 'react-full-screen';
import { generatePath } from 'react-router-dom';
import {
@@ -29,12 +29,15 @@ import APIError from 'types/api/error';
import { USER_ROLES } from 'types/roles';
import ConfirmDeleteDialog from '../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
import DisabledControlTooltip from '../../components/DisabledControlTooltip/DisabledControlTooltip';
import DisabledMenuItemLabel from '../../components/DisabledMenuItemLabel/DisabledMenuItemLabel';
import DashboardSettings from '../../DashboardSettings';
import { useAddSection } from '../../PanelsAndSectionsLayout/Section/hooks/useAddSection';
import SectionTitleModal from '../../PanelsAndSectionsLayout/Section/SectionTitleModal';
import JsonEditorDrawer from '../JsonEditorDrawer/JsonEditorDrawer';
import SettingsDrawer from '../SettingsDrawer';
import styles from './DashboardActions.module.scss';
import { DASHBOARD_LOCKED_REASON } from '../../hooks/useDashboardEditGuard';
import { useDashboardStore } from '../../store/useDashboardStore';
interface DashboardActionsProps {
@@ -58,9 +61,8 @@ function DashboardActions({
onLockToggle,
onOpenRename,
}: DashboardActionsProps): JSX.Element {
const canEdit = useDashboardStore((s) => s.isEditable);
const settingsRequest = useDashboardStore((s) => s.settingsRequest);
const clearSettingsRequest = useDashboardStore((s) => s.clearSettingsRequest);
const canEditDashboard = useDashboardStore((s) => s.canEditDashboard);
const isLocked = useDashboardStore((s) => s.isLocked);
const { user } = useAppContext();
const { safeNavigate } = useSafeNavigate();
const { showErrorModal } = useErrorModal();
@@ -74,14 +76,6 @@ function DashboardActions({
const [isDeleteOpen, setIsDeleteOpen] = useState<boolean>(false);
const deleteDashboardMutation = useDeleteDashboard(dashboard.id);
// Open the settings drawer when something in the tree requests it (e.g. the
// variables bar's "Add variable" button).
useEffect(() => {
if (settingsRequest) {
setIsSettingsDrawerOpen(true);
}
}, [settingsRequest]);
const { addSection, isSaving: isAddingSection } = useAddSection({
layouts: dashboard.spec.layouts,
});
@@ -121,23 +115,38 @@ function DashboardActions({
});
}, [deleteDashboardMutation]);
// Shown only to edit-permitted users, so the only disabled reason is the lock.
const editLabel = useCallback(
(text: string): ReactNode =>
isLocked ? (
<DisabledMenuItemLabel reason={DASHBOARD_LOCKED_REASON}>
{text}
</DisabledMenuItemLabel>
) : (
text
),
[isLocked],
);
const menuItems = useMemo<MenuItem[]>(() => {
const dashboardGroup: MenuItem[] = [];
if (canEdit) {
if (canEditDashboard) {
dashboardGroup.push({
key: 'rename',
label: 'Rename',
label: editLabel('Rename'),
icon: <PenLine size={14} />,
disabled: isLocked,
onClick: onOpenRename,
});
// Clone creates a new dashboard, so it's not lock-gated.
dashboardGroup.push({
key: 'clone',
label: 'Clone dashboard',
icon: <Copy size={14} />,
disabled: isCloning,
onClick: (): void => void handleClone(),
});
}
dashboardGroup.push({
key: 'clone',
label: 'Clone dashboard',
icon: <Copy size={14} />,
disabled: isCloning,
onClick: (): void => void handleClone(),
});
if (isAuthor || user.role === USER_ROLES.ADMIN) {
dashboardGroup.push({
key: 'lock',
@@ -154,16 +163,6 @@ function DashboardActions({
onClick: handle.enter,
});
const layoutGroup: MenuItem[] = [];
if (canEdit) {
layoutGroup.push({
key: 'new-section',
label: 'New section',
icon: <SquareStack size={14} />,
onClick: (): void => setIsNewSectionOpen(true),
});
}
const items: MenuItem[] = [
{
type: 'group',
@@ -172,27 +171,39 @@ function DashboardActions({
children: dashboardGroup,
},
];
if (layoutGroup.length > 0) {
// Omit the whole Layout group (header included) in view mode.
if (canEditDashboard) {
items.push({
type: 'group',
key: 'group-layout',
label: 'Layout',
children: layoutGroup,
children: [
{
key: 'new-section',
label: editLabel('New section'),
icon: <SquareStack size={14} />,
disabled: isLocked,
onClick: (): void => setIsNewSectionOpen(true),
},
],
});
items.push(
{ type: 'divider', key: 'divider-danger' },
{
key: 'delete',
label: editLabel('Delete dashboard'),
icon: <Trash2 size={14} />,
danger: true,
disabled: isLocked,
onClick: (): void => setIsDeleteOpen(true),
},
);
}
items.push(
{ type: 'divider', key: 'divider-danger' },
{
key: 'delete',
label: 'Delete dashboard',
icon: <Trash2 size={14} />,
danger: true,
onClick: (): void => setIsDeleteOpen(true),
},
);
return items;
}, [
canEdit,
editLabel,
canEditDashboard,
isLocked,
isCloning,
isAuthor,
user.role,
@@ -211,34 +222,34 @@ function DashboardActions({
variant="solid"
color="secondary"
size="md"
className={styles.toolbarButton}
prefix={<Grid3X3 size="md" />}
testId="options"
>
Actions
</Button>
</DropdownMenuSimple>
{canEdit && (
{canEditDashboard && (
<>
<Button
variant="solid"
color="secondary"
className={styles.toolbarButton}
prefix={<Configure size="md" />}
testId="show-drawer"
onClick={(): void => setIsSettingsDrawerOpen(true)}
size="md"
<DisabledControlTooltip
reason={DASHBOARD_LOCKED_REASON}
disabled={isLocked}
>
Configure
</Button>
<Button
variant="solid"
color="secondary"
prefix={<Configure size="md" />}
testId="show-drawer"
disabled={isLocked}
onClick={(): void => setIsSettingsDrawerOpen(true)}
size="md"
>
Configure
</Button>
</DisabledControlTooltip>
<SettingsDrawer
drawerTitle="Dashboard Configuration"
isOpen={isSettingsDrawerOpen}
destroyOnClose
onClose={(): void => {
setIsSettingsDrawerOpen(false);
clearSettingsRequest();
}}
onClose={(): void => setIsSettingsDrawerOpen(false)}
>
<DashboardSettings dashboard={dashboard} />
</SettingsDrawer>
@@ -247,7 +258,6 @@ function DashboardActions({
<Button
variant="solid"
color="secondary"
className={styles.toolbarButton}
prefix={<Braces size="md" />}
testId="edit-json"
onClick={(): void => setIsJsonEditorOpen(true)}
@@ -255,17 +265,23 @@ function DashboardActions({
>
JSON
</Button>
{!isDashboardLocked && (
<Button
variant="solid"
color="primary"
onClick={onAddPanel}
prefix={<Plus size="md" />}
testId="add-panel-header"
size="md"
{canEditDashboard && (
<DisabledControlTooltip
reason={DASHBOARD_LOCKED_REASON}
disabled={isLocked}
>
New Panel
</Button>
<Button
variant="solid"
color="primary"
onClick={onAddPanel}
prefix={<Plus size="md" />}
testId="add-panel-header"
disabled={isLocked}
size="md"
>
New Panel
</Button>
</DisabledControlTooltip>
)}
<JsonEditorDrawer
dashboard={dashboard}

View File

@@ -14,6 +14,8 @@ import { defineJsonEditorTheme, JSON_EDITOR_THEME } from './editorTheme';
import styles from './JsonEditorDrawer.module.scss';
import JsonEditorToolbar from './JsonEditorToolbar';
import { useJsonEditor } from './useJsonEditor';
import DisabledControlTooltip from '../../components/DisabledControlTooltip/DisabledControlTooltip';
import { useDashboardStore } from '../../store/useDashboardStore';
interface JsonEditorDrawerProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
@@ -28,6 +30,11 @@ function JsonEditorDrawer({
}: JsonEditorDrawerProps): JSX.Element {
const [, copyToClipboard] = useCopyToClipboard();
const isEditable = useDashboardStore((s) => s.isEditable);
const readOnlyReason = useDashboardStore((s) => s.editDisabledReason);
// Inspect-only when not editable: Apply/Format/Reset disabled.
const readOnly = !isEditable;
const {
draft,
setDraft,
@@ -39,7 +46,7 @@ function JsonEditorDrawer({
format,
reset,
apply,
} = useJsonEditor({ dashboard, isOpen, onApplied: onClose });
} = useJsonEditor({ dashboard, isOpen, readOnly, onApplied: onClose });
const onCopy = useCallback((): void => {
copyToClipboard(draft);
@@ -63,13 +70,15 @@ function JsonEditorDrawer({
event.stopPropagation();
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault();
void apply();
if (!readOnly) {
void apply();
}
}
},
[apply],
[apply, readOnly],
);
const applyDisabled = !isDirty || !validity.valid || isSaving;
const applyDisabled = readOnly || !isDirty || !validity.valid || isSaving;
const validationText = validity.valid
? `Valid JSON · ${validity.lineCount} lines`
: `Line ${validity.errorLine ?? '?'} · ${validity.message ?? 'Invalid JSON'}`;
@@ -150,16 +159,18 @@ function JsonEditorDrawer({
>
Cancel
</Button>
<Button
variant="solid"
color="primary"
size="md"
testId="json-editor-apply"
disabled={applyDisabled}
onClick={(): void => void apply()}
>
Apply changes
</Button>
<DisabledControlTooltip reason={readOnlyReason} disabled={readOnly}>
<Button
variant="solid"
color="primary"
size="md"
testId="json-editor-apply"
disabled={applyDisabled}
onClick={readOnly ? undefined : (): void => void apply()}
>
Apply changes
</Button>
</DisabledControlTooltip>
</div>
</div>
}
@@ -168,6 +179,7 @@ function JsonEditorDrawer({
<div className={styles.body} onKeyDown={onKeyDown}>
<JsonEditorToolbar
isDirty={isDirty}
readOnly={readOnly}
onFormat={format}
onCopy={onCopy}
onDownload={onDownload}
@@ -180,6 +192,7 @@ function JsonEditorDrawer({
value={draft}
onChange={(value): void => setDraft(value ?? '')}
options={{
readOnly,
scrollbar: { alwaysConsumeMouseWheel: false },
minimap: { enabled: false },
fontSize: 13,

View File

@@ -5,6 +5,8 @@ import styles from './JsonEditorToolbar.module.scss';
interface JsonEditorToolbarProps {
isDirty: boolean;
/** Locked/no-permission — Format and Reset (draft mutators) are disabled. */
readOnly?: boolean;
onFormat: () => void;
onCopy: () => void;
onDownload: () => void;
@@ -13,6 +15,7 @@ interface JsonEditorToolbarProps {
function JsonEditorToolbar({
isDirty,
readOnly = false,
onFormat,
onCopy,
onDownload,
@@ -26,6 +29,7 @@ function JsonEditorToolbar({
size="sm"
prefix={<AlignLeft size={14} />}
testId="json-editor-format"
disabled={readOnly}
onClick={onFormat}
>
Format
@@ -57,7 +61,7 @@ function JsonEditorToolbar({
size="sm"
prefix={<RotateCcw size={14} />}
testId="json-editor-reset"
disabled={!isDirty}
disabled={readOnly || !isDirty}
onClick={onReset}
>
Reset

View File

@@ -7,6 +7,13 @@ import { useJsonEditor } from '../useJsonEditor';
jest.mock('../useJsonEditor', () => ({ useJsonEditor: jest.fn() }));
// Editable by default so the drawer renders in its editable (non-read-only) mode.
jest.mock('../../../store/useDashboardStore', () => ({
useDashboardStore: (
selector: (s: { isEditable: boolean; editDisabledReason: string }) => unknown,
): unknown => selector({ isEditable: true, editDisabledReason: '' }),
}));
jest.mock('@monaco-editor/react', () => ({
__esModule: true,
default: ({

View File

@@ -23,6 +23,8 @@ export interface JsonValidity {
interface Params {
dashboard: DashboardtypesGettableDashboardV2DTO;
isOpen: boolean;
/** Locked/no-permission — `apply` is a no-op so edits can never be saved. */
readOnly?: boolean;
onApplied: () => void;
}
@@ -77,6 +79,7 @@ function errorLineFromMessage(
export function useJsonEditor({
dashboard,
isOpen,
readOnly = false,
onApplied,
}: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
@@ -147,7 +150,7 @@ export function useJsonEditor({
}, [appliedText]);
const apply = useCallback(async (): Promise<void> => {
if (!validity.valid || !isDirty) {
if (readOnly || !validity.valid || !isDirty) {
return;
}
try {
@@ -173,6 +176,7 @@ export function useJsonEditor({
validity.valid,
isDirty,
draft,
readOnly,
refetch,
onApplied,
showErrorModal,

View File

@@ -8,8 +8,6 @@ type SettingsDrawerProps = PropsWithChildren<{
drawerTitle: string;
isOpen: boolean;
onClose: () => void;
/** Unmount the content on close so it re-initializes on the next open. */
destroyOnClose?: boolean;
}>;
function SettingsDrawer({
@@ -17,7 +15,6 @@ function SettingsDrawer({
drawerTitle,
isOpen,
onClose,
destroyOnClose = false,
}: SettingsDrawerProps): JSX.Element {
return (
<Drawer
@@ -26,7 +23,6 @@ function SettingsDrawer({
width="50%"
onClose={onClose}
open={isOpen}
destroyOnClose={destroyOnClose}
rootClassName={styles.settingsContainerRoot}
>
{/* Need to type cast because of OverlayScrollbar type definition. We should be good once we remove it. */}

View File

@@ -1,14 +1,17 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQueryClient } from 'react-query';
import { FullScreenHandle } from 'react-full-screen';
import { toast } from '@signozhq/ui/sonner';
import logEvent from 'api/common/logEvent';
import {
getGetDashboardV2QueryKey,
lockDashboardV2,
unlockDashboardV2,
} from 'api/generated/services/dashboard';
import type {
DashboardtypesGettableDashboardV2DTO,
DashboardtypesJSONPatchOperationDTO,
GetDashboardV2200,
} from 'api/generated/services/sigNoz.schemas';
import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
@@ -32,13 +35,13 @@ import styles from './DashboardPageToolbar.module.scss';
interface DashboardPageToolbarProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
handle: FullScreenHandle;
refetch: () => void;
}
function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
const { dashboard, handle, refetch } = props;
const { dashboard, handle } = props;
const id = dashboard.id;
const queryClient = useQueryClient();
// Session-local lock state: the toggle appears once locked and persists for the page.
const [isDashboardLocked, setIsDashboardLocked] = useState(!!dashboard.locked);
@@ -101,12 +104,21 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
await unlockDashboardV2({ id });
toast.success('Dashboard unlocked');
}
refetch();
// Patch just the `locked` flag in the cache — a full refetch would reload
// every panel's chart data for a metadata-only change.
const key = getGetDashboardV2QueryKey({ id });
const cached = queryClient.getQueryData<GetDashboardV2200>(key);
if (cached) {
queryClient.setQueryData<GetDashboardV2200>(key, {
...cached,
data: { ...cached.data, locked: next },
});
}
} catch (error) {
setIsDashboardLocked(!next);
showErrorModal(error as APIError);
}
}, [id, isDashboardLocked, refetch, showErrorModal]);
}, [id, isDashboardLocked, queryClient, showErrorModal]);
const onNameSave = useCallback(
async (next: string): Promise<void> => {

View File

@@ -40,7 +40,7 @@ function DynamicVariableFields({
attributeError,
}: DynamicVariableFieldsProps): JSX.Element {
const [search, setSearch] = useState('');
const debouncedSearch = useDebounce(search, 500);
const debouncedSearch = useDebounce(search, 300);
const apiSignal = signalForApi(signal);
const {
@@ -82,34 +82,17 @@ function DynamicVariableFields({
return (
<>
{/* Combined row retained from V1: the field on the left, `from` + the
telemetry source on the right. */}
<div className={cx(styles.row, styles.dynamicCombinedRow)}>
<CustomSelect
className={styles.dynamicFieldSelect}
showSearch
value={attribute || undefined}
placeholder="Select a field"
loading={isLoading}
options={options}
onSearch={setSearch}
onChange={(value): void => onChange({ dynamicAttribute: value as string })}
noDataMessage="No fields found"
errorMessage={errorMessage}
onRetry={(): void => {
void refetch();
}}
showRetryButton={error ? isRetryableError(error) : true}
data-testid="variable-field-select"
/>
<Typography.Text className={styles.fromText}>from</Typography.Text>
<TextToolTip
text="By default, this searches across logs, traces, and metrics, which can be slow. Selecting a single source improves performance. Many fields share the same values across different signals (for example, `k8s.pod.name` is identical in logs, traces and metrics) making one source enough. Only use `All telemetry` when you need fields that have different values in different signal types."
useFilledIcon={false}
outlinedIcon={<Info size={14} />}
/>
<div className={cx(styles.row, styles.sortSection)}>
<div className={cx(styles.labelContainer, styles.sourceLabel)}>
<Typography.Text className={styles.label}>Source</Typography.Text>
<TextToolTip
text="By default, this searches across logs, traces, and metrics, which can be slow. Selecting a single source improves performance. Many fields share the same values across different signals (for example, `k8s.pod.name` is identical in logs, traces and metrics) making one source enough. Only use `All telemetry` when you need fields that have different values in different signal types."
useFilledIcon={false}
outlinedIcon={<Info size={14} />}
/>
</div>
<Select
className={styles.dynamicSourceSelect}
className={styles.sortSelect}
popupMatchSelectWidth={false}
value={signal}
options={DYNAMIC_SIGNALS.map((s) => ({
@@ -122,6 +105,28 @@ function DynamicVariableFields({
data-testid="variable-signal-select"
/>
</div>
<div className={cx(styles.row, styles.sortSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>Attribute</Typography.Text>
</div>
<CustomSelect
className={styles.searchSelect}
showSearch
value={attribute || undefined}
placeholder="Select a telemetry field"
loading={isLoading}
options={options}
onSearch={setSearch}
onChange={(value): void => onChange({ dynamicAttribute: value as string })}
noDataMessage="No fields found"
errorMessage={errorMessage}
onRetry={(): void => {
void refetch();
}}
showRetryButton={error ? isRetryableError(error) : true}
data-testid="variable-field-select"
/>
</div>
{attributeError ? (
<Typography.Text className={styles.errorText}>
{attributeError}

View File

@@ -138,7 +138,7 @@
align-items: center;
gap: 6px;
min-height: 24px;
padding: 4px 16px !important;
padding: 6px 14px;
white-space: nowrap;
border-radius: 0;
color: var(--l2-foreground);
@@ -157,7 +157,6 @@
.betaTag {
margin-left: 4px;
vertical-align: middle;
}
/* Query */
@@ -265,7 +264,6 @@
flex-flow: wrap;
gap: 8px;
padding: 4.5px 11px;
max-height: 120px;
overflow-y: auto;
}
@@ -330,54 +328,16 @@
/* All variable selects (Source / Attribute / Sort / Default Value) share width
and a consistent --l2-border outline. */
.sortSelect,
.searchSelect,
.dynamicFieldSelect,
.dynamicSourceSelect {
:global(.ant-select-selector) {
border-color: var(--l2-border) !important;
}
}
.sortSelect,
.searchSelect {
width: 240px;
flex-shrink: 0;
// The clear (X) is an SVG, so antd's font-glyph offset mis-centers it — center
// it on the row and make it clearly visible.
:global(.ant-select-clear) {
display: flex;
align-items: center;
justify-content: center;
margin-top: 0;
transform: translateY(-50%);
color: var(--l2-foreground);
opacity: 1;
:global(.ant-select-selector) {
border-color: var(--l2-border) !important;
}
}
/* Dynamic variable: field | "from" | info | source, retained from V1. */
.dynamicCombinedRow {
display: grid;
grid-template-columns: 1fr auto 16px 180px;
gap: 12px;
align-items: center;
}
.dynamicFieldSelect,
.dynamicSourceSelect {
width: 100%;
min-width: 0;
}
.fromText {
font-family: 'Space Mono';
font-size: 13px;
font-weight: var(--font-weight-medium);
white-space: nowrap;
}
.actionButtons {
width: 100%;
display: flex;

View File

@@ -1,4 +1,3 @@
import { useRef, useState } from 'react';
import { Check, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
@@ -7,7 +6,6 @@ import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
// eslint-disable-next-line signoz/no-antd-components -- TextArea/Collapse: no @signozhq/ui equivalent
import { Collapse, Input as AntdInput } from 'antd';
import { CustomMultiSelect } from 'components/NewSelect';
import type { VariableType } from '../variableFormModel';
import DynamicVariableFields from './DynamicVariableFields';
@@ -32,19 +30,9 @@ function VariableForm({
siblings,
isNew,
isSaving,
panelOptions,
appliedPanelIds,
onClose,
onSave,
}: VariableFormProps): JSX.Element {
// The "apply to panels" selection is transient form state, seeded from the
// panels that already reference this variable. Held in a ref so the save
// callback (owned by useVariableForm) reads the latest value.
const [selectedPanelIds, setSelectedPanelIds] =
useState<string[]>(appliedPanelIds);
const selectedPanelIdsRef = useRef(selectedPanelIds);
selectedPanelIdsRef.current = selectedPanelIds;
const {
model,
set,
@@ -66,12 +54,7 @@ function VariableForm({
showAllOptionField,
payloadVariables,
handleSave,
} = useVariableForm({
initial,
siblings,
isNew,
onSave: (next): void => onSave(next, selectedPanelIdsRef.current),
});
} = useVariableForm({ initial, siblings, isNew, onSave });
// Shared list rows (preview/sort/multi/default) for the list-type variables;
// rendered as a sibling inside each list-type panel. Only the active panel
@@ -118,20 +101,6 @@ function VariableForm({
attributeError={attributeError}
/>
{listFields}
<div className={styles.row}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>
Apply to panels
</Typography.Text>
</div>
<CustomMultiSelect
placeholder="Select panels"
options={panelOptions}
value={selectedPanelIds}
onChange={(value): void => setSelectedPanelIds(value as string[])}
data-testid="variable-apply-panels"
/>
</div>
</div>
</TabsContent>

View File

@@ -3,13 +3,18 @@ import { Check, GripVertical, PenLine, Trash2, X } from '@signozhq/icons';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import type { VariableFormModel } from './variableFormModel';
import styles from './Variables.module.scss';
const TYPE_LABEL: Record<VariableFormModel['type'], string> = {
QUERY: 'Query',
CUSTOM: 'Custom',
TEXT: 'Text',
DYNAMIC: 'Dynamic',
};
interface VariableRowProps {
variable: VariableFormModel;
index: number;
@@ -20,11 +25,9 @@ interface VariableRowProps {
onRequestDelete: (index: number) => void;
onConfirmDelete: (index: number) => void;
onCancelDelete: () => void;
/** Apply this variable's filter to all panels. Dynamic variables only. */
onApplyToAll: (index: number) => void;
}
/** A single draggable variable row in the two-column (name / description) table. */
/** A single draggable variable row (drag handle + meta + inline actions). */
function VariableRow({
variable,
index,
@@ -34,7 +37,6 @@ function VariableRow({
onRequestDelete,
onConfirmDelete,
onCancelDelete,
onApplyToAll,
}: VariableRowProps): JSX.Element {
const {
attributes,
@@ -59,7 +61,7 @@ function VariableRow({
className={styles.row}
data-testid={`variable-row-${variable.name}`}
>
<div className={styles.varCell}>
<div className={styles.rowMain}>
{canEdit ? (
<span
ref={setActivatorNodeRef}
@@ -72,94 +74,65 @@ function VariableRow({
</span>
) : null}
<Typography.Text className={styles.varName}>
{variable.name}
${variable.name}
</Typography.Text>
</div>
<div className={styles.descCell}>
<span className={styles.typeTag}>{TYPE_LABEL[variable.type]}</span>
{variable.description ? (
<Typography.Text className={styles.varDesc}>
{variable.description}
</Typography.Text>
) : (
<span className={styles.varDescEmpty}></span>
)}
{canEdit ? (
<div
className={cx(styles.rowActions, {
[styles.rowActionsVisible]: isConfirmingDelete,
})}
>
{isConfirmingDelete ? (
<>
<Typography.Text className={styles.confirmText}>
Delete?
</Typography.Text>
<Button
variant="ghost"
color="destructive"
size="icon"
onClick={(): void => onConfirmDelete(index)}
aria-label="Confirm delete"
testId={`variable-delete-confirm-${variable.name}`}
>
<Check size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={onCancelDelete}
aria-label="Cancel delete"
>
<X size={14} />
</Button>
</>
) : (
<>
{variable.type === 'DYNAMIC' ? (
<TooltipSimple
side="top"
title="Add this variable as a filter to every panel"
>
<Button
variant="ghost"
color="secondary"
size="sm"
className={styles.applyAllButton}
onClick={(): void => onApplyToAll(index)}
testId={`variable-apply-all-${variable.name}`}
>
Apply to all
</Button>
</TooltipSimple>
) : null}
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={(): void => onEdit(index)}
aria-label="Edit variable"
testId={`variable-edit-${variable.name}`}
>
<PenLine size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={(): void => onRequestDelete(index)}
aria-label="Delete variable"
testId={`variable-delete-${variable.name}`}
>
<Trash2 size={14} />
</Button>
</>
)}
</div>
) : null}
</div>
{canEdit && isConfirmingDelete ? (
<div className={styles.rowActions}>
<Typography.Text className={styles.confirmText}>Delete?</Typography.Text>
<Button
variant="ghost"
color="destructive"
size="icon"
onClick={(): void => onConfirmDelete(index)}
aria-label="Confirm delete"
testId={`variable-delete-confirm-${variable.name}`}
>
<Check size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={onCancelDelete}
aria-label="Cancel delete"
>
<X size={14} />
</Button>
</div>
) : null}
{canEdit && !isConfirmingDelete ? (
<div className={styles.rowActions}>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={(): void => onEdit(index)}
aria-label="Edit variable"
testId={`variable-edit-${variable.name}`}
>
<PenLine size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={(): void => onRequestDelete(index)}
aria-label="Delete variable"
testId={`variable-delete-${variable.name}`}
>
<Trash2 size={14} />
</Button>
</div>
) : null}
</div>
);
}

View File

@@ -4,7 +4,7 @@
gap: 16px;
}
.footer {
.header {
display: flex;
justify-content: flex-end;
gap: 16px;
@@ -28,125 +28,69 @@
color: var(--l2-foreground);
}
// Two-column (Variable / Description) table matching the V1 variables listing.
$grid-columns: minmax(0, 2fr) minmax(0, 3fr);
.table {
display: flex;
flex-direction: column;
border: 1px solid var(--l2-border);
border-radius: 6px;
overflow: hidden;
}
.headerRow {
display: grid;
grid-template-columns: $grid-columns;
padding: 0 16px;
border-bottom: 1px solid var(--l2-border);
}
.headerCell {
padding: 12px 0;
font-size: 13px;
font-weight: 500;
color: var(--l1-foreground);
&:first-child {
padding-right: 16px;
border-right: 1px solid var(--l2-border);
}
&:last-child {
padding-left: 16px;
}
}
.list {
display: flex;
flex-direction: column;
gap: 8px;
}
.row {
position: relative;
display: grid;
grid-template-columns: $grid-columns;
padding: 0 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--l1-border);
border-radius: 4px;
background: var(--l1-background);
&:not(:last-child) {
border-bottom: 1px solid var(--l2-border);
}
&:hover {
background: var(--l2-background);
}
}
.varCell {
.rowMain {
display: flex;
align-items: flex-start;
gap: 8px;
align-items: center;
gap: 10px;
min-width: 0;
padding: 12px 16px 12px 0;
border-right: 1px solid var(--l2-border);
}
.dragHandle {
display: flex;
flex-shrink: 0;
align-items: center;
margin-top: 1px;
color: var(--l3-foreground);
cursor: grab;
touch-action: none;
}
.varName {
min-width: 0;
font-family: 'Space Mono', monospace;
font-size: 13px;
color: var(--bg-robin-400);
word-break: break-all;
}
.descCell {
position: relative;
min-width: 0;
padding: 12px 0 12px 16px;
font-weight: 500;
color: var(--l1-foreground);
}
.varDesc {
font-family: 'Space Mono', monospace;
font-size: 13px;
color: var(--bg-sienna-400);
white-space: pre-wrap;
word-break: break-word;
min-width: 0;
overflow: hidden;
font-size: 12px;
color: var(--l2-foreground);
text-overflow: ellipsis;
white-space: nowrap;
}
.varDescEmpty {
color: var(--l3-foreground);
.typeTag {
flex-shrink: 0;
padding: 1px 8px;
font-size: 11px;
letter-spacing: 0.04em;
color: var(--l2-foreground);
text-transform: uppercase;
background: var(--l2-background);
border-radius: 10px;
}
.rowActions {
position: absolute;
top: 8px;
right: 0;
display: flex;
flex-shrink: 0;
align-items: center;
gap: 2px;
padding-left: 16px;
background: var(--l2-background);
opacity: 0;
transition: opacity 120ms ease;
}
.row:hover .rowActions {
opacity: 1;
}
.rowActionsVisible {
opacity: 1;
}
.confirmText {
@@ -154,8 +98,3 @@ $grid-columns: minmax(0, 2fr) minmax(0, 3fr);
font-size: 12px;
color: var(--l2-foreground);
}
.applyAllButton {
font-size: 12px;
white-space: nowrap;
}

View File

@@ -25,7 +25,6 @@ interface VariablesListProps {
onConfirmDelete: (index: number) => void;
onCancelDelete: () => void;
onMove: (from: number, to: number) => void;
onApplyToAll: (index: number) => void;
}
function VariablesList({
@@ -37,7 +36,6 @@ function VariablesList({
onConfirmDelete,
onCancelDelete,
onMove,
onApplyToAll,
}: VariablesListProps): JSX.Element {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 1 } }),
@@ -55,39 +53,32 @@ function VariablesList({
};
return (
<div className={styles.table} data-testid="variables-list">
<div className={styles.headerRow}>
<span className={styles.headerCell}>Variable</span>
<span className={styles.headerCell}>Description</span>
</div>
<DndContext
sensors={sensors}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
<DndContext
sensors={sensors}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
>
<SortableContext
items={variables.map((v) => v.name)}
strategy={verticalListSortingStrategy}
>
<SortableContext
items={variables.map((v) => v.name)}
strategy={verticalListSortingStrategy}
>
<div className={styles.list}>
{variables.map((variable, index) => (
<VariableRow
key={variable.name || `variable-${index}`}
variable={variable}
index={index}
canEdit={canEdit}
isConfirmingDelete={confirmingIndex === index}
onEdit={onEdit}
onRequestDelete={onRequestDelete}
onConfirmDelete={onConfirmDelete}
onCancelDelete={onCancelDelete}
onApplyToAll={onApplyToAll}
/>
))}
</div>
</SortableContext>
</DndContext>
</div>
<div className={styles.list} data-testid="variables-list">
{variables.map((variable, index) => (
<VariableRow
key={variable.name || `variable-${index}`}
variable={variable}
index={index}
canEdit={canEdit}
isConfirmingDelete={confirmingIndex === index}
onEdit={onEdit}
onRequestDelete={onRequestDelete}
onConfirmDelete={onConfirmDelete}
onCancelDelete={onCancelDelete}
/>
))}
</div>
</SortableContext>
</DndContext>
);
}

View File

@@ -1,231 +0,0 @@
import type {
DashboardtypesDashboardSpecDTOPanels,
DashboardtypesJSONPatchOperationDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
buildApplyVariableToPanelsPatch,
buildSyncVariableToPanelsPatch,
getPanelIdsReferencingVariable,
} from '../applyVariableToPanelsPatch';
/** Minimal builder-query panel wrapped in a CompositeQuery, with a given filter. */
function compositePanel(filterExpression: string): unknown {
return {
kind: 'panel',
spec: {
display: { title: '' },
plugin: { kind: 'signoz/TimeSeries', spec: {} },
queries: [
{
kind: 'builder_query',
spec: {
plugin: {
kind: 'signoz/CompositeQuery',
spec: {
queries: [
{
type: 'builder_query',
spec: {
name: 'A',
signal: 'logs',
filter: { expression: filterExpression },
},
},
],
},
},
},
},
],
},
};
}
/** A bare LIST-style BuilderQuery panel (filter lives directly on the plugin spec). */
function listPanel(filterExpression: string): unknown {
return {
kind: 'panel',
spec: {
display: { title: '' },
plugin: { kind: 'signoz/List', spec: {} },
queries: [
{
kind: 'builder_query',
spec: {
plugin: {
kind: 'signoz/BuilderQuery',
spec: {
name: 'A',
signal: 'logs',
filter: { expression: filterExpression },
},
},
},
},
],
},
};
}
/** A PromQL panel — no builder filter, must be skipped. */
function promqlPanel(): unknown {
return {
kind: 'panel',
spec: {
display: { title: '' },
plugin: { kind: 'signoz/TimeSeries', spec: {} },
queries: [
{
kind: 'promql',
spec: {
plugin: { kind: 'signoz/PromQLQuery', spec: { query: 'up' } },
},
},
],
},
};
}
/** Reads the first builder query's filter expression out of a patch op's value. */
function expressionOf(
op: DashboardtypesJSONPatchOperationDTO,
): string | undefined {
const queries = op.value as Array<{
spec: { plugin: { kind: string; spec: any } };
}>;
const { plugin } = queries[0].spec;
const builderSpec =
plugin.kind === 'signoz/CompositeQuery'
? plugin.spec.queries[0].spec
: plugin.spec;
return builderSpec.filter?.expression;
}
function panels(
map: Record<string, unknown>,
): DashboardtypesDashboardSpecDTOPanels {
return map as DashboardtypesDashboardSpecDTOPanels;
}
describe('buildApplyVariableToPanelsPatch', () => {
it('appends the clause to an existing filter (AND-joined)', () => {
const ops = buildApplyVariableToPanelsPatch(
panels({ p1: compositePanel('env = "prod"') }),
'service.name',
'svc',
);
expect(ops).toHaveLength(1);
expect(ops[0]).toMatchObject({
op: 'replace',
path: '/spec/panels/p1/spec/queries',
});
expect(expressionOf(ops[0])).toBe('env = "prod" AND service.name IN $svc');
});
it('sets the clause when the filter is empty', () => {
const ops = buildApplyVariableToPanelsPatch(
panels({ p1: compositePanel('') }),
'service.name',
'svc',
);
expect(expressionOf(ops[0])).toBe('service.name IN $svc');
});
it('applies to a bare BuilderQuery (LIST) panel', () => {
const ops = buildApplyVariableToPanelsPatch(
panels({ p1: listPanel('') }),
'k8s.pod.name',
'pod',
);
expect(expressionOf(ops[0])).toBe('k8s.pod.name IN $pod');
});
it('is idempotent — re-applying does not duplicate the clause', () => {
const ops = buildApplyVariableToPanelsPatch(
panels({ p1: compositePanel('service.name IN $svc') }),
'service.name',
'svc',
);
expect(ops).toHaveLength(0);
});
it('only targets the requested panels', () => {
const ops = buildApplyVariableToPanelsPatch(
panels({ p1: compositePanel(''), p2: compositePanel('') }),
'service.name',
'svc',
['p2'],
);
expect(ops).toHaveLength(1);
expect(ops[0].path).toBe('/spec/panels/p2/spec/queries');
});
it('skips PromQL panels (no builder filter)', () => {
const ops = buildApplyVariableToPanelsPatch(
panels({ p1: promqlPanel() }),
'service.name',
'svc',
);
expect(ops).toHaveLength(0);
});
it('returns nothing when attribute or name is missing', () => {
expect(
buildApplyVariableToPanelsPatch(
panels({ p1: compositePanel('') }),
'',
'svc',
),
).toHaveLength(0);
});
});
describe('buildSyncVariableToPanelsPatch', () => {
it('adds to selected panels and removes from the rest', () => {
const ops = buildSyncVariableToPanelsPatch(
panels({
p1: compositePanel('service.name IN $svc'), // has it, not selected → remove
p2: compositePanel(''), // selected → add
p3: compositePanel('service.name IN $svc'), // has it, selected → unchanged
}),
'service.name',
'svc',
['p2', 'p3'],
);
const byPath = Object.fromEntries(ops.map((op) => [op.path, op]));
expect(Object.keys(byPath).sort()).toStrictEqual([
'/spec/panels/p1/spec/queries',
'/spec/panels/p2/spec/queries',
]);
expect(expressionOf(byPath['/spec/panels/p1/spec/queries'])).toBe('');
expect(expressionOf(byPath['/spec/panels/p2/spec/queries'])).toBe(
'service.name IN $svc',
);
});
it('removing keeps other clauses intact', () => {
const ops = buildSyncVariableToPanelsPatch(
panels({ p1: compositePanel('env = "prod" AND service.name IN $svc') }),
'service.name',
'svc',
[],
);
expect(expressionOf(ops[0])).toBe('env = "prod"');
});
});
describe('getPanelIdsReferencingVariable', () => {
it('returns only panels whose filter references the variable', () => {
const ids = getPanelIdsReferencingVariable(
panels({
p1: compositePanel('service.name IN $svc'),
p2: compositePanel('env = "prod"'),
p3: listPanel('service.name IN $svc'),
}),
'service.name',
'svc',
);
expect(ids.sort()).toStrictEqual(['p1', 'p3']);
});
});

View File

@@ -1,179 +0,0 @@
import type {
DashboardtypesDashboardSpecDTOPanels,
DashboardtypesJSONPatchOperationDTO,
DashboardtypesQueryDTO,
Querybuildertypesv5BuilderQuerySpecDTO,
Querybuildertypesv5CompositeQueryDTO,
Querybuildertypesv5QueryEnvelopeBuilderDTO,
} from 'api/generated/services/sigNoz.schemas';
import { cloneDeep } from 'lodash-es';
// Injects/removes a dynamic variable's filter (`attribute IN $name`) in panel
// builder queries as JSON-Patch ops. Only builder queries carry a filter.
function clauseFor(attribute: string, variableName: string): string {
return `${attribute} IN $${variableName}`;
}
/** Runs `fn` on every builder-query spec in a panel's single query (Composite or bare Builder). */
function forEachBuilderSpec(
queries: DashboardtypesQueryDTO[],
fn: (spec: Querybuildertypesv5BuilderQuerySpecDTO) => void,
): void {
const plugin = queries[0]?.spec?.plugin;
if (!plugin?.spec) {
return;
}
if (plugin.kind === 'signoz/CompositeQuery') {
const composite = plugin.spec as Querybuildertypesv5CompositeQueryDTO;
(composite.queries ?? [])
.filter((envelope) => envelope.type === 'builder_query')
.forEach((envelope) => {
const { spec } = envelope as Querybuildertypesv5QueryEnvelopeBuilderDTO;
if (spec) {
fn(spec as Querybuildertypesv5BuilderQuerySpecDTO);
}
});
} else if (plugin.kind === 'signoz/BuilderQuery') {
fn(plugin.spec as Querybuildertypesv5BuilderQuerySpecDTO);
}
}
/** Appends the clause to every builder query's filter. Returns whether anything changed. */
function addClause(queries: DashboardtypesQueryDTO[], clause: string): boolean {
let changed = false;
forEachBuilderSpec(queries, (spec) => {
const existing = spec.filter?.expression?.trim();
// Idempotent: a repeated apply must not stack duplicate clauses.
if (existing?.includes(clause)) {
return;
}
spec.filter = {
expression: existing ? `${existing} AND ${clause}` : clause,
};
changed = true;
});
return changed;
}
/** Removes the managed clause (an ` AND `-joined part) from every builder filter. */
function removeClause(
queries: DashboardtypesQueryDTO[],
clause: string,
): boolean {
let changed = false;
forEachBuilderSpec(queries, (spec) => {
const existing = spec.filter?.expression;
if (!existing) {
return;
}
const parts = existing
.split(' AND ')
.map((part) => part.trim())
.filter(Boolean);
const kept = parts.filter((part) => part !== clause);
if (kept.length !== parts.length) {
spec.filter = { expression: kept.join(' AND ') };
changed = true;
}
});
return changed;
}
/** Whether any builder query in the panel already carries the clause. */
function panelHasClause(
queries: DashboardtypesQueryDTO[],
clause: string,
): boolean {
let has = false;
forEachBuilderSpec(queries, (spec) => {
if (spec.filter?.expression?.includes(clause)) {
has = true;
}
});
return has;
}
function replaceQueriesOp(
panelId: string,
queries: DashboardtypesQueryDTO[],
): DashboardtypesJSONPatchOperationDTO {
return {
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
path: `/spec/panels/${panelId}/spec/queries`,
value: queries,
};
}
/** Add-only: inject the variable's filter into the given panels (default: all). */
export function buildApplyVariableToPanelsPatch(
panels: DashboardtypesDashboardSpecDTOPanels,
attribute: string,
variableName: string,
targetPanelIds?: string[],
): DashboardtypesJSONPatchOperationDTO[] {
if (!attribute || !variableName) {
return [];
}
const clause = clauseFor(attribute, variableName);
const ids = targetPanelIds ?? Object.keys(panels);
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
ids.forEach((id) => {
const panel = panels[id];
if (!panel?.spec?.queries?.length) {
return;
}
const queries = cloneDeep(panel.spec.queries);
if (addClause(queries, clause)) {
ops.push(replaceQueriesOp(id, queries));
}
});
return ops;
}
/** Full sync: selected panels get the clause; every other panel has it removed. */
export function buildSyncVariableToPanelsPatch(
panels: DashboardtypesDashboardSpecDTOPanels,
attribute: string,
variableName: string,
selectedPanelIds: string[],
): DashboardtypesJSONPatchOperationDTO[] {
if (!attribute || !variableName) {
return [];
}
const clause = clauseFor(attribute, variableName);
const selected = new Set(selectedPanelIds);
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
Object.keys(panels).forEach((id) => {
const panel = panels[id];
if (!panel?.spec?.queries?.length) {
return;
}
const queries = cloneDeep(panel.spec.queries);
const changed = selected.has(id)
? addClause(queries, clause)
: removeClause(queries, clause);
if (changed) {
ops.push(replaceQueriesOp(id, queries));
}
});
return ops;
}
/** Panel ids whose queries currently reference the variable — pre-populates the picker. */
export function getPanelIdsReferencingVariable(
panels: DashboardtypesDashboardSpecDTOPanels,
attribute: string,
variableName: string,
): string[] {
if (!attribute || !variableName) {
return [];
}
const clause = clauseFor(attribute, variableName);
return Object.keys(panels).filter((id) => {
const queries = panels[id]?.spec?.queries;
return queries?.length ? panelHasClause(queries, clause) : false;
});
}

View File

@@ -1,16 +0,0 @@
.body {
font-size: 13px;
line-height: 20px;
color: var(--l2-foreground);
}
.variableName {
font-family: 'Space Mono', monospace;
color: var(--bg-robin-400);
}
.footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}

View File

@@ -1,67 +0,0 @@
import { Check, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import styles from './ApplyToAllDialog.module.scss';
interface ApplyToAllDialogProps {
open: boolean;
variableName: string;
isLoading: boolean;
onConfirm: () => void;
onClose: () => void;
}
/** Confirms applying a dynamic variable as a filter to every panel. */
function ApplyToAllDialog({
open,
variableName,
isLoading,
onConfirm,
onClose,
}: ApplyToAllDialogProps): JSX.Element {
const footer = (
<div className={styles.footer}>
<Button variant="solid" color="secondary" onClick={onClose}>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="primary"
loading={isLoading}
onClick={onConfirm}
testId="confirm-apply-to-all"
>
<Check size={12} />
Apply to all
</Button>
</div>
);
return (
<DialogWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
title="Apply variable to all panels"
width="narrow"
showCloseButton={false}
// Lift above the settings drawer (z ~1000); overlay off (it would only half-dim).
style={{ zIndex: 1100 }}
showOverlay={false}
footer={footer}
>
<div className={styles.body}>
Add <span className={styles.variableName}>${variableName}</span> as a filter
to every panel on this dashboard. Panels that already reference it are left
unchanged.
</div>
</DialogWrapper>
);
}
export default ApplyToAllDialog;

View File

@@ -1,16 +1,9 @@
import { useEffect, useMemo, useState } from 'react';
import { toast } from '@signozhq/ui/sonner';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import settingsStyles from '../DashboardSettings.module.scss';
import { useOptimisticPatch } from '../../hooks/useOptimisticPatch';
import { useDashboardStore } from '../../store/useDashboardStore';
import {
buildApplyVariableToPanelsPatch,
buildSyncVariableToPanelsPatch,
getPanelIdsReferencingVariable,
} from './applyVariableToPanelsPatch';
import { useSaveVariables } from './useSaveVariables';
import { dtoToFormModel } from './variableAdapters';
import {
@@ -21,7 +14,6 @@ import VariableForm from './VariableForm/VariableForm';
import VariablesList from './VariablesList';
import styles from './Variables.module.scss';
import AddVariableButton from './components/AddVariableButton';
import ApplyToAllDialog from './components/ApplyToAllDialog/ApplyToAllDialog';
import NoVariablesCard from './components/NoVariablesCard/NoVariablesCard';
import { EditingState } from './types';
@@ -31,13 +23,7 @@ interface VariablesSettingsProps {
function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
// The drawer destroys on close, so reading this once on mount is enough to
// open the add-form when deep-linked (e.g. the bar's "Add variable" button).
const openAddOnMount = useDashboardStore(
(s) => s.settingsRequest?.addVariable ?? false,
);
const { save, isSaving } = useSaveVariables();
const { patchAsync, isPatching } = useOptimisticPatch();
const initialFormModels = useMemo(
() => dashboard.spec.variables.map(dtoToFormModel),
@@ -52,13 +38,10 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboard.updatedAt]);
const [isEditing, setIsEditing] = useState<EditingState>(
openAddOnMount && isEditable ? { type: 'new' } : null,
);
const [isEditing, setIsEditing] = useState<EditingState>(null);
const [confirmDeleteIndex, setConfirmDeleteIndex] = useState<number | null>(
null,
);
const [applyToAllIndex, setApplyToAllIndex] = useState<number | null>(null);
const editingFormModel: VariableFormModel | null = useMemo(() => {
if (!isEditing) {
@@ -74,64 +57,20 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
return variables.filter((_, i) => i !== self);
}, [variables, isEditing]);
const panelOptions = useMemo(
() =>
Object.entries(dashboard.spec.panels ?? {}).map(([id, panel]) => ({
value: id,
label: panel.spec?.display?.name || id,
})),
[dashboard.spec.panels],
);
// Panels the edited variable is already applied to — pre-checks the picker.
const appliedPanelIds = useMemo(() => {
if (!editingFormModel || editingFormModel.type !== 'DYNAMIC') {
return [];
}
return getPanelIdsReferencingVariable(
dashboard.spec.panels,
editingFormModel.dynamicAttribute,
editingFormModel.name,
);
}, [editingFormModel, dashboard.spec.panels]);
const persist = (next: VariableFormModel[]): void => {
setVariables(next);
void save(next);
};
const handleFormSave = (
formModel: VariableFormModel,
selectedPanelIds: string[],
): void => {
const handleFormSave = (Formmodel: VariableFormModel): void => {
const next = [...variables];
if (isEditing?.type === 'new') {
next.push(formModel);
next.push(Formmodel);
} else if (isEditing?.type === 'edit') {
next[isEditing.index] = formModel;
next[isEditing.index] = Formmodel;
}
setIsEditing(null);
setVariables(next);
void (async (): Promise<void> => {
const saved = await save(next);
if (!saved || formModel.type !== 'DYNAMIC') {
return;
}
const ops = buildSyncVariableToPanelsPatch(
dashboard.spec.panels,
formModel.dynamicAttribute,
formModel.name,
selectedPanelIds,
);
if (ops.length === 0) {
return;
}
try {
await patchAsync(ops);
} catch {
toast.error('Could not update panels');
}
})();
persist(next);
};
const handleMove = (from: number, to: number): void => {
@@ -149,32 +88,6 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
setConfirmDeleteIndex(null);
};
const applyToAllVariable =
applyToAllIndex === null ? null : variables[applyToAllIndex];
const handleConfirmApplyToAll = async (): Promise<void> => {
if (!applyToAllVariable) {
return;
}
const ops = buildApplyVariableToPanelsPatch(
dashboard.spec.panels,
applyToAllVariable.dynamicAttribute,
applyToAllVariable.name,
);
if (ops.length === 0) {
toast.info('No panels needed this filter.');
setApplyToAllIndex(null);
return;
}
try {
await patchAsync(ops);
toast.success(`Applied $${applyToAllVariable.name} to all panels`);
} catch {
toast.error('Could not apply the variable to panels');
}
setApplyToAllIndex(null);
};
if (editingFormModel) {
return (
<VariableForm
@@ -182,8 +95,6 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
siblings={siblings}
isNew={isEditing?.type === 'new'}
isSaving={isSaving}
panelOptions={panelOptions}
appliedPanelIds={appliedPanelIds}
onClose={(): void => setIsEditing(null)}
onSave={handleFormSave}
/>
@@ -197,6 +108,9 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
<NoVariablesCard isEditable={isEditable} setIsEditing={setIsEditing} />
) : (
<>
<div className={styles.header}>
<AddVariableButton isEditable={isEditable} setIsEditing={setIsEditing} />
</div>
<VariablesList
variables={variables}
canEdit={isEditable}
@@ -206,20 +120,9 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
onConfirmDelete={handleConfirmDelete}
onCancelDelete={(): void => setConfirmDeleteIndex(null)}
onMove={handleMove}
onApplyToAll={(index): void => setApplyToAllIndex(index)}
/>
<div className={styles.footer}>
<AddVariableButton isEditable={isEditable} setIsEditing={setIsEditing} />
</div>
</>
)}
<ApplyToAllDialog
open={applyToAllVariable !== null}
variableName={applyToAllVariable?.name ?? ''}
isLoading={isPatching}
onConfirm={(): void => void handleConfirmApplyToAll()}
onClose={(): void => setApplyToAllIndex(null)}
/>
</div>
);
}

View File

@@ -6,11 +6,6 @@ export type EditingState =
| { type: 'edit'; index: number }
| null;
export interface PanelOption {
label: string;
value: string;
}
export interface VariableFormProps {
initial: VariableFormModel;
/** The other variables (excluding this one), for uniqueness & cycle checks. */
@@ -18,10 +13,6 @@ export interface VariableFormProps {
/** True when adding a new variable (enables auto-naming from the attribute). */
isNew: boolean;
isSaving: boolean;
/** All panels, for the dynamic "apply to panels" picker. */
panelOptions: PanelOption[];
/** Panels this variable is already applied to — pre-checks the picker. */
appliedPanelIds: string[];
onClose: () => void;
onSave: (model: VariableFormModel, selectedPanelIds: string[]) => void;
onSave: (model: VariableFormModel) => void;
}

View File

@@ -14,7 +14,6 @@ import type {
import {
DYNAMIC_SIGNAL_ALL,
DYNAMIC_SIGNALS,
type DynamicSignalOption,
emptyVariableFormModel,
signalForApi,
@@ -69,12 +68,9 @@ export function dtoToFormModel(
...listCommon,
type: 'DYNAMIC',
dynamicAttribute: plugin.spec.name ?? '',
// Unrecognized/empty signal "all telemetry", so the source always shows.
dynamicSignal: DYNAMIC_SIGNALS.includes(
plugin.spec.signal as DynamicSignalOption,
)
? (plugin.spec.signal as DynamicSignalOption)
: DYNAMIC_SIGNAL_ALL,
// An omitted wire signal means "all telemetry".
dynamicSignal:
(plugin.spec.signal as DynamicSignalOption) ?? DYNAMIC_SIGNAL_ALL,
};
}
// Default to Query (also covers a query plugin or a missing/unknown plugin).

View File

@@ -17,7 +17,6 @@ import { useAppContext } from 'providers/App/App';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { USER_ROLES } from 'types/roles';
import { useDashboardStore } from '../store/useDashboardStore';
import styles from './DashboardSettings.module.scss';
interface DashboardSettingsProps {
@@ -39,9 +38,6 @@ const prefixIcons: Record<TabKeys, JSX.Element> = {
function DashboardSettings({ dashboard }: DashboardSettingsProps): JSX.Element {
const { user } = useAppContext();
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
// Opened once per drawer mount (the drawer destroys on close); a deep-link
// request lands us on the right tab.
const settingsRequest = useDashboardStore((s) => s.settingsRequest);
const enablePublicDashboard = isCloudUser || isEnterpriseSelfHostedUser;
@@ -73,7 +69,7 @@ function DashboardSettings({ dashboard }: DashboardSettingsProps): JSX.Element {
);
return (
<TabsRoot defaultValue={settingsRequest?.tab ?? TabKeys.OVERVIEW}>
<TabsRoot defaultValue={TabKeys.OVERVIEW}>
<TabsList variant="primary">
{Object.values(TabKeys).map((key) => (
<TabsTrigger value={key} key={key}>

View File

@@ -6,12 +6,16 @@ import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import { useConfirmableAction } from 'hooks/useConfirmableAction';
import DisabledControlTooltip from '../../components/DisabledControlTooltip/DisabledControlTooltip';
import styles from './Header.module.scss';
interface HeaderProps {
isDirty: boolean;
isSaving: boolean;
showSwitchToView?: boolean;
/** Locked/no-permission dashboard — Save is disabled with a reason. */
readOnly?: boolean;
readOnlyReason?: string;
onSave: () => void;
onSwitchToView?: () => void;
onClose: () => void;
@@ -21,6 +25,8 @@ function Header({
isDirty,
isSaving,
showSwitchToView = false,
readOnly = false,
readOnlyReason,
onSave,
onSwitchToView,
onClose,
@@ -63,16 +69,18 @@ function Header({
Switch to View Mode
</Button>
)}
<Button
variant="solid"
color="primary"
data-testid="panel-editor-v2-save"
disabled={!isDirty || isSaving}
loading={isSaving}
onClick={onSave}
>
Save changes
</Button>
<DisabledControlTooltip reason={readOnlyReason ?? ''} disabled={readOnly}>
<Button
variant="solid"
color="primary"
data-testid="panel-editor-v2-save"
disabled={readOnly || !isDirty || isSaving}
loading={!readOnly && isSaving}
onClick={readOnly ? undefined : onSave}
>
Save changes
</Button>
</DisabledControlTooltip>
</div>
<DialogWrapper

View File

@@ -159,6 +159,8 @@ function makePanel(
const baseProps = {
dashboardId: 'dash-1',
panelId: 'panel-1',
isEditable: true,
editDisabledReason: '',
onClose: jest.fn(),
onSaved: jest.fn(),
};

View File

@@ -43,6 +43,10 @@ interface PanelEditorContainerProps {
isNew?: boolean;
/** Target section for a new panel; falls back to the last/new section. */
layoutIndex?: number;
/** The dashboard can be edited (unlocked + permission); gates Save. */
isEditable: boolean;
/** Why Save is disabled (locked / no permission); '' when editable. */
editDisabledReason: string;
/** Leave the editor (navigate back to the dashboard) without saving. */
onClose: () => void;
/** Called after a successful save — navigates back to the dashboard. */
@@ -60,6 +64,8 @@ function PanelEditorContainer({
panel,
isNew = false,
layoutIndex,
isEditable,
editDisabledReason,
onClose,
onSaved,
}: PanelEditorContainerProps): JSX.Element {
@@ -198,6 +204,9 @@ function PanelEditorContainer({
});
const onSave = useCallback(async (): Promise<void> => {
if (!isEditable) {
return;
}
try {
// Bake the live query into the spec so unstaged edits are saved too.
await save(buildSaveSpec(draft.spec));
@@ -206,7 +215,7 @@ function PanelEditorContainer({
} catch {
toast.error('Failed to save panel');
}
}, [save, buildSaveSpec, draft.spec, onSaved]);
}, [isEditable, save, buildSaveSpec, draft.spec, onSaved]);
return (
<div className={styles.page} data-testid="panel-editor-v2">
@@ -214,6 +223,8 @@ function PanelEditorContainer({
isDirty={isDirty}
isSaving={isSaving}
showSwitchToView={!isNew}
readOnly={!isEditable}
readOnlyReason={editDisabledReason}
onSave={onSave}
onSwitchToView={onSwitchToView}
onClose={onClose}

View File

@@ -25,6 +25,7 @@
.welcome {
color: var(--l1-foreground);
font-family: Inter;
font-size: 16px;
font-weight: 500;
line-height: 24px;
@@ -33,56 +34,52 @@
.welcomeInfo {
color: var(--l3-foreground);
font-family: Inter;
font-size: 13px;
font-weight: 400;
line-height: 18px;
}
}
.steps {
display: flex;
flex-direction: column;
gap: 12px;
}
.step {
.addPanel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px;
border: 1px dashed var(--l3-border);
border: 1px dashed var(--l1-border);
border-radius: 6px;
}
.stepText {
.addPanelText {
display: flex;
align-items: flex-start;
gap: 10px;
.stepIcon {
.icon {
height: 14px;
width: 14px;
margin-top: 2px;
flex: none;
}
}
.stepCopy {
.addPanelCopy {
display: flex;
flex-direction: column;
gap: 2px;
}
.stepTitle {
.addPanelTitle {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.stepInfo {
.addPanelInfo {
color: var(--l3-foreground);
font-family: Inter;
font-size: 13px;
font-weight: 400;
line-height: 18px;

View File

@@ -1,4 +1,4 @@
import { Configure, Plus } from '@signozhq/icons';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
@@ -6,7 +6,6 @@ import dashboardEmojiUrl from '@/assets/Icons/dashboard_emoji.svg';
import landscapeUrl from '@/assets/Icons/landscape.svg';
import { useCreatePanel } from '../../hooks/useCreatePanel';
import { useDashboardStore } from '../../store/useDashboardStore';
import PanelTypeSelectionModal from '../Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
import styles from './DashboardEmptyState.module.scss';
@@ -19,8 +18,6 @@ function DashboardEmptyState({
}: DashboardEmptyStateProps): JSX.Element {
const { isPickerOpen, openPicker, closePicker, createPanel } =
useCreatePanel();
const isEditable = useDashboardStore((s) => s.isEditable);
const requestSettings = useDashboardStore((s) => s.requestSettings);
return (
<section className={styles.emptyState}>
@@ -35,55 +32,28 @@ function DashboardEmptyState({
</Typography.Text>
</div>
<div className={styles.steps}>
<div className={styles.step}>
<div className={styles.stepText}>
<Configure size={14} className={styles.stepIcon} />
<div className={styles.stepCopy}>
<Typography.Text className={styles.stepTitle}>
Configure your new dashboard
</Typography.Text>
<Typography.Text className={styles.stepInfo}>
Give it a name, add description, tags and variables
</Typography.Text>
</div>
<div className={styles.addPanel}>
<div className={styles.addPanelText}>
<img src={landscapeUrl} alt="" className={styles.icon} />
<div className={styles.addPanelCopy}>
<Typography.Text className={styles.addPanelTitle}>
Add panels
</Typography.Text>
<Typography.Text className={styles.addPanelInfo}>
Add panels to visualize your data
</Typography.Text>
</div>
{isEditable && (
<Button
variant="solid"
color="secondary"
prefix={<Configure size="md" />}
onClick={(): void => requestSettings({ tab: 'Overview' })}
testId="empty-configure"
>
Configure
</Button>
)}
</div>
<div className={styles.step}>
<div className={styles.stepText}>
<img src={landscapeUrl} alt="" className={styles.stepIcon} />
<div className={styles.stepCopy}>
<Typography.Text className={styles.stepTitle}>
Add panels
</Typography.Text>
<Typography.Text className={styles.stepInfo}>
Add panels to visualize your data
</Typography.Text>
</div>
</div>
{canAddPanel && (
<Button
color="primary"
prefix={<Plus size="md" />}
onClick={(): void => openPicker()}
testId="add-panel"
>
New Panel
</Button>
)}
</div>
{canAddPanel && (
<Button
color="primary"
prefix={<Plus size="md" />}
onClick={(): void => openPicker()}
testId="add-panel"
>
New Panel
</Button>
)}
</div>
</div>
<PanelTypeSelectionModal

View File

@@ -7,6 +7,15 @@ import type { DashboardSection } from '../../../../utils';
import { useDashboardStore } from '../../../../store/useDashboardStore';
import { usePanelActionItems } from '../usePanelActionItems';
/** Keys of the disabled items, in order. */
function disabledKeys(
result: ReturnType<typeof usePanelActionItems>,
): unknown[] {
return result.items
.filter((item) => 'disabled' in item && item.disabled)
.map((item) => ('key' in item ? item.key : undefined));
}
const mockOpenEditor = jest.fn();
jest.mock(
'pages/DashboardPageV2/DashboardContainer/hooks/useOpenPanelEditor',
@@ -115,7 +124,7 @@ describe('usePanelActionItems', () => {
beforeEach(() => {
jest.clearAllMocks();
mockRole = 'ADMIN';
useDashboardStore.setState({ isEditable: true });
useDashboardStore.setState({ canEditDashboard: true, isLocked: false });
});
it('ADMIN on an editable dashboard with a known kind gets the full V1-parity set, divider-separated', () => {
@@ -162,13 +171,11 @@ describe('usePanelActionItems', () => {
]);
});
it('read-only dashboard keeps the non-mutating actions (View, Download, Create Alerts)', () => {
useDashboardStore.setState({ isEditable: false });
it('no edit permission (view mode) hides the edit actions entirely', () => {
useDashboardStore.setState({ canEditDashboard: false });
const { result } = renderHook(() =>
usePanelActionItems({ ...baseArgs, panelActions: undefined }),
);
// View, the Download submenu (PNG/SVG) and Create Alerts are all
// non-mutating, so they survive on a read-only dashboard (V1 parity).
expect(itemKeys(result.current)).toStrictEqual([
'view-panel',
'divider',
@@ -177,6 +184,32 @@ describe('usePanelActionItems', () => {
]);
});
it('locked (edit mode) keeps the edit actions visible but disabled', () => {
useDashboardStore.setState({ canEditDashboard: true, isLocked: true });
// A locked dashboard mounts panels without layout context (no panelActions).
const { result } = renderHook(() =>
usePanelActionItems({ ...baseArgs, panelActions: undefined }),
);
expect(itemKeys(result.current)).toStrictEqual([
'view-panel',
'edit-panel',
'clone-panel',
'divider',
'download',
'create-alert',
'divider',
'move',
'divider',
'delete-panel',
]);
expect(disabledKeys(result.current)).toStrictEqual([
'edit-panel',
'clone-panel',
'move',
'delete-panel',
]);
});
it('move is disabled when there is no other titled section to move to', () => {
const { result } = renderHook(() =>
usePanelActionItems({

View File

@@ -1,5 +1,12 @@
import { useCallback, useMemo } from 'react';
import { Bell, Copy, Fullscreen, PenLine, Trash2 } from '@signozhq/icons';
import { type ReactNode, useCallback, useMemo } from 'react';
import {
Bell,
Copy,
FolderInput,
Fullscreen,
PenLine,
Trash2,
} from '@signozhq/icons';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import useComponentPermission from 'hooks/useComponentPermission';
@@ -23,6 +30,8 @@ import { useMovePanelToSection } from '../hooks/useMovePanelToSection';
import { useViewPanel } from '../hooks/useViewPanel';
import { buildMoveItems } from '../utils/buildMoveItems';
import { PANEL_ACTION_META } from './panelActionMeta';
import DisabledMenuItemLabel from '../../../components/DisabledMenuItemLabel/DisabledMenuItemLabel';
import { DASHBOARD_LOCKED_REASON } from '../../../hooks/useDashboardEditGuard';
// Stable fallback so renders without layout context don't churn the mutation
// hooks' deps (a fresh [] each render would re-create their callbacks).
@@ -67,7 +76,8 @@ export function usePanelActionItems({
],
user.role,
);
const isEditable = useDashboardStore((s) => s.isEditable);
const canEditDashboard = useDashboardStore((s) => s.canEditDashboard);
const isLocked = useDashboardStore((s) => s.isLocked);
const openPanelEditor = useOpenPanelEditor();
const createAlert = useCreateAlertFromPanel();
const { openView } = useViewPanel();
@@ -102,6 +112,18 @@ export function usePanelActionItems({
const { request: requestDelete } = deleteConfirm;
const items = useMemo<MenuItem[]>(() => {
// Edit actions are shown only to edit-permitted users; the lock is their only
// disabled state, surfaced as a hover tooltip on the row.
const canEdit = canEditDashboard;
const label = (text: string): ReactNode =>
isLocked ? (
<DisabledMenuItemLabel reason={DASHBOARD_LOCKED_REASON}>
{text}
</DisabledMenuItemLabel>
) : (
text
);
const panelGroup: MenuItem[] = [];
if (panelCapabilities.view) {
panelGroup.push({
@@ -111,26 +133,30 @@ export function usePanelActionItems({
onClick: (): void => openView(panelId),
});
}
if (isEditable && canEditWidget && panelCapabilities.edit) {
if (canEdit && canEditWidget && panelCapabilities.edit) {
panelGroup.push({
key: 'edit-panel',
label: 'Edit panel',
label: label('Edit panel'),
icon: <PenLine size={14} />,
disabled: isLocked,
onClick: (): void => openPanelEditor(panelId),
});
}
// Clone needs the section context to place the copy, so — unlike Edit —
// it requires panelActions.
if (isEditable && canEditWidget && panelActions && panelCapabilities.clone) {
if (canEdit && canEditWidget && panelCapabilities.clone) {
// Needs section context to place the copy; disabled without it.
panelGroup.push({
key: 'clone-panel',
label: 'Clone',
label: label('Clone'),
icon: <Copy size={14} />,
onClick: (): void =>
void clonePanel({
panelId,
layoutIndex: panelActions.currentLayoutIndex,
}),
disabled: isLocked || !panelActions,
onClick: (): void => {
if (panelActions) {
void clonePanel({
panelId,
layoutIndex: panelActions.currentLayoutIndex,
});
}
},
});
}
@@ -140,7 +166,7 @@ export function usePanelActionItems({
}
// Create Alerts opens a new tab and never mutates the dashboard, so —
// unlike edit/clone — it isn't gated on `isEditable` (V1 parity).
// unlike edit/clone — it isn't gated on editability (V1 parity).
if (panelCapabilities.createAlert) {
dataGroup.push({
key: 'create-alert',
@@ -150,24 +176,35 @@ export function usePanelActionItems({
});
}
const moveGroup: MenuItem[] =
canMove && panelActions
? buildMoveItems({
sections,
currentLayoutIndex: panelActions.currentLayoutIndex,
panelId,
movePanel,
})
: [];
let moveGroup: MenuItem[] = [];
if (canEdit && canMove) {
moveGroup =
!isLocked && panelActions
? buildMoveItems({
sections,
currentLayoutIndex: panelActions.currentLayoutIndex,
panelId,
movePanel,
})
: [
{
key: 'move',
label: label('Move to section'),
icon: <FolderInput size={14} />,
disabled: true,
},
];
}
const deleteGroup: MenuItem[] =
canDelete && panelActions
canEdit && canDelete
? [
{
key: 'delete-panel',
danger: true,
icon: <Trash2 size={14} />,
label: 'Delete panel',
label: label('Delete panel'),
disabled: isLocked || !panelActions,
onClick: (): void => requestDelete(),
},
]
@@ -179,7 +216,8 @@ export function usePanelActionItems({
index === 0 ? group : [{ type: 'divider' as const }, ...group],
);
}, [
isEditable,
canEditDashboard,
isLocked,
canEditWidget,
canMove,
canDelete,

View File

@@ -5,6 +5,8 @@ import { Button } from '@signozhq/ui/button';
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
import ConfirmDeleteDialog from '../../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
import DisabledControlTooltip from '../../../components/DisabledControlTooltip/DisabledControlTooltip';
import { DASHBOARD_LOCKED_REASON } from '../../../hooks/useDashboardEditGuard';
import { useCreatePanel } from '../../../hooks/useCreatePanel';
import type { DashboardSection } from '../../../utils';
import PanelTypeSelectionModal from '../../Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
@@ -28,7 +30,8 @@ interface SectionProps {
}
function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const canEditDashboard = useDashboardStore((s) => s.canEditDashboard);
const isLocked = useDashboardStore((s) => s.isLocked);
const {
isPickerOpen,
openPicker,
@@ -104,8 +107,9 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
onToggle={toggle}
repeatVariable={section.repeatVariable}
dragHandle={dragHandle}
disabledReason={isLocked ? DASHBOARD_LOCKED_REASON : ''}
actions={
isEditable
canEditDashboard
? {
onRename: (): void => setIsRenaming(true),
onAddPanel: (): void => openPicker(section.layoutIndex),
@@ -119,17 +123,25 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
grid
) : (
<div className={styles.emptySection}>
{isEditable && (
<Button
type="button"
variant="dashed"
color="secondary"
prefix={<Plus size="md" />}
onClick={(): void => openPicker(section.layoutIndex)}
testId={`section-add-panel-${section.id}`}
{canEditDashboard && (
<DisabledControlTooltip
reason={DASHBOARD_LOCKED_REASON}
disabled={isLocked}
>
New Panel
</Button>
<Button
type="button"
variant="dashed"
color="secondary"
prefix={<Plus size="md" />}
disabled={isLocked}
onClick={
isLocked ? undefined : (): void => openPicker(section.layoutIndex)
}
testId={`section-add-panel-${section.id}`}
>
New Panel
</Button>
</DisabledControlTooltip>
)}
</div>
))}

View File

@@ -1,13 +1,16 @@
import { useMemo } from 'react';
import { type ReactNode, useMemo } from 'react';
import { EllipsisVertical, PenLine, Plus, Trash2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import DisabledMenuItemLabel from '../../../components/DisabledMenuItemLabel/DisabledMenuItemLabel';
import styles from './SectionActionsMenu.module.scss';
interface SectionActionsMenuProps {
sectionId: string;
/** Non-empty when edits are unavailable — items render disabled with this reason. */
disabledReason?: string;
onAddPanel?: () => void;
onRename?: () => void;
onDeleteSection?: () => void;
@@ -15,17 +18,28 @@ interface SectionActionsMenuProps {
function SectionActionsMenu({
sectionId,
disabledReason = '',
onAddPanel,
onRename,
onDeleteSection,
}: SectionActionsMenuProps): JSX.Element {
const items = useMemo<MenuItem[]>(() => {
const disabled = !!disabledReason;
const label = (text: string): ReactNode =>
disabled ? (
<DisabledMenuItemLabel reason={disabledReason}>
{text}
</DisabledMenuItemLabel>
) : (
text
);
const result: MenuItem[] = [];
if (onAddPanel) {
result.push({
key: 'add-panel',
icon: <Plus size={14} />,
label: 'Add panel',
label: label('Add panel'),
disabled,
onClick: onAddPanel,
});
}
@@ -33,7 +47,8 @@ function SectionActionsMenu({
result.push({
key: 'rename',
icon: <PenLine size={14} />,
label: 'Rename section',
label: label('Rename section'),
disabled,
onClick: onRename,
});
}
@@ -44,13 +59,14 @@ function SectionActionsMenu({
key: 'delete-section',
danger: true,
icon: <Trash2 size={14} />,
label: 'Delete section',
label: label('Delete section'),
disabled,
onClick: onDeleteSection,
},
);
}
return result;
}, [onAddPanel, onRename, onDeleteSection]);
}, [disabledReason, onAddPanel, onRename, onDeleteSection]);
return (
<DropdownMenuSimple menu={{ items }}>

View File

@@ -29,8 +29,10 @@ interface SectionHeaderProps {
repeatVariable?: string;
/** Provided by SortableSection in sectioned mode; absent for untitled/free-flow. */
dragHandle?: SectionDragHandle;
/** Present only in editable mode; absent (read-only) when locked/no-permission. */
/** Present for edit-permitted users; absent (no menu) in view mode. */
actions?: SectionHeaderActions;
/** Non-empty when locked — actions render disabled with this reason. */
disabledReason?: string;
}
function SectionHeader({
@@ -41,6 +43,7 @@ function SectionHeader({
repeatVariable,
dragHandle,
actions,
disabledReason = '',
}: SectionHeaderProps): JSX.Element {
return (
<div className={cx(styles.header, { [styles.headerOpen]: open })}>
@@ -79,6 +82,7 @@ function SectionHeader({
{actions ? (
<SectionActionsMenu
sectionId={sectionId}
disabledReason={disabledReason}
onAddPanel={actions.onAddPanel}
onRename={actions.onRename}
onDeleteSection={actions.onDeleteSection}

View File

@@ -1,31 +0,0 @@
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { useDashboardStore } from '../store/useDashboardStore';
import styles from './VariablesBar.module.scss';
/**
* Full-width labelled "Add variable" button shown in the empty state, before any
* variables exist. Opens the Variables settings tab with the add form primed.
*/
function AddVariableFull(): JSX.Element {
const requestSettings = useDashboardStore((s) => s.requestSettings);
return (
<Button
variant="outlined"
color="secondary"
size="md"
className={styles.addVariable}
prefix={<Plus size={14} />}
testId="dashboard-variables-add"
onClick={(): void =>
requestSettings({ tab: 'Variables', addVariable: true })
}
>
Add variable
</Button>
);
}
export default AddVariableFull;

View File

@@ -1,35 +0,0 @@
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { useDashboardStore } from '../store/useDashboardStore';
import styles from './VariablesBar.module.scss';
/**
* Compact "+" trigger (label on hover) shown after the variable pills once at
* least one variable exists. Opens the Variables settings tab with the add form
* primed.
*/
function AddVariableIcon(): JSX.Element {
const requestSettings = useDashboardStore((s) => s.requestSettings);
return (
<TooltipSimple side="top" title="Add variable">
<Button
variant="outlined"
color="secondary"
size="icon"
className={styles.addVariable}
aria-label="Add variable"
testId="dashboard-variables-add"
onClick={(): void =>
requestSettings({ tab: 'Variables', addVariable: true })
}
>
<Plus size={14} />
</Button>
</TooltipSimple>
);
}
export default AddVariableIcon;

View File

@@ -1,14 +1,17 @@
import { useMemo } from 'react';
import { SolidInfoCircle } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
// eslint-disable-next-line signoz/no-antd-components -- lightweight description tooltip, matches V1
import { Tooltip } from 'antd';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import { sortValuesByOrder } from '../DashboardSettings/Variables/variableFormModel';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
import type { VariableSelection, VariableSelectionMap } from './selectionTypes';
import CustomSelector from './selectors/CustomSelector';
import DynamicSelector from './selectors/DynamicSelector';
import QuerySelector from './selectors/QuerySelector';
import TextSelector from './selectors/TextSelector';
import ValueSelector from './selectors/ValueSelector';
import styles from './VariablesBar.module.scss';
interface VariableSelectorProps {
@@ -19,7 +22,7 @@ interface VariableSelectorProps {
selections: VariableSelectionMap;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
/** Batched fill applied when options resolve (Query/Dynamic/Custom auto-selection). */
/** Batched fill applied when options resolve (Query/Dynamic auto-selection). */
onAutoSelect: (selection: VariableSelection) => void;
}
@@ -32,6 +35,17 @@ function VariableSelector({
onChange,
onAutoSelect,
}: VariableSelectorProps): JSX.Element {
const customOptions = useMemo(
() =>
variable.type === 'CUSTOM'
? sortValuesByOrder(
commaValuesParser(variable.customValue),
variable.sort,
).map(String)
: [],
[variable],
);
const renderControl = (): JSX.Element => {
switch (variable.type) {
case 'TEXT':
@@ -67,11 +81,13 @@ function VariableSelector({
case 'CUSTOM':
default:
return (
<CustomSelector
variable={variable}
<ValueSelector
options={customOptions}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
selection={selection}
onChange={onChange}
onAutoSelect={onAutoSelect}
testId={`variable-select-${variable.name}`}
/>
);
}

View File

@@ -1,21 +1,9 @@
// Block (not flex) so the strip wraps around the floated time selector: one line
// beside it when collapsed, and — via `.stripExpanded`'s `clear: both` — dropping
// full-width below it when expanded. A flex `.bar` would be a BFC that sits beside
// the float and makes the child strip's `clear` inert.
.bar {
min-width: 0;
}
.addVariable {
flex: none;
border-style: dashed !important;
border-color: var(--l3-border) !important;
}
.strip {
display: flow-root;
// Collapsed: one line only, so the trailing add "+" never wraps below.
white-space: nowrap;
}
.stripExpanded {
@@ -24,12 +12,10 @@
gap: 8px;
padding-top: 12px;
overflow: visible;
white-space: normal;
clear: both;
.variableSlot,
.moreButton,
.addSlot {
.moreButton {
margin: 0;
}
@@ -62,12 +48,6 @@
}
.moreButton {
display: inline-flex;
margin-right: 8px;
vertical-align: top;
}
.addSlot {
display: inline-flex;
vertical-align: top;
}

View File

@@ -6,9 +6,6 @@ import cx from 'classnames';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { useInlineOverflowCount } from 'hooks/useInlineOverflowCount';
import { useDashboardStore } from '../store/useDashboardStore';
import AddVariableFull from './AddVariableFull';
import AddVariableIcon from './AddVariableIcon';
import type { VariableSelection } from './selectionTypes';
import { useVariableSelection } from './useVariableSelection';
import VariableSelector from './VariableSelector';
@@ -47,19 +44,15 @@ interface VariablesBarProps {
function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
const { variables, selection, setSelection, autoSelect } =
useVariableSelection(dashboard);
const isEditable = useDashboardStore((s) => s.isEditable);
const [expanded, setExpanded] = useState(false);
const { containerRef, visibleCount, overflowCount } = useInlineOverflowCount({
itemCount: variables.length,
gap: 8,
// Reserve room for the "+N" trigger and the add "+" so both stay on one line.
reserveWidth: isEditable ? 112 : 48,
reserveWidth: 48,
enabled: !expanded,
});
// Editors can add a variable even before any exist; viewers with no variables
// have nothing to show.
if (variables.length === 0 && !isEditable) {
if (variables.length === 0) {
return null;
}
@@ -81,14 +74,6 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
</Button>
);
if (variables.length === 0) {
return (
<div className={styles.bar} data-testid="dashboard-variables-bar">
<AddVariableFull />
</div>
);
}
return (
<div className={styles.bar} data-testid="dashboard-variables-bar">
<div
@@ -143,15 +128,6 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
)}
</span>
)}
{/* After the more/less trigger, in every state. Kept inline (not block)
so the row still flows under the floated time selector, and always
mounted so measuring never toggles it. */}
{isEditable && (
<span className={styles.addSlot}>
<AddVariableIcon />
</span>
)}
</div>
</div>
);

View File

@@ -1,113 +0,0 @@
import { renderHook } from '@testing-library/react';
import {
emptyVariableFormModel,
type VariableFormModel,
} from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableSelection } from '../selectionTypes';
import { useAutoSelect } from '../useAutoSelect';
function model(overrides: Partial<VariableFormModel>): VariableFormModel {
return { ...emptyVariableFormModel(), ...overrides };
}
function run(
variable: VariableFormModel,
options: string[],
selection: VariableSelection,
): VariableSelection | undefined {
const onAutoSelect = jest.fn();
renderHook(() => useAutoSelect(variable, options, selection, onAutoSelect));
return onAutoSelect.mock.calls[0]?.[0];
}
describe('useAutoSelect', () => {
it('materializes a query ALL selection to the full option array', () => {
const next = run(
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
['a', 'b', 'c'],
{ value: null, allSelected: true },
);
expect(next).toStrictEqual({ value: ['a', 'b', 'c'], allSelected: true });
});
it('re-materializes ALL when the options grow', () => {
const next = run(
model({ type: 'CUSTOM', multiSelect: true, showAllOption: true }),
['a', 'b', 'c', 'd'],
{ value: ['a', 'b', 'c'], allSelected: true },
);
expect(next).toStrictEqual({
value: ['a', 'b', 'c', 'd'],
allSelected: true,
});
});
it('leaves a query ALL selection untouched when already the full set', () => {
const next = run(
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
['a', 'b'],
{ value: ['a', 'b'], allSelected: true },
);
expect(next).toBeUndefined();
});
it('does NOT materialize a dynamic ALL selection (it sends __all__)', () => {
const next = run(
model({ type: 'DYNAMIC', multiSelect: true, showAllOption: true }),
['a', 'b'],
{ value: null, allSelected: true },
);
expect(next).toBeUndefined();
});
it('keeps the still-valid subset of a multi-select when options re-scope', () => {
const next = run(
model({ type: 'QUERY', multiSelect: true }),
['a', 'b', 'd'],
{ value: ['a', 'b', 'c'], allSelected: false },
);
expect(next).toStrictEqual({ value: ['a', 'b'], allSelected: false });
});
it('re-defaults a multi-select when none of the selected values remain', () => {
const next = run(model({ type: 'QUERY', multiSelect: true }), ['x', 'y'], {
value: ['a', 'b'],
allSelected: false,
});
expect(next).toStrictEqual({ value: ['x'], allSelected: false });
});
it('auto-selects the default (if present) for a single-select', () => {
const next = run(
model({ type: 'QUERY', defaultValue: 'b' }),
['a', 'b', 'c'],
{ value: '', allSelected: false },
);
expect(next).toStrictEqual({ value: 'b', allSelected: false });
});
it('auto-selects the first option when the default is not available', () => {
const next = run(model({ type: 'QUERY', defaultValue: 'z' }), ['a', 'b'], {
value: '',
allSelected: false,
});
expect(next).toStrictEqual({ value: 'a', allSelected: false });
});
it('leaves a valid single selection untouched', () => {
const next = run(model({ type: 'QUERY' }), ['a', 'b'], {
value: 'b',
allSelected: false,
});
expect(next).toBeUndefined();
});
it('does nothing while options are empty', () => {
const next = run(model({ type: 'QUERY' }), [], {
value: '',
allSelected: false,
});
expect(next).toBeUndefined();
});
});

View File

@@ -1,50 +0,0 @@
import { useMemo } from 'react';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import { sortValuesByOrder } from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableSelection } from '../selectionTypes';
import { useAutoSelect } from '../useAutoSelect';
import ValueSelector from './ValueSelector';
interface CustomSelectorProps {
variable: VariableFormModel;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
onAutoSelect: (selection: VariableSelection) => void;
}
/**
* Custom-variable options come from the comma-separated `customValue` (no fetch),
* but still auto-select a default/first option so the variable is never left blank.
*/
function CustomSelector({
variable,
selection,
onChange,
onAutoSelect,
}: CustomSelectorProps): JSX.Element {
const options = useMemo(
() =>
sortValuesByOrder(
commaValuesParser(variable.customValue),
variable.sort,
).map(String),
[variable.customValue, variable.sort],
);
useAutoSelect(variable, options, selection, onAutoSelect);
return (
<ValueSelector
options={options}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}
/>
);
}
export default CustomSelector;

View File

@@ -3,7 +3,6 @@ import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
import { DASHBOARD_CACHE_TIME } from 'constants/queryCacheTime';
import type { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
@@ -71,7 +70,7 @@ function DynamicSelector({
(s) => s.onVariableFetchFailure,
);
const { data, isFetching, error, refetch } = useQuery(
const { data, isFetching } = useQuery(
[
'dashboard-variable-dynamic',
variable.name,
@@ -96,8 +95,6 @@ function DynamicSelector({
!!variable.dynamicAttribute &&
(isVariableFetching || (isVariableSettled && hasVariableFetchedOnce)),
refetchOnWindowFocus: false,
// Each cycle mints a fresh key; a small cacheTime bounds cache churn.
cacheTime: DASHBOARD_CACHE_TIME,
onSettled: (_, error) =>
error
? onVariableFetchFailure(variable.name)
@@ -120,10 +117,6 @@ function DynamicSelector({
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
loading={isFetching || isVariableWaiting}
errorMessage={error ? (error as Error).message || null : null}
onRetry={(): void => {
void refetch();
}}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}

View File

@@ -3,7 +3,6 @@ import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import { DASHBOARD_CACHE_TIME } from 'constants/queryCacheTime';
import type { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
@@ -63,7 +62,7 @@ function QuerySelector({
(s) => s.onVariableFetchFailure,
);
const { data, isFetching, error, refetch } = useQuery(
const { data, isFetching } = useQuery(
[
'dashboard-variable',
variable.name,
@@ -80,8 +79,6 @@ function QuerySelector({
{
enabled: isVariableFetching || (isVariableSettled && hasVariableFetchedOnce),
refetchOnWindowFocus: false,
// Each cycle mints a fresh key; a small cacheTime bounds cache churn.
cacheTime: DASHBOARD_CACHE_TIME,
onSettled: (_, error) =>
error
? onVariableFetchFailure(variable.name)
@@ -107,10 +104,6 @@ function QuerySelector({
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
loading={isFetching || isVariableWaiting}
errorMessage={error ? (error as Error).message || null : null}
onRetry={(): void => {
void refetch();
}}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}

View File

@@ -1,6 +1,7 @@
import { useMemo } from 'react';
import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
import type { OptionData } from 'components/NewSelect/types';
import { ALL_SELECT_VALUE } from 'container/DashboardContainer/utils';
import type { VariableSelection } from '../selectionTypes';
import styles from '../VariablesBar.module.scss';
@@ -13,9 +14,6 @@ interface ValueSelectorProps {
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
testId?: string;
/** Option-fetch error surfaced in the dropdown, with a retry action. */
errorMessage?: string | null;
onRetry?: () => void;
}
/**
@@ -31,8 +29,6 @@ function ValueSelector({
selection,
onChange,
testId,
errorMessage,
onRetry,
}: ValueSelectorProps): JSX.Element {
const optionData = useMemo<OptionData[]>(
() => options.map((option) => ({ label: option, value: option })),
@@ -40,11 +36,8 @@ function ValueSelector({
);
if (multiSelect) {
// All-selected → hand CustomMultiSelect the full option set so it engages its
// "all" path (overlay when closed, every option checked when open). Passing the
// scalar sentinel instead makes it render a literal `__ALL__` row.
const value = selection.allSelected
? options
? ALL_SELECT_VALUE
: (Array.isArray(selection.value) ? selection.value : []).map(String);
return (
<CustomMultiSelect
@@ -53,8 +46,6 @@ function ValueSelector({
options={optionData}
value={value}
loading={loading}
errorMessage={errorMessage}
onRetry={onRetry}
showSearch
placeholder="Select value"
enableAllSelection={showAllOption}
@@ -91,8 +82,6 @@ function ValueSelector({
: String(selection.value)
}
loading={loading}
errorMessage={errorMessage}
onRetry={onRetry}
showSearch
placeholder="Select value"
onChange={(next): void =>

View File

@@ -1,61 +1,12 @@
import { useEffect } from 'react';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
import type {
SelectedVariableValue,
VariableSelection,
} from './selectionTypes';
/** The variable's default (or first option) as a fresh selection. */
function fillDefault(
variable: VariableFormModel,
options: string[],
): VariableSelection {
const dv = variable.defaultValue;
const fallback = Array.isArray(dv) ? dv[0] : dv;
const initial = fallback && options.includes(fallback) ? fallback : options[0];
return {
value: variable.multiSelect ? [initial] : initial,
allSelected: false,
};
}
/** For an all-selected variable, the value to materialize (or null if unchanged). */
function reconcileAllSelected(
variable: VariableFormModel,
options: string[],
current: SelectedVariableValue,
): VariableSelection | null {
// Dynamic ALL travels as the `__all__` wire sentinel and shows ALL from the
// flag, so it needs no materialized value. Query/custom ALL must carry the full
// option array (the payload builder can't expand it) — keep it in sync.
if (!variable.multiSelect || variable.type === 'DYNAMIC') {
return null;
}
const alreadyFull =
Array.isArray(current) &&
current.length === options.length &&
current.every((c) => options.includes(String(c)));
return alreadyFull ? null : { value: options, allSelected: true };
}
function isValidSingle(
current: SelectedVariableValue,
options: string[],
): boolean {
return (
!Array.isArray(current) &&
current !== '' &&
current !== null &&
current !== undefined &&
options.includes(String(current))
);
}
import type { VariableSelection } from './selectionTypes';
/**
* Reconciles a variable's selection with its freshly-fetched options: materialize
* ALL to the full set, keep a still-valid multi-select subset, else auto-pick the
* default (or first option) so dependent children always have a usable value.
* When fetched options arrive and the current selection isn't one of them,
* auto-pick the variable's default (if present in the options) or the first
* option so dependent children always have a usable parent value.
*/
export function useAutoSelect(
variable: VariableFormModel,
@@ -64,36 +15,27 @@ export function useAutoSelect(
onAutoSelect: (selection: VariableSelection) => void,
): void {
useEffect(() => {
if (options.length === 0) {
if (options.length === 0 || selection.allSelected) {
return;
}
const current = selection.value;
if (selection.allSelected) {
const next = reconcileAllSelected(variable, options, current);
if (next) {
onAutoSelect(next);
}
const isValid = Array.isArray(current)
? current.length > 0 && current.every((c) => options.includes(String(c)))
: current !== '' &&
current !== null &&
current !== undefined &&
options.includes(String(current));
if (isValid) {
return;
}
if (variable.multiSelect && Array.isArray(current) && current.length > 0) {
const valid = current.map(String).filter((c) => options.includes(c));
if (valid.length === current.length) {
return;
}
onAutoSelect(
valid.length > 0
? { value: valid, allSelected: false }
: fillDefault(variable, options),
);
return;
}
if (!variable.multiSelect && isValidSingle(current, options)) {
return;
}
onAutoSelect(fillDefault(variable, options));
const dv = variable.defaultValue;
const fallback = Array.isArray(dv) ? dv[0] : dv;
const initial =
fallback && options.includes(fallback) ? fallback : options[0];
onAutoSelect({
value: variable.multiSelect ? [initial] : initial,
allSelected: false,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [options]);
}

View File

@@ -33,36 +33,21 @@ export const variablesUrlParser = parseAsJson<
);
function defaultSelection(model: VariableFormModel): VariableSelection {
// `defaultValue` is a string | string[] on the wire.
const def = model.defaultValue;
// Explicit ALL sentinel, or a multi-select allowing ALL with no default → ALL.
if (
def === ALL_SELECTED ||
(Array.isArray(def) && def.length === 1 && def[0] === ALL_SELECTED)
) {
return { value: null, allSelected: true };
}
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 };
}
if (model.multiSelect && model.showAllOption) {
return { value: null, allSelected: true };
}
return { value: model.multiSelect ? [] : '', allSelected: false };
}
function fromUrlValue(
raw: SelectedVariableValue,
model: VariableFormModel,
): VariableSelection {
// Only read the `__ALL__` sentinel as "ALL" for variables that support it —
// otherwise a legitimate value of "__ALL__" (e.g. a text var) is taken literally.
if (raw === ALL_SELECTED && model.multiSelect && model.showAllOption) {
return { value: null, allSelected: true };
}
return { value: raw, allSelected: false };
function fromUrlValue(raw: SelectedVariableValue): VariableSelection {
return raw === ALL_SELECTED
? { value: null, allSelected: true }
: { value: raw, allSelected: false };
}
interface UseVariableSelection {
@@ -127,7 +112,7 @@ export function useVariableSelection(
variables.forEach((variable) => {
const urlValue = urlValues?.[variable.name];
if (urlValue !== undefined) {
seeded[variable.name] = fromUrlValue(urlValue, variable);
seeded[variable.name] = fromUrlValue(urlValue);
} else if (stored[variable.name]) {
seeded[variable.name] = stored[variable.name];
} else {
@@ -135,27 +120,12 @@ export function useVariableSelection(
}
});
setVariableValues(dashboardId, seeded);
// Drop URL selections for variables that no longer exist (renamed/removed),
// so a shared link doesn't carry stale entries a later variable could inherit.
if (urlValues) {
const validNames = new Set(variables.map((v) => v.name));
const orphaned = Object.keys(urlValues).some((n) => !validNames.has(n));
if (orphaned) {
const pruned: Record<string, SelectedVariableValue> = {};
Object.entries(urlValues).forEach(([name, value]) => {
if (validNames.has(name)) {
pruned[name] = value;
}
});
void setUrlValues(pruned);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboardId, variables]);
// Start a full fetch cycle on load / dependency-order / time change. A value
// change instead goes through `enqueueDescendants`, not this effect.
// 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(',')}`;

View File

@@ -0,0 +1,9 @@
.trigger {
display: inline-flex;
}
.aboveOverlay {
// Lift the tooltip above the dropdown menu (z 50) and the antd Drawer (z 1000)
// so it is never clipped behind them. The tooltip content reads this variable.
--tooltip-z-index: 1100;
}

View File

@@ -0,0 +1,33 @@
import type { ReactNode } from 'react';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import styles from './DisabledControlTooltip.module.scss';
interface DisabledControlTooltipProps {
reason: string;
disabled: boolean;
children: ReactNode;
}
// A disabled button swallows hover, so the wrapping span is the tooltip trigger.
function DisabledControlTooltip({
reason,
disabled,
children,
}: DisabledControlTooltipProps): JSX.Element {
if (!disabled) {
return <>{children}</>;
}
return (
<TooltipSimple
title={reason}
arrow
disableHoverableContent
tooltipContentProps={{ className: styles.aboveOverlay }}
>
<span className={styles.trigger}>{children}</span>
</TooltipSimple>
);
}
export default DisabledControlTooltip;

View File

@@ -0,0 +1,10 @@
.label {
// Re-enable pointer events so the tooltip fires while the disabled row
// (pointer-events: none) suppresses selection.
pointer-events: auto;
}
.aboveOverlay {
// Lift the tooltip above the dropdown menu (z 50) and the antd Drawer (z 1000).
--tooltip-z-index: 1100;
}

View File

@@ -0,0 +1,28 @@
import type { ReactNode } from 'react';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import styles from './DisabledMenuItemLabel.module.scss';
interface DisabledMenuItemLabelProps {
reason: string;
children: ReactNode;
}
// A disabled row has pointer-events: none, so the label re-enables them to catch hover.
function DisabledMenuItemLabel({
reason,
children,
}: DisabledMenuItemLabelProps): JSX.Element {
return (
<TooltipSimple
title={reason}
arrow
disableHoverableContent
tooltipContentProps={{ className: styles.aboveOverlay }}
>
<span className={styles.label}>{children}</span>
</TooltipSimple>
);
}
export default DisabledMenuItemLabel;

View File

@@ -0,0 +1,34 @@
.footer {
display: flex;
flex-direction: column;
position: fixed;
bottom: 0;
left: 0;
width: 100%;
z-index: 100;
pointer-events: none;
}
.lockedText {
align-self: flex-end;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 6px;
border-radius: 4px 0 0 0;
background: var(--bg-sakura-500);
color: var(--l1-foreground);
backdrop-filter: blur(6px);
font-family: 'Space Mono';
font-size: 12px;
font-weight: 500;
line-height: 16px;
letter-spacing: 0.48px;
text-transform: uppercase;
}
.lockedBar {
width: 100%;
height: 6px;
background: var(--bg-sakura-500);
}

View File

@@ -0,0 +1,17 @@
import { LockKeyhole } from '@signozhq/icons';
import styles from './LockedIndicator.module.scss';
function LockedIndicator(): JSX.Element {
return (
<div className={styles.footer}>
<div className={styles.lockedText} data-testid="dashboard-locked-indicator">
<LockKeyhole size={14} />
Locked
</div>
<div className={styles.lockedBar} />
</div>
);
}
export default LockedIndicator;

View File

@@ -21,8 +21,8 @@ jest.mock('api/generated/services/dashboard', () => ({
jest.mock('../../store/useDashboardStore', () => ({
useDashboardStore: jest.fn(
(selector: (s: { dashboardId: string }) => unknown) =>
selector({ dashboardId: 'dash-1' }),
(selector: (s: { dashboardId: string; isEditable: boolean }) => unknown) =>
selector({ dashboardId: 'dash-1', isEditable: true }),
),
}));

View File

@@ -0,0 +1,48 @@
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import useComponentPermission from 'hooks/useComponentPermission';
import { useAppContext } from 'providers/App/App';
import {
DASHBOARD_LOCKED_REASON,
DASHBOARD_NO_EDIT_PERMISSION_REASON,
} from '../store/slices/editContextSlice';
// Re-exported from the (dependency-light) slice so leaf modules / tests can import
// the reason strings without pulling this hook's provider chain.
export {
DASHBOARD_LOCKED_REASON,
DASHBOARD_NO_EDIT_PERMISSION_REASON,
} from '../store/slices/editContextSlice';
export interface DashboardEditGuard {
isEditable: boolean;
isLocked: boolean;
canEditDashboard: boolean;
editDisabledReason: string;
}
// Editability + reason, derived from the dashboard (used where the store is cold,
// e.g. the panel-editor route reached by direct URL).
export function useDashboardEditGuard(
dashboard: DashboardtypesGettableDashboardV2DTO | undefined,
): DashboardEditGuard {
const { user } = useAppContext();
const [editDashboardPermission] = useComponentPermission(
['edit_dashboard'],
user.role,
);
const canEditDashboard = !!editDashboardPermission;
const isLocked = !!dashboard?.locked;
let editDisabledReason = '';
if (isLocked) {
editDisabledReason = DASHBOARD_LOCKED_REASON;
} else if (!canEditDashboard) {
editDisabledReason = DASHBOARD_NO_EDIT_PERMISSION_REASON;
}
return {
isEditable: canEditDashboard && !isLocked,
isLocked,
canEditDashboard,
editDisabledReason,
};
}

View File

@@ -1,3 +1,4 @@
import { useCallback } from 'react';
import { useMutation, useQueryClient } from 'react-query';
import {
getGetDashboardV2QueryKey,
@@ -11,6 +12,7 @@ import type {
import APIError from 'types/api/error';
import { applyJsonPatch } from '../optimistic/applyJsonPatch';
import { DASHBOARD_LOCKED_REASON } from '../store/slices/editContextSlice';
import { useDashboardStore } from '../store/useDashboardStore';
/** Cached dashboard snapshot, kept for rollback on error. */
@@ -35,6 +37,7 @@ export function useOptimisticPatch(
dashboardIdOverride?: string,
): UseOptimisticPatch {
const storeDashboardId = useDashboardStore((s) => s.dashboardId);
const storeIsEditable = useDashboardStore((s) => s.isEditable);
const dashboardId = dashboardIdOverride ?? storeDashboardId;
const queryClient = useQueryClient();
const queryKey = getGetDashboardV2QueryKey({ id: dashboardId });
@@ -69,8 +72,22 @@ export function useOptimisticPatch(
},
});
// Defense-in-depth: block edits when the store is warm for this dashboard and it
// isn't editable. Skipped when the store isn't seeded for this id (panel editor
// via direct URL), where that surface gates its own save.
const { mutateAsync } = mutation;
const patchAsync = useCallback(
(ops: DashboardtypesJSONPatchOperationDTO[]): Promise<unknown> => {
if (storeDashboardId === dashboardId && !storeIsEditable) {
return Promise.reject(new Error(DASHBOARD_LOCKED_REASON));
}
return mutateAsync(ops);
},
[storeDashboardId, dashboardId, storeIsEditable, mutateAsync],
);
return {
patchAsync: mutation.mutateAsync,
patchAsync,
isPatching: mutation.isLoading,
error: mutation.error ?? null,
};

View File

@@ -2,16 +2,16 @@ import { useEffect } from 'react';
import { FullScreen, useFullScreenHandle } from 'react-full-screen';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import useComponentPermission from 'hooks/useComponentPermission';
import { useAppContext } from 'providers/App/App';
import DashboardPageToolbar from './DashboardPageToolbar';
import PanelsAndSectionsLayout from './PanelsAndSectionsLayout';
import { useDashboardEditGuard } from './hooks/useDashboardEditGuard';
import { useResolvedVariables } from './hooks/useResolvedVariables';
import { useSyncVariablesForSuggestions } from './hooks/useSyncVariablesForSuggestions';
import { useDashboardStore } from './store/useDashboardStore';
import styles from './DashboardContainer.module.scss';
import DashboardPageHeader from './components/DashboardPageHeader/DashboardPageHeader';
import LockedIndicator from './components/LockedIndicator/LockedIndicator';
import { Base64Icons } from './DashboardSettings/Overview/utils';
interface DashboardContainerProps {
@@ -33,18 +33,15 @@ function DashboardContainer({
const fullScreenHandle = useFullScreenHandle();
const { user } = useAppContext();
const [editDashboardPermission] = useComponentPermission(
['edit_dashboard'],
user.role,
);
const { isLocked, canEditDashboard } = useDashboardEditGuard(dashboard);
// Seed during render (not an effect) so the first Panel render already sees the id —
// useDashboardFetchRequired throws on a missing id. setEditContext self-guards.
const setEditContext = useDashboardStore((s) => s.setEditContext);
setEditContext({
dashboardId: dashboard.id,
isEditable: !dashboard.locked && editDashboardPermission,
isLocked,
canEditDashboard,
refetch,
});
@@ -59,17 +56,10 @@ function DashboardContainer({
return (
<FullScreen handle={fullScreenHandle}>
<div className={styles.container}>
{!fullScreenHandle.active && (
<>
<DashboardPageHeader title={name} image={image} />
<DashboardPageToolbar
dashboard={dashboard}
handle={fullScreenHandle}
refetch={refetch}
/>
</>
)}
<DashboardPageHeader title={name} image={image} />
<DashboardPageToolbar dashboard={dashboard} handle={fullScreenHandle} />
<PanelsAndSectionsLayout layouts={spec.layouts} panels={spec.panels} />
{isLocked && <LockedIndicator />}
</div>
</FullScreen>
);

View File

@@ -77,24 +77,17 @@ describe('buildVariablesPayload', () => {
});
});
it('falls back to a string configured default when unselected', () => {
it('falls back to a list variable configured default when unselected', () => {
const definitions = [
variable('region', 'QUERY', { defaultValue: 'us-east' }),
variable('region', 'QUERY', {
defaultValue: { value: 'us-east' },
} as unknown as Partial<VariableFormModel>),
];
expect(buildVariablesPayload(definitions, {})).toStrictEqual({
region: { type: 'query', value: 'us-east' },
});
});
it('falls back to an array configured default when unselected', () => {
const definitions = [
variable('region', 'QUERY', { defaultValue: ['us-east', 'us-west'] }),
];
expect(buildVariablesPayload(definitions, {})).toStrictEqual({
region: { type: 'query', value: ['us-east', 'us-west'] },
});
});
it('omits a variable with no selection and no default', () => {
const definitions = [variable('q', 'QUERY')];
expect(buildVariablesPayload(definitions, {})).toStrictEqual({});

View File

@@ -40,12 +40,9 @@ function configuredDefault(
if (definition.type === 'TEXT') {
return definition.textValue || undefined;
}
// `defaultValue` is `string | string[]` on the wire — use it directly.
const def = definition.defaultValue;
if (Array.isArray(def)) {
return def.length > 0 ? def : undefined;
}
return def || undefined;
return (
definition.defaultValue as { value?: SelectedVariableValue } | undefined
)?.value;
}
/**

View File

@@ -2,145 +2,109 @@ import {
emptyVariableFormModel,
type VariableFormModel,
} from '../../../DashboardSettings/Variables/variableFormModel';
import {
deriveFetchContext,
type VariableFetchContext,
} from '../../../VariablesBar/variableDependencies';
import { deriveFetchContext } from '../../../VariablesBar/variableDependencies';
import { useDashboardStore } from '../../useDashboardStore';
import { VariableFetchState } from '../variableFetchSlice.utils';
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;
}
function reset(names: string[], context: VariableFetchContext): void {
beforeEach(() => {
useDashboardStore.setState({
variableFetchStates: {},
variableLastUpdated: {},
variableCycleIds: {},
variableFetchContext: null,
});
store().initVariableFetch(names, context);
}
store().initVariableFetch(['q1', 'q2', 'd1'], context);
});
describe('variableFetchSlice', () => {
// q1 (root query) → q2 (query referencing $q1) ; d1, d2 (dynamics).
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 d2 = model({ name: 'd2', type: 'DYNAMIC', dynamicAttribute: 'ns' });
const context = deriveFetchContext([q1, q2, d1, d2]);
beforeEach(() => reset(['q1', 'q2', 'd1', 'd2'], context));
it('initializes every variable to idle', () => {
expect(states()).toStrictEqual({
q1: 'idle',
q2: 'idle',
d1: 'idle',
d2: 'idle',
q1: VariableFetchState.Idle,
q2: VariableFetchState.Idle,
d1: VariableFetchState.Idle,
});
});
it('enqueueFetchAll loads roots, waits dependents and (ungated) dynamics', () => {
store().enqueueFetchAll(false);
expect(states()).toMatchObject({
q1: 'loading',
q2: 'waiting',
d1: 'waiting',
d2: 'waiting',
expect(states()).toStrictEqual({
q1: VariableFetchState.Loading,
q2: VariableFetchState.Waiting,
d1: VariableFetchState.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');
expect(states().d1).toBe(VariableFetchState.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' });
expect(states()).toMatchObject({
q1: VariableFetchState.Idle,
q2: VariableFetchState.Loading,
d1: VariableFetchState.Waiting,
});
store().onVariableFetchComplete('q2');
expect(states()).toMatchObject({ q2: 'idle', d1: 'loading', d2: 'loading' });
expect(states()).toMatchObject({
q1: VariableFetchState.Idle,
q2: VariableFetchState.Idle,
d1: VariableFetchState.Loading,
});
});
it('ignores a settle for a variable that is not actively fetching', () => {
// q1 was never enqueued (still idle) — a stray complete must be a no-op.
it('enqueueDescendants revalidates only descendants + dynamics', () => {
store().enqueueFetchAll(false);
store().onVariableFetchComplete('q1');
expect(states().q1).toBe('idle');
expect(store().variableLastUpdated.q1 ?? 0).toBe(0);
});
it('changing a query variable revalidates query descendants but NOT dynamics', () => {
store().enqueueFetchAll(true);
['q1', 'q2', 'd1', 'd2'].forEach((n) => store().onVariableFetchComplete(n));
const before = { ...store().variableCycleIds };
store().onVariableFetchComplete('q2');
store().onVariableFetchComplete('d1');
store().enqueueDescendants('q1');
expect(states().q2).toBe('revalidating');
expect(store().variableCycleIds.q2).toBe(before.q2 + 1);
expect(store().variableCycleIds.d1).toBe(before.d1);
expect(store().variableCycleIds.d2).toBe(before.d2);
// q2 depends on q1 (settled) → revalidates; d1 waits (q2 no longer settled).
expect(states().q2).toBe(VariableFetchState.Revalidating);
expect(states().d1).toBe(VariableFetchState.Waiting);
});
it('changing a dynamic refreshes the OTHER dynamics, never itself or query vars', () => {
store().enqueueFetchAll(true);
['q1', 'q2', 'd1', 'd2'].forEach((n) => store().onVariableFetchComplete(n));
it('enqueueDescendantsBatch bumps each descendant + dynamic exactly once', () => {
store().enqueueFetchAll(false);
store().onVariableFetchComplete('q1');
store().onVariableFetchComplete('q2');
store().onVariableFetchComplete('d1');
const before = { ...store().variableCycleIds };
store().enqueueDescendants('d1');
expect(store().variableCycleIds.d1).toBe(before.d1);
expect(store().variableCycleIds.d2).toBe(before.d2 + 1);
expect(store().variableCycleIds.q1).toBe(before.q1);
// 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');
});
});
describe('variableFetchSlice — diamond dependencies', () => {
// qA, qB (roots) → qC (references both $qA and $qB).
const qA = model({ name: 'qA', type: 'QUERY', queryValue: 'SELECT 1' });
const qB = model({ name: 'qB', type: 'QUERY', queryValue: 'SELECT 2' });
const qC = model({ name: 'qC', type: 'QUERY', queryValue: 'SELECT $qA $qB' });
const context = deriveFetchContext([qA, qB, qC]);
beforeEach(() => reset(['qA', 'qB', 'qC'], context));
it('unblocks the child only once BOTH parents are settled', () => {
store().enqueueFetchAll(false);
expect(states().qC).toBe('waiting');
store().onVariableFetchComplete('qA');
expect(states().qC).toBe('waiting'); // qB still loading
store().onVariableFetchComplete('qB');
expect(states().qC).not.toBe('waiting'); // both settled → fetches
});
});
describe('variableFetchSlice — dependency cycle', () => {
// qX ↔ qY reference each other → dropped from the topological order.
const qX = model({ name: 'qX', type: 'QUERY', queryValue: 'SELECT $qY' });
const qY = model({ name: 'qY', type: 'QUERY', queryValue: 'SELECT $qX' });
const context = deriveFetchContext([qX, qY]);
beforeEach(() => reset(['qX', 'qY'], context));
it('enqueues cyclic query variables as best-effort roots (not silently idle)', () => {
store().enqueueFetchAll(false);
expect(states().qX).not.toBe('idle');
expect(states().qY).not.toBe('idle');
expect(states().q1).toBe(VariableFetchState.Error);
expect(states().q2).toBe(VariableFetchState.Idle);
});
});

View File

@@ -2,19 +2,24 @@ import type { StateCreator } from 'zustand';
import type { DashboardStore } from '../useDashboardStore';
/**
* Edit context shared across the V2 dashboard tree — the dashboard id, whether
* the user can edit, and the react-query refetch. Set once by DashboardContainer
* so hooks/components read it from the store instead of receiving it as props
* through every layer. Not persisted.
*/
export const DASHBOARD_LOCKED_REASON = 'This dashboard is locked';
export const DASHBOARD_NO_EDIT_PERMISSION_REASON =
'You dont have permission to edit this dashboard';
// Edit context shared across the V2 dashboard tree, set once by DashboardContainer.
export interface EditContextSlice {
dashboardId: string;
// canEditDashboard && !isLocked.
isEditable: boolean;
isLocked: boolean;
canEditDashboard: boolean;
// Locked / no-permission reason for tooltips; '' when editable.
editDisabledReason: string;
refetch: () => void;
setEditContext: (ctx: {
dashboardId: string;
isEditable: boolean;
isLocked: boolean;
canEditDashboard: boolean;
refetch: () => void;
}) => void;
}
@@ -27,20 +32,35 @@ export const createEditContextSlice: StateCreator<
> = (set, get) => ({
dashboardId: '',
isEditable: false,
isLocked: false,
canEditDashboard: false,
editDisabledReason: '',
refetch: (): void => undefined,
// Idempotent (no-op when unchanged) so it's safe to call during render.
setEditContext: (ctx): void => {
const { dashboardId, isEditable, refetch } = get();
const isEditable = ctx.canEditDashboard && !ctx.isLocked;
let editDisabledReason = '';
if (ctx.isLocked) {
editDisabledReason = DASHBOARD_LOCKED_REASON;
} else if (!ctx.canEditDashboard) {
editDisabledReason = DASHBOARD_NO_EDIT_PERMISSION_REASON;
}
const prev = get();
if (
dashboardId === ctx.dashboardId &&
isEditable === ctx.isEditable &&
refetch === ctx.refetch
prev.dashboardId === ctx.dashboardId &&
prev.isEditable === isEditable &&
prev.isLocked === ctx.isLocked &&
prev.canEditDashboard === ctx.canEditDashboard &&
prev.refetch === ctx.refetch
) {
return;
}
set({
dashboardId: ctx.dashboardId,
isEditable: ctx.isEditable,
isEditable,
isLocked: ctx.isLocked,
canEditDashboard: ctx.canEditDashboard,
editDisabledReason,
refetch: ctx.refetch,
});
},

View File

@@ -1,39 +0,0 @@
import type { StateCreator } from 'zustand';
import type { DashboardStore } from '../useDashboardStore';
/** Which settings-drawer tab to open. Mirrors the DashboardSettings tab keys. */
export type SettingsTab = 'Overview' | 'Variables' | 'Publish';
export interface SettingsRequest {
tab: SettingsTab;
/** Open the add-variable form immediately (Variables tab only). */
addVariable?: boolean;
}
/**
* A transient request to open the settings drawer at a given tab/action, so a
* control anywhere in the tree (e.g. the "Add variable" button in the variables
* bar) can deep-link into the drawer without prop-threading. Not persisted;
* cleared when the drawer closes.
*/
export interface SettingsRequestSlice {
settingsRequest: SettingsRequest | null;
requestSettings: (request: SettingsRequest) => void;
clearSettingsRequest: () => void;
}
export const createSettingsRequestSlice: StateCreator<
DashboardStore,
[['zustand/persist', unknown]],
[],
SettingsRequestSlice
> = (set) => ({
settingsRequest: null,
requestSettings: (settingsRequest): void => {
set({ settingsRequest });
},
clearSettingsRequest: (): void => {
set({ settingsRequest: null });
},
});

View File

@@ -6,7 +6,6 @@ import {
areAllQueryVariablesSettled,
type FetchMaps,
isSettled,
isVariableInActiveFetchState,
resolveFetchState,
unlockWaitingDynamicVariables,
VariableFetchState,
@@ -115,17 +114,6 @@ export const createVariableFetchSlice: StateCreator<
: resolveFetchState(maps, name);
});
// Query variables dropped from the dependency order (part of a cycle) would
// otherwise never fetch and would stall waiting dynamics — start them as
// best-effort roots so they surface data/an error instead of sitting empty.
const orderedQuery = new Set(queryVariableOrder);
Object.keys(variableTypes).forEach((name) => {
if (variableTypes[name] === 'QUERY' && !orderedQuery.has(name)) {
maps.cycleIds[name] = (maps.cycleIds[name] || 0) + 1;
maps.states[name] = resolveFetchState(maps, name);
}
});
// Dynamic variables: start now if query variables already have values,
// otherwise wait until the query variables settle.
dynamicVariableOrder.forEach((name) => {
@@ -143,11 +131,6 @@ export const createVariableFetchSlice: StateCreator<
},
onVariableFetchComplete: (name): void => {
// Ignore a stale/late settle for a variable no longer fetching, so a
// superseded cycle can't overwrite fresh state (V1 parity).
if (!isVariableInActiveFetchState(get().variableFetchStates[name])) {
return;
}
const { variableFetchContext } = get();
const maps = cloneMaps(get());
maps.states[name] = VariableFetchState.Idle;
@@ -156,17 +139,12 @@ export const createVariableFetchSlice: StateCreator<
if (variableFetchContext) {
const { dependencyData, variableTypes, dynamicVariableOrder } =
variableFetchContext;
// Unblock a waiting query child only once ALL its parents are settled —
// otherwise it would fetch against a not-yet-resolved parent.
// Unblock waiting query-type children.
(dependencyData.graph[name] || []).forEach((child) => {
if (
variableTypes[child] !== 'QUERY' ||
maps.states[child] !== VariableFetchState.Waiting
variableTypes[child] === 'QUERY' &&
maps.states[child] === VariableFetchState.Waiting
) {
return;
}
const parents = dependencyData.parentGraph[child] || [];
if (parents.every((p) => isSettled(maps.states[p]))) {
maps.states[child] = resolveFetchState(maps, child);
}
});
@@ -187,9 +165,6 @@ export const createVariableFetchSlice: StateCreator<
},
onVariableFetchFailure: (name): void => {
if (!isVariableInActiveFetchState(get().variableFetchStates[name])) {
return;
}
const { variableFetchContext } = get();
const maps = cloneMaps(get());
maps.states[name] = VariableFetchState.Error;
@@ -230,14 +205,13 @@ export const createVariableFetchSlice: StateCreator<
const { dependencyData, variableTypes, dynamicVariableOrder } =
variableFetchContext;
const maps = cloneMaps(get());
const changed = new Set(names);
// Union of the changed variables' query descendants (never the changed ones
// themselves), refreshed once each: refetch when all parents are settled.
// 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' && !changed.has(desc)) {
if (variableTypes[desc] === 'QUERY') {
queryDescendants.add(desc);
}
});
@@ -251,22 +225,17 @@ export const createVariableFetchSlice: StateCreator<
: VariableFetchState.Waiting;
});
// A dynamic's options depend only on its sibling DYNAMIC selections, so only a
// dynamic change affects them — refresh the *other* dynamics (never the one
// that changed, which would refetch its own identical options).
if (names.some((name) => variableTypes[name] === 'DYNAMIC')) {
dynamicVariableOrder
.filter((dynName) => !changed.has(dynName))
.forEach((dynName) => {
maps.cycleIds[dynName] = (maps.cycleIds[dynName] || 0) + 1;
maps.states[dynName] = areAllQueryVariablesSettled(
maps.states,
variableTypes,
)
? resolveFetchState(maps, dynName)
: VariableFetchState.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)
: VariableFetchState.Waiting;
});
set({
variableFetchStates: maps.states,

View File

@@ -21,13 +21,6 @@ export function isSettled(state: VariableFetchState | undefined): boolean {
return state === VariableFetchState.Idle || state === VariableFetchState.Error;
}
/** Active = a fetch is in flight; only then should a settle be applied. */
export function isVariableInActiveFetchState(
state: VariableFetchState | undefined,
): boolean {
return state === 'loading' || state === 'revalidating';
}
/** Fetch-start state: `revalidating` if fetched before, else `loading`. */
export function resolveFetchState(
maps: FetchMaps,

View File

@@ -17,16 +17,11 @@ import {
createVariableFetchSlice,
type VariableFetchSlice,
} from './slices/variableFetchSlice';
import {
createSettingsRequestSlice,
type SettingsRequestSlice,
} from './slices/settingsRequestSlice';
export type DashboardStore = EditContextSlice &
CollapseSlice &
VariableSelectionSlice &
VariableFetchSlice &
SettingsRequestSlice;
VariableFetchSlice;
/**
* V2 dashboard session store. Holds cross-cutting client state only — never the
@@ -42,7 +37,6 @@ export const useDashboardStore = create<DashboardStore>()(
...createCollapseSlice(...a),
...createVariableSelectionSlice(...a),
...createVariableFetchSlice(...a),
...createSettingsRequestSlice(...a),
}),
{
name: '@signoz/dashboard-v2',

View File

@@ -12,6 +12,7 @@ import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useDashboardFetch } from '../DashboardContainer/hooks/useDashboardFetch';
import { useDashboardEditGuard } from '../DashboardContainer/hooks/useDashboardEditGuard';
import { getPanelDefinition } from '../DashboardContainer/Panels/registry';
import { buildPluginSpec } from '../DashboardContainer/Panels/utils/buildPluginSpec';
import { buildDefaultQueries } from '../DashboardContainer/Panels/utils/buildDefaultQueries';
@@ -43,6 +44,9 @@ function PanelEditorPage(): JSX.Element {
const { dashboard, isLoading, isError, error } =
useDashboardFetch(dashboardId);
// Derived here (not from the store) because the editor route doesn't mount
// DashboardContainer, so the store's edit context may be cold on a direct URL.
const { isEditable, editDisabledReason } = useDashboardEditGuard(dashboard);
// Feed variables to the query builder autocomplete inside the editor.
useSyncVariablesForSuggestions(dashboard);
@@ -114,6 +118,8 @@ function PanelEditorPage(): JSX.Element {
panel={panel}
isNew={!!newKind}
layoutIndex={layoutIndex}
isEditable={isEditable}
editDisabledReason={editDisabledReason}
onClose={backToDashboard}
onSaved={backToDashboard}
/>

View File

@@ -39,6 +39,8 @@ interface Props {
dashboardName: string;
createdBy: string;
isLocked: boolean;
// Edit permission (edit_dashboard). Read actions show regardless; edit actions are hidden without it.
canEdit: boolean;
onView: (event: React.MouseEvent<HTMLElement>) => void;
}
@@ -48,6 +50,7 @@ function ActionsPopover({
dashboardName,
createdBy,
isLocked,
canEdit,
onView,
}: Props): JSX.Element {
const [, setCopy] = useCopyToClipboard();
@@ -135,45 +138,49 @@ function ActionsPopover({
>
Copy Link
</Button>
<Tooltip
placement="left"
title={
isLocked ? 'This dashboard is locked, so it cannot be renamed.' : ''
}
>
<span className={styles.menuItemWrap}>
<Button
color="secondary"
className={styles.menuItem}
prefix={<PenLine size={14} />}
disabled={isLocked}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
if (!isLocked) {
setIsRenameOpen(true);
}
}}
testId="dashboard-action-rename"
>
Rename
</Button>
</span>
</Tooltip>
<Button
color="secondary"
className={styles.menuItem}
prefix={<Copy size={14} />}
loading={isCloning}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
runClone();
}}
testId="dashboard-action-duplicate"
>
Duplicate
</Button>
{canEdit && (
<Tooltip
placement="left"
title={
isLocked ? 'This dashboard is locked, so it cannot be renamed.' : ''
}
>
<span className={styles.menuItemWrap}>
<Button
color="secondary"
className={styles.menuItem}
prefix={<PenLine size={14} />}
disabled={isLocked}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
if (!isLocked) {
setIsRenameOpen(true);
}
}}
testId="dashboard-action-rename"
>
Rename
</Button>
</span>
</Tooltip>
)}
{canEdit && (
<Button
color="secondary"
className={styles.menuItem}
prefix={<Copy size={14} />}
loading={isCloning}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
runClone();
}}
testId="dashboard-action-duplicate"
>
Duplicate
</Button>
)}
{canToggleLock && (
<Button
color="secondary"
@@ -190,12 +197,14 @@ function ActionsPopover({
{isLocked ? 'Unlock Dashboard' : 'Lock Dashboard'}
</Button>
)}
<DeleteActionItem
dashboardId={dashboardId}
dashboardName={dashboardName}
createdBy={createdBy}
isLocked={isLocked}
/>
{canEdit && (
<DeleteActionItem
dashboardId={dashboardId}
dashboardName={dashboardName}
createdBy={createdBy}
isLocked={isLocked}
/>
)}
</div>
}
placement="bottomRight"

View File

@@ -24,7 +24,7 @@ import styles from './DashboardRow.module.scss';
interface Props {
dashboard: DashboardListItem;
index: number;
canAct: boolean;
canEdit: boolean;
showUpdatedAt: boolean;
showUpdatedBy: boolean;
}
@@ -32,7 +32,7 @@ interface Props {
function DashboardRow({
dashboard,
index,
canAct,
canEdit,
showUpdatedAt,
showUpdatedBy,
}: Props): JSX.Element {
@@ -153,16 +153,15 @@ function DashboardRow({
</Button>
</TooltipSimple>
{canAct && (
<ActionsPopover
link={link}
dashboardId={id}
dashboardName={name}
createdBy={createdBy}
isLocked={isLocked}
onView={onClickHandler}
/>
)}
<ActionsPopover
link={link}
dashboardId={id}
dashboardName={name}
createdBy={createdBy}
isLocked={isLocked}
canEdit={canEdit}
onView={onClickHandler}
/>
</div>
<div className={styles.details}>
<div className={styles.createdAt}>

View File

@@ -44,10 +44,11 @@ function DashboardsList(): JSX.Element {
const { isCloudUser } = useGetTenantLicense();
const { user } = useAppContext();
const [action, canCreateNewDashboard] = useComponentPermission(
['action', 'create_new_dashboards'],
const [editDashboard, canCreateNewDashboard] = useComponentPermission(
['edit_dashboard', 'create_new_dashboards'],
user.role,
);
const canEdit = !!editDashboard;
const {
filters,
@@ -285,6 +286,7 @@ function DashboardsList(): JSX.Element {
onReset={handleResetView}
onDelete={handleRemoveView}
onRename={renameView}
canEdit={canEdit}
/>
<div className={styles.main}>
<div className={styles.mainScroll}>
@@ -340,7 +342,7 @@ function DashboardsList(): JSX.Element {
pageSize={clientView ? CLIENT_VIEW_LIMIT : PAGE_SIZE}
total={total}
onPageChange={setPage}
canAct={!!action}
canEdit={canEdit}
showUpdatedAt={visibleColumns.updatedAt}
showUpdatedBy={visibleColumns.updatedBy}
loading={isFetching}

View File

@@ -11,7 +11,7 @@ interface Props {
pageSize: number;
total: number;
onPageChange: (page: number) => void;
canAct: boolean;
canEdit: boolean;
showUpdatedAt: boolean;
showUpdatedBy: boolean;
loading: boolean;
@@ -23,7 +23,7 @@ function DashboardsListContent({
pageSize,
total,
onPageChange,
canAct,
canEdit,
showUpdatedAt,
showUpdatedBy,
loading,
@@ -37,14 +37,14 @@ function DashboardsListContent({
<DashboardRow
dashboard={dashboard}
index={index}
canAct={canAct}
canEdit={canEdit}
showUpdatedAt={showUpdatedAt}
showUpdatedBy={showUpdatedBy}
/>
),
},
],
[canAct, showUpdatedAt, showUpdatedBy],
[canEdit, showUpdatedAt, showUpdatedBy],
);
const paginationConfig = total > pageSize && {

View File

@@ -30,7 +30,7 @@ interface Props {
pageSize: number;
total: number;
onPageChange: (page: number) => void;
canAct: boolean;
canEdit: boolean;
showUpdatedAt: boolean;
showUpdatedBy: boolean;
loading: boolean;
@@ -55,7 +55,7 @@ function DashboardsResults({
pageSize,
total,
onPageChange,
canAct,
canEdit,
showUpdatedAt,
showUpdatedBy,
loading,
@@ -91,7 +91,7 @@ function DashboardsResults({
pageSize={pageSize}
total={total}
onPageChange={onPageChange}
canAct={canAct}
canEdit={canEdit}
showUpdatedAt={showUpdatedAt}
showUpdatedBy={showUpdatedBy}
loading={loading}

View File

@@ -27,6 +27,8 @@ interface Props {
isCustomActive: boolean;
isModified: boolean;
collapsed?: boolean;
// Edit permission. Viewers can select views but not add / rename / delete / save.
canEdit: boolean;
onSelect: (id: string) => void;
onSave: (name: string) => void;
onSaveChanges: () => void;
@@ -52,6 +54,7 @@ function ViewsRail({
isCustomActive,
isModified,
collapsed = false,
canEdit,
onSelect,
onSave,
onSaveChanges,
@@ -129,7 +132,7 @@ function ViewsRail({
<div className={styles.dirtyDot} title="Unsaved changes" />
)}
</Button>
{row.deletable && (
{canEdit && row.deletable && (
<div className={styles.itemActions}>
<ViewNamePopover
open={renamingId === row.id}
@@ -177,25 +180,27 @@ function ViewsRail({
<aside className={cx(styles.rail, { [styles.collapsed]: collapsed })}>
<div className={styles.header}>
<h4 className={styles.headerTitle}>Views</h4>
<ViewNamePopover
open={saveOpen}
onOpenChange={setSaveOpen}
onSubmit={onSave}
title="Save as view"
confirmLabel="Save view"
testIdPrefix="save-view"
trigger={
<Button
variant="ghost"
color="secondary"
size="icon"
title="Save current filters as a view"
testId="dashboards-view-save-trigger"
>
<Plus size={14} />
</Button>
}
/>
{canEdit && (
<ViewNamePopover
open={saveOpen}
onOpenChange={setSaveOpen}
onSubmit={onSave}
title="Save as view"
confirmLabel="Save view"
testIdPrefix="save-view"
trigger={
<Button
variant="ghost"
color="secondary"
size="icon"
title="Save current filters as a view"
testId="dashboards-view-save-trigger"
>
<Plus size={14} />
</Button>
}
/>
)}
</div>
<div className={styles.search}>
@@ -265,23 +270,27 @@ function ViewsRail({
<div className={styles.dirtyPanel}>
<div className={styles.dirtyTitle}>Unsaved changes</div>
<div className={styles.dirtyActions}>
<Button
variant="solid"
color="primary"
size="sm"
onClick={onSaveChanges}
testId="dashboards-view-save-changes"
>
Save
</Button>
<Button
variant="outlined"
color="secondary"
size="sm"
onClick={(): void => setSaveOpen(true)}
>
Save as
</Button>
{canEdit && (
<>
<Button
variant="solid"
color="primary"
size="sm"
onClick={onSaveChanges}
testId="dashboards-view-save-changes"
>
Save
</Button>
<Button
variant="outlined"
color="secondary"
size="sm"
onClick={(): void => setSaveOpen(true)}
>
Save as
</Button>
</>
)}
<Button variant="ghost" color="secondary" size="sm" onClick={onReset}>
Reset
</Button>
@@ -293,16 +302,18 @@ function ViewsRail({
<div className={cx(styles.dirtyPanel, styles.dirtyPanelDefault)}>
<div className={styles.dirtyTitle}>Filters active</div>
<div className={styles.dirtyActions}>
<Button
variant="solid"
color="primary"
size="sm"
prefix={<Plus size={12} />}
onClick={(): void => setSaveOpen(true)}
testId="dashboards-view-save-as-new"
>
Save as new view
</Button>
{canEdit && (
<Button
variant="solid"
color="primary"
size="sm"
prefix={<Plus size={12} />}
onClick={(): void => setSaveOpen(true)}
testId="dashboards-view-save-as-new"
>
Save as new view
</Button>
)}
<Button variant="ghost" color="secondary" size="sm" onClick={onReset}>
Reset
</Button>

View File

@@ -12,24 +12,13 @@
padding-bottom: 8px;
}
&__mode-select {
min-width: 90px;
}
// Dropdown content is rendered in a portal; bump above FloatingPanel
// (z-index 999) so it stays visible when the consumer panel is floating.
&__mode-dropdown {
--dropdown-menu-content-z-index: 1000;
}
// Shared content container — no scroll, each view handles its own
&__content {
flex: 1;
display: flex;
flex-direction: column;
border: 1px solid var(--l2-border);
border-radius: 4px;
padding: 8px;
border-top: 0px;
min-height: 0;
}
}

View File

@@ -1,7 +1,5 @@
import { useMemo, useState } from 'react';
import { ChevronDown } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple as Dropdown } from '@signozhq/ui/dropdown-menu';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import logEvent from 'api/common/logEvent';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import { JsonView } from 'periscope/components/JsonView';
@@ -9,13 +7,16 @@ import { PrettyView, PrettyViewProps } from 'periscope/components/PrettyView';
import './DataViewer.styles.scss';
type ViewMode = 'pretty' | 'json';
enum ViewMode {
Pretty = 'pretty',
Json = 'json',
}
const VIEW_MODE_CHANGED_EVENT = 'Data Viewer: View mode changed';
const VIEW_MODE_OPTIONS: { label: string; value: ViewMode }[] = [
{ label: 'Pretty', value: 'pretty' },
{ label: 'JSON', value: 'json' },
{ label: 'Pretty', value: ViewMode.Pretty },
{ label: 'JSON', value: ViewMode.Json },
];
export interface DataViewerProps {
@@ -30,12 +31,17 @@ function DataViewer({
drawerKey = 'default',
prettyViewProps,
}: DataViewerProps): JSX.Element {
const [viewMode, setViewMode] = useState<ViewMode>('pretty');
const [viewMode, setViewMode] = useState<ViewMode>(ViewMode.Pretty);
const jsonString = useMemo(() => JSON.stringify(data, null, 2), [data]);
const handleViewModeChange = (value: string): void => {
const next = value as ViewMode;
// A single-select toggle can emit '' when the active item is toggled off;
// ignore it so one mode is always selected.
if (next !== ViewMode.Pretty && next !== ViewMode.Json) {
return;
}
setViewMode(next);
try {
logEvent(VIEW_MODE_CHANGED_EVENT, {
@@ -48,49 +54,25 @@ function DataViewer({
}
};
const currentLabel =
VIEW_MODE_OPTIONS.find((opt) => opt.value === viewMode)?.label ?? 'Pretty';
return (
<div className="data-viewer">
<div className="data-viewer__toolbar">
<Dropdown
align="start"
className="data-viewer__mode-dropdown"
menu={{
items: [
{
type: 'radio-group',
value: viewMode,
onChange: handleViewModeChange,
children: VIEW_MODE_OPTIONS.map((opt) => ({
type: 'radio',
key: opt.value,
value: opt.value,
label: opt.label,
})),
},
],
}}
>
<Button
variant="outlined"
size="sm"
color="secondary"
className="data-viewer__mode-select"
suffix={<ChevronDown size={12} />}
>
{currentLabel}
</Button>
</Dropdown>
<ToggleGroupSimple
type="single"
size="sm"
value={viewMode}
onChange={handleViewModeChange}
items={VIEW_MODE_OPTIONS}
testId="data-viewer-view-mode"
/>
<CopyButton value={jsonString} ariaLabel="Copy JSON" />
</div>
<div className="data-viewer__content">
{viewMode === 'pretty' && (
{viewMode === ViewMode.Pretty && (
<PrettyView data={data} drawerKey={drawerKey} {...prettyViewProps} />
)}
{viewMode === 'json' && <JsonView data={jsonString} />}
{viewMode === ViewMode.Json && <JsonView data={jsonString} />}
</div>
</div>
);

View File

@@ -23,6 +23,9 @@ const editorOptions: EditorProps['options'] = {
lineHeight: 18,
colorDecorators: true,
scrollBeyondLastLine: false,
// Disabled: the transparent editor background leaves the sticky-scroll widget
// without an opaque backing, so scrolling lines bleed through and overlap it.
stickyScroll: { enabled: false },
scrollbar: {
vertical: 'hidden',
horizontal: 'hidden',

View File

@@ -12,8 +12,10 @@
&__search-input {
width: 100%;
border: 1px solid var(--l2-border) !important;
border-radius: 4px;
border: 0 !important;
border-top: 1px solid var(--l2-border) !important;
border-bottom: 1px solid var(--l2-border) !important;
border-radius: 0 !important;
font-family: 'SF Mono', 'Geist Mono', 'Fira Code', monospace !important;
font-size: 12px !important;
line-height: 18px !important;
@@ -22,6 +24,7 @@
box-shadow: none !important;
&:focus {
border: 1px solid var(--l2-border) !important;
border-color: var(--primary) !important;
outline: none !important;
box-shadow: none !important;
@@ -47,6 +50,9 @@
> ul,
&__pinned > ul {
padding-left: 0 !important;
// Clip the hover bleed to the panel width. `clip` (not `hidden`) does
// not create a scroll container, so the sticky search stays working.
overflow-x: clip;
}
// Force font on all tree elements
@@ -71,45 +77,87 @@
margin-right: 16px;
}
// Leaf node row — hover highlights only this row
// Leaf node row — full-width hover highlight
&__row {
border-radius: 2px;
display: flex !important;
align-items: baseline;
position: relative;
isolation: isolate; // own stacking context so ::before sits behind content, not the panel bg
&:hover {
background-color: var(--l3-background);
// Keep actions visible on hover, or while this row's menu is open
&:hover .pretty-view__actions,
&:has(> span [data-state='open']) .pretty-view__actions {
opacity: 1;
}
.pretty-view__actions {
opacity: 1;
}
// Edge-to-edge highlight, bled past indentation, clipped by `> ul`.
// Persists while this row's ... menu is open (the trigger stays in the
// row and carries data-state=open, even though the menu is portaled out).
&:hover::before,
&:has(> span [data-state='open'])::before {
content: '';
position: absolute;
inset: 0 -9999px;
background: var(--l3-background);
z-index: -1;
}
// Push actions to the right edge
> span {
flex: 1;
}
// Brighten the value text from its default grey to foreground-hover
// while the row is active (hover, or its ... menu open). !important
// overrides react-json-tree's per-type inline color on the value span.
&:hover > span,
&:has(> span [data-state='open']) > span {
color: var(--l1-foreground-hover) !important;
}
}
// Nested node (object/array) — hover only on the label line, not children
// Nested node (object/array) — full-width hover on the header line only
&__nested-row {
position: relative;
isolation: isolate;
> label,
> span:not(ul span) {
border-radius: 2px;
padding: 1px 2px;
display: inline !important; // keep item string inline with label
}
// Highlight label + item string on hover, show actions
&:hover > label,
&:hover > label + span {
background-color: var(--l3-background);
// Edge-to-edge highlight, capped to the header line so it doesn't
// cover the children this <li> contains. Persists while this row's own
// menu is open — `> span` scopes it so a child row's open menu (nested
// in `> ul`) doesn't light up this parent.
&:hover::before,
&:has(> span [data-state='open'])::before {
content: '';
position: absolute;
top: 0;
height: 20px; // 18px line-height + 2px top padding
left: -9999px;
right: -9999px;
background: var(--l3-background);
z-index: -1;
}
&:hover > label + span .pretty-view__actions {
&:hover > label + span .pretty-view__actions,
&:has(> span [data-state='open']) > label + span .pretty-view__actions {
opacity: 1;
}
// Push the ... to the right edge of the row instead of hugging the key.
// Absolute (not flex) so the arrow/label/children layout stays intact;
// the row <li> is position: relative (react-json-tree sets it inline).
.pretty-view__actions {
position: absolute;
top: 2px; // align with the header line (li padding-top)
right: 0;
height: 18px; // line-height — centers the icon (span is align-items: center)
}
// In nested rows, value-row should not take full width
.pretty-view__value-row {
width: auto;
@@ -172,6 +220,7 @@
display: inline-flex;
align-items: baseline;
gap: 6px;
padding-left: 6px !important;
}
&__pinned-icon {

88
tests/uv.lock generated
View File

@@ -263,55 +263,55 @@ wheels = [
[[package]]
name = "cryptography"
version = "46.0.7"
version = "48.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" }
sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" },
{ url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" },
{ url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" },
{ url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" },
{ url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" },
{ url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" },
{ url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" },
{ url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" },
{ url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" },
{ url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" },
{ url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" },
{ url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" },
{ url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" },
{ url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" },
{ url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" },
{ url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" },
{ url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" },
{ url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" },
{ url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" },
{ url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" },
{ url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" },
{ url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" },
{ url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" },
{ url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" },
{ url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" },
{ url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" },
{ url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" },
{ url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" },
{ url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" },
{ url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" },
{ url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" },
{ url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" },
{ url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" },
{ url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" },
{ url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" },
{ url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" },
{ url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" },
{ url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" },
{ url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" },
{ url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" },
{ url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" },
{ url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" },
{ url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" },
{ url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" },
{ url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" },
{ url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" },
{ url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" },
{ url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" },
{ url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" },
{ url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" },
{ url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" },
{ url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" },
{ url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" },
{ url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" },
{ url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" },
{ url = "https://files.pythonhosted.org/packages/42/06/3e768b4c3bc78201583fa35a0e18f640dd782ff41afba88f8545481a8874/cryptography-48.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345", size = 7989830, upload-time = "2026-06-09T22:31:07.8Z" },
{ url = "https://files.pythonhosted.org/packages/8a/13/6476736484b94041110c8340a3eb63962fea4975baea8cb4a512adb44d4d/cryptography-48.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4", size = 4689201, upload-time = "2026-06-09T22:31:09.745Z" },
{ url = "https://files.pythonhosted.org/packages/79/62/65a87f34d2a431546e2509b85d55e8c90df86d668f6731da64d538512ac2/cryptography-48.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991", size = 4702822, upload-time = "2026-06-09T22:32:24.409Z" },
{ url = "https://files.pythonhosted.org/packages/7f/59/810b5204b0a9b10f4b6bc06bd551a8b609803cd931806bc3b71884b225e5/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265", size = 4694875, upload-time = "2026-06-09T22:32:08.737Z" },
{ url = "https://files.pythonhosted.org/packages/24/dc/d8ca05ffea724eec6d232ea6f18e74c269eb6bdfdcc9bfba689790d1325f/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17", size = 5290385, upload-time = "2026-06-09T22:31:15.212Z" },
{ url = "https://files.pythonhosted.org/packages/03/8c/3be6cb4da181f5bb6c19cf560c2359d60644a6b5fc5b57854e528f47b296/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411", size = 4737082, upload-time = "2026-06-09T22:32:22.66Z" },
{ url = "https://files.pythonhosted.org/packages/aa/f6/d5f60a5a1434dbfd949e227fd0065d194c7e6b6ac526b17f5c06152b8231/cryptography-48.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02", size = 4325328, upload-time = "2026-06-09T22:32:10.777Z" },
{ url = "https://files.pythonhosted.org/packages/17/b7/ba75dd947a14b6ad907b01ae8f6b5b348cdd1b48142f0063dee9e20c1d9d/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa", size = 4694530, upload-time = "2026-06-09T22:31:53.105Z" },
{ url = "https://files.pythonhosted.org/packages/62/29/50d6b9e8aff12d8b67afaeb3569335e32dc83a5723e3bbded24fdac9f809/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3", size = 5245046, upload-time = "2026-06-09T22:31:25.774Z" },
{ url = "https://files.pythonhosted.org/packages/9f/04/618f4115cfc0add0838c82507aa18a346089428da8653ad38b3ff36f5cb3/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c", size = 4736660, upload-time = "2026-06-09T22:32:12.676Z" },
{ url = "https://files.pythonhosted.org/packages/24/9c/06e062462a0de28a3b3911322eded4c16deb9f441b1b7575d3dc59488ab5/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72", size = 4822229, upload-time = "2026-06-09T22:31:17.062Z" },
{ url = "https://files.pythonhosted.org/packages/f4/be/0561971eaaee4b8a0e7d5113c536921063ab91aaf23278ac374eaf881e11/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9", size = 4966364, upload-time = "2026-06-09T22:31:32.842Z" },
{ url = "https://files.pythonhosted.org/packages/a4/27/728c77876f12b000820b69ae490f3c4083775e79e07827e9e60be07ad209/cryptography-48.0.1-cp314-cp314t-win32.whl", hash = "sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471", size = 3278498, upload-time = "2026-06-09T22:31:29.154Z" },
{ url = "https://files.pythonhosted.org/packages/06/e3/79a612c6d7b1e6ee0edd43633d53035bec2cfb78c82b76f7864f39e36f34/cryptography-48.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2", size = 3798790, upload-time = "2026-06-09T22:31:56.697Z" },
{ url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" },
{ url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" },
{ url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" },
{ url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" },
{ url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" },
{ url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" },
{ url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" },
{ url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" },
{ url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" },
{ url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" },
{ url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" },
{ url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" },
{ url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" },
{ url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" },
]
[[package]]