Compare commits

...

15 Commits

Author SHA1 Message Date
Ashwin Bhatkal
f6455753db refactor(dashboard-v2): extract add-variable buttons into components 2026-07-08 11:58:21 +05:30
Ashwin Bhatkal
8c788a156a feat(dashboard-v2): hide dashboard chrome in full screen
Show only the sections and panels in full screen — the header and toolbar
(actions, variables bar, time selector) are hidden for a clean presentation
view; exit with Esc. Also drops a stray blank line that failed the format check.
2026-07-08 11:58:21 +05:30
Ashwin Bhatkal
002c83c05b fix(dashboard-v2): flow the variables bar under the time selector + reposition add button
Revert the bar wrapper to block so the strip wraps around the floated time
selector (one line beside it collapsed; full-width below it when expanded) — a
flex wrapper had made it a BFC that trapped the rows on the left. The add-variable
"+" now sits after the +N/Less trigger, kept inline and always mounted so the
overflow measurement no longer toggles it (which caused a layout shift), with the
collapse math reserving space for it.
2026-07-08 11:58:21 +05:30
Ashwin Bhatkal
6c8b32afe9 feat(dashboard-v2): add a Configure step to the dashboard empty state 2026-07-08 11:58:21 +05:30
Ashwin Bhatkal
067b17da6a feat(dashboard-v2): raise dynamic-variable attribute search debounce to 500ms 2026-07-08 11:58:21 +05:30
Ashwin Bhatkal
c037de71f7 fix(dashboard-v2): keep add-variable button out of collapsed bar + flat toolbar borders
- Move the add-variable + icon inside the strip and only show it when the bar
  isn't collapsed (no overflow) or is expanded, so it never breaks the layout
  mid-wrap; expand to reveal it.
- Make the Actions/Configure/Edit-as-JSON border a flat l3 outline with no
  box-shadow/depth.
2026-07-08 11:58:21 +05:30
Ashwin Bhatkal
254c1a229a style(dashboard-v2): use the l3 border on toolbar buttons and the empty state
Give Actions/Configure/Edit-as-JSON and the dashboard empty-state dashed border
the more visible --l3-border.
2026-07-08 11:58:21 +05:30
Ashwin Bhatkal
50333baa80 feat(dashboard-v2): collapse the add-variable button once variables exist
Full labelled button when there are no variables; once some exist it shrinks to
a + icon (label on hover) placed after the collapsed +N. Dashed l3 border.
2026-07-08 11:58:21 +05:30
Ashwin Bhatkal
980c552299 feat(dashboard-v2): add-variable button in the variables bar
Add a dashed outlined 'Add variable' button on the left of the variables bar
(shown for editors even with no variables). It deep-links into the settings
drawer at the Variables tab with the add-variable form open, via a transient
settingsRequest store slice; the drawer destroys on close so it re-initializes
cleanly each open.
2026-07-08 11:58:21 +05:30
Ashwin Bhatkal
95fc660f6f feat(dashboard-v2): restore the V1 combined field/source row for dynamic variables
Replace the separate Source and Attribute rows with the single V1-style row:
the telemetry field on the left, then "from" and the source select on the right.
2026-07-08 11:58:21 +05:30
Ashwin Bhatkal
40fc864dfb fix(dashboard-v2): cap the variable preview-values box height
The 'Preview of Values' box only had a min-height, so a field with many values
grew the box tall. Add a max-height so it scrolls instead.
2026-07-08 11:58:21 +05:30
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
52 changed files with 1183 additions and 467 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

@@ -5,3 +5,9 @@
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,10 @@
import { useCallback, useMemo, useState } from 'react';
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { FullScreenHandle } from 'react-full-screen';
import { generatePath } from 'react-router-dom';
import {
@@ -29,12 +35,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,7 +67,10 @@ function DashboardActions({
onLockToggle,
onOpenRename,
}: DashboardActionsProps): JSX.Element {
const canEdit = useDashboardStore((s) => s.isEditable);
const canEditDashboard = useDashboardStore((s) => s.canEditDashboard);
const isLocked = useDashboardStore((s) => s.isLocked);
const settingsRequest = useDashboardStore((s) => s.settingsRequest);
const clearSettingsRequest = useDashboardStore((s) => s.clearSettingsRequest);
const { user } = useAppContext();
const { safeNavigate } = useSafeNavigate();
const { showErrorModal } = useErrorModal();
@@ -72,6 +84,14 @@ 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,
});
@@ -111,23 +131,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',
@@ -144,16 +179,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',
@@ -162,27 +187,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,
@@ -201,28 +238,40 @@ 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"
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"
className={styles.toolbarButton}
prefix={<Configure size="md" />}
testId="show-drawer"
disabled={isLocked}
onClick={(): void => setIsSettingsDrawerOpen(true)}
size="md"
>
Configure
</Button>
</DisabledControlTooltip>
<SettingsDrawer
drawerTitle="Dashboard Configuration"
isOpen={isSettingsDrawerOpen}
onClose={(): void => setIsSettingsDrawerOpen(false)}
destroyOnClose
onClose={(): void => {
setIsSettingsDrawerOpen(false);
clearSettingsRequest();
}}
>
<DashboardSettings dashboard={dashboard} />
</SettingsDrawer>
@@ -231,6 +280,7 @@ function DashboardActions({
<Button
variant="solid"
color="secondary"
className={styles.toolbarButton}
prefix={<Braces size="md" />}
testId="edit-json"
onClick={(): void => setIsJsonEditorOpen(true)}
@@ -238,17 +288,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,6 +8,8 @@ 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({
@@ -15,6 +17,7 @@ function SettingsDrawer({
drawerTitle,
isOpen,
onClose,
destroyOnClose = false,
}: SettingsDrawerProps): JSX.Element {
return (
<Drawer
@@ -23,6 +26,7 @@ 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, 300);
const debouncedSearch = useDebounce(search, 500);
const apiSignal = signalForApi(signal);
const {
@@ -82,38 +82,14 @@ function DynamicVariableFields({
return (
<>
<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.sortSelect}
popupMatchSelectWidth={false}
value={signal}
options={DYNAMIC_SIGNALS.map((s) => ({
label: DYNAMIC_SIGNAL_LABEL[s],
value: s,
}))}
onChange={(value): void =>
onChange({ dynamicSignal: value as DynamicSignalOption })
}
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>
{/* 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.searchSelect}
className={styles.dynamicFieldSelect}
showSearch
value={attribute || undefined}
placeholder="Select a telemetry field"
placeholder="Select a field"
loading={isLoading}
options={options}
onSearch={setSearch}
@@ -126,6 +102,25 @@ function DynamicVariableFields({
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} />}
/>
<Select
className={styles.dynamicSourceSelect}
popupMatchSelectWidth={false}
value={signal}
options={DYNAMIC_SIGNALS.map((s) => ({
label: DYNAMIC_SIGNAL_LABEL[s],
value: s,
}))}
onChange={(value): void =>
onChange({ dynamicSignal: value as DynamicSignalOption })
}
data-testid="variable-signal-select"
/>
</div>
{attributeError ? (
<Typography.Text className={styles.errorText}>

View File

@@ -138,7 +138,7 @@
align-items: center;
gap: 6px;
min-height: 24px;
padding: 6px 14px;
padding: 4px 16px !important;
white-space: nowrap;
border-radius: 0;
color: var(--l2-foreground);
@@ -157,6 +157,7 @@
.betaTag {
margin-left: 4px;
vertical-align: middle;
}
/* Query */
@@ -264,6 +265,7 @@
flex-flow: wrap;
gap: 8px;
padding: 4.5px 11px;
max-height: 120px;
overflow-y: auto;
}
@@ -329,15 +331,41 @@
/* All variable selects (Source / Attribute / Sort / Default Value) share width
and a consistent --l2-border outline. */
.sortSelect,
.searchSelect {
width: 240px;
flex-shrink: 0;
.searchSelect,
.dynamicFieldSelect,
.dynamicSourceSelect {
:global(.ant-select-selector) {
border-color: var(--l2-border) !important;
}
}
.sortSelect,
.searchSelect {
width: 240px;
flex-shrink: 0;
}
/* 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

@@ -23,6 +23,11 @@ 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 initialFormModels = useMemo(
@@ -38,7 +43,9 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboard.updatedAt]);
const [isEditing, setIsEditing] = useState<EditingState>(null);
const [isEditing, setIsEditing] = useState<EditingState>(
openAddOnMount && isEditable ? { type: 'new' } : null,
);
const [confirmDeleteIndex, setConfirmDeleteIndex] = useState<number | null>(
null,
);

View File

@@ -17,6 +17,7 @@ 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 {
@@ -38,6 +39,9 @@ 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;
@@ -69,7 +73,7 @@ function DashboardSettings({ dashboard }: DashboardSettingsProps): JSX.Element {
);
return (
<TabsRoot defaultValue={TabKeys.OVERVIEW}>
<TabsRoot defaultValue={settingsRequest?.tab ?? 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,7 +25,6 @@
.welcome {
color: var(--l1-foreground);
font-family: Inter;
font-size: 16px;
font-weight: 500;
line-height: 24px;
@@ -34,52 +33,56 @@
.welcomeInfo {
color: var(--l3-foreground);
font-family: Inter;
font-size: 13px;
font-weight: 400;
line-height: 18px;
}
}
.addPanel {
.steps {
display: flex;
flex-direction: column;
gap: 12px;
}
.step {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px;
border: 1px dashed var(--l1-border);
border: 1px dashed var(--l3-border);
border-radius: 6px;
}
.addPanelText {
.stepText {
display: flex;
align-items: flex-start;
gap: 10px;
.icon {
.stepIcon {
height: 14px;
width: 14px;
margin-top: 2px;
flex: none;
}
}
.addPanelCopy {
.stepCopy {
display: flex;
flex-direction: column;
gap: 2px;
}
.addPanelTitle {
.stepTitle {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.addPanelInfo {
.stepInfo {
color: var(--l3-foreground);
font-family: Inter;
font-size: 13px;
font-weight: 400;
line-height: 18px;

View File

@@ -1,4 +1,4 @@
import { Plus } from '@signozhq/icons';
import { Configure, Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
@@ -6,6 +6,7 @@ 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';
@@ -18,6 +19,8 @@ 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}>
@@ -32,28 +35,55 @@ function DashboardEmptyState({
</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 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>
{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

@@ -0,0 +1,31 @@
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

@@ -0,0 +1,35 @@
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,7 +1,17 @@
// 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;
}
@@ -15,7 +25,8 @@
clear: both;
.variableSlot,
.moreButton {
.moreButton,
.addSlot {
margin: 0;
}
@@ -48,6 +59,12 @@
}
.moreButton {
display: inline-flex;
margin-right: 8px;
vertical-align: top;
}
.addSlot {
display: inline-flex;
vertical-align: top;
}

View File

@@ -6,6 +6,9 @@ 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';
@@ -44,15 +47,20 @@ 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,
reserveWidth: 48,
// Reserve the trailing line space: the "+N" trigger, plus the always-present
// add-variable "+" when editable, so the collapse math accounts for both.
reserveWidth: isEditable ? 88 : 48,
enabled: !expanded,
});
if (variables.length === 0) {
// Editors can add a variable even before any exist; viewers with no variables
// have nothing to show.
if (variables.length === 0 && !isEditable) {
return null;
}
@@ -74,6 +82,14 @@ 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
@@ -128,6 +144,15 @@ 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

@@ -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,
});
@@ -56,16 +53,19 @@ function DashboardContainer({
// suggests them ($variable) in the panel editor and dashboards-page builder.
useSyncVariablesForSuggestions(dashboard);
// In full screen show only the sections and panels — the header/toolbar chrome
// is hidden for a clean presentation view (exit with Esc).
return (
<FullScreen handle={fullScreenHandle}>
<div className={styles.container}>
<DashboardPageHeader title={name} image={image} />
<DashboardPageToolbar
dashboard={dashboard}
handle={fullScreenHandle}
refetch={refetch}
/>
{!fullScreenHandle.active && (
<>
<DashboardPageHeader title={name} image={image} />
<DashboardPageToolbar dashboard={dashboard} handle={fullScreenHandle} />
</>
)}
<PanelsAndSectionsLayout layouts={spec.layouts} panels={spec.panels} />
{isLocked && <LockedIndicator />}
</div>
</FullScreen>
);

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

@@ -0,0 +1,39 @@
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

@@ -17,11 +17,16 @@ import {
createVariableFetchSlice,
type VariableFetchSlice,
} from './slices/variableFetchSlice';
import {
createSettingsRequestSlice,
type SettingsRequestSlice,
} from './slices/settingsRequestSlice';
export type DashboardStore = EditContextSlice &
CollapseSlice &
VariableSelectionSlice &
VariableFetchSlice;
VariableFetchSlice &
SettingsRequestSlice;
/**
* V2 dashboard session store. Holds cross-cutting client state only — never the
@@ -37,6 +42,7 @@ 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]]