Compare commits

..

7 Commits

Author SHA1 Message Date
Ashwin Bhatkal
7db90bbbb4 test(e2e): assert the created id instead of branching on it
playwright/no-conditional-in-test: registering the new dashboard for cleanup only if
the URL happened to parse meant a parse failure would leak a dashboard silently.
Assert it parsed, then register unconditionally.
2026-08-06 00:35:51 +05:30
Ashwin Bhatkal
394aec0533 test(e2e): rewrite the dashboards-list spec for V2
The V1 list spec seeded through an API that now returns 501 and asserted against a
list that has since become views-first. This replaces it with ten tests against the
V2 list — chrome and controls, every view in the rail, a seeded dashboard appearing,
opening one, search narrowing and matching nothing, pinning into the Pinned view, the
create modal and creating through it, and a deleted dashboard leaving — and drops its
parked-specs.json entry.

Titles carry a per-run, per-worker suffix and every assertion is scoped to this
suite's own dashboards: the workspace is shared, so counting rows or asserting on
"the first row" would depend on what else happens to exist.

Not covered, deliberately, rather than left as a skip: Recently viewed. Opening a
dashboard and then switching to that view shows no rows at all — not the dashboard
just visited, nothing. That looks like a defect rather than a test problem, so it is
reported instead of encoded here.
2026-08-06 00:32:22 +05:30
Ashwin Bhatkal
762ea483db test(e2e): harden the V2 helpers against what the real UI does
Everything here was a spec failing for a reason no reading of the component would
have given up:

  - clicking an option ROW does not toggle its checkbox, so a second value silently
    never joined the commit — and an assertion on the closed control could not see
    it, because the control shows one tag plus a "+N". Hence readCheckedOptions,
    which reads the open list;
  - single-select renders a different dropdown (`.custom-select-dropdown`) whose rows
    have no `.option-label-text`, so row matching needs both shapes;
  - Escape only closes the list while the control holds focus, which a row click
    moves — closing now falls back to a click outside, which is what a user does;
  - the tooltip primitive renders its content twice, so an unscoped locator is a
    strict-mode violation, not a missing element;
  - `beforeAll` runs once per worker and the v2 API rejects a duplicate name, so
    seed titles carry the worker index.

Verified by running the smoke spec and the variables spec together on two workers.
2026-08-06 00:12:55 +05:30
Ashwin Bhatkal
6d60ea0c5e test(e2e): seed and drive V2 dashboards from a spec
Helpers for the page that now serves /dashboard/:id. Seeding goes through the v2 API
with the Perses-shape spec the UI itself writes, so a spec asserts against data it
placed rather than against migration output or whatever telemetry the stack happens
to hold — the fixture here is text + custom variables only, whose option lists come
from the definition and are therefore fixed.

The helpers encode the interaction contracts that cost the most to rediscover, each
found by driving the real UI:

  - a multi-select commits when the dropdown CLOSES, not per toggle;
  - an ALL selection renders as an overlay that is a SIBLING of the element carrying
    the testid, so reading it from the control returns empty;
  - opening an ALL selection checks every option, so clicking the wanted value
    UNchecks it — the row's "Only" button is the deterministic way in, and the clear
    icon is deliberately absent while the draft is all;
  - hovering a row reveals Only / Toggle, whose text joins the row's accessible name,
    so rows are matched on their label element instead;
  - the bar collapses variables past the first into a "+N" overflow at the config's
    1280px viewport, hence WIDE_VIEWPORT.

01-smoke.spec.ts exercises exactly this path — seed, open, read, pick, commit — so the
foundation is covered by a test rather than asserted in a description.
2026-08-05 23:50:57 +05:30
Ashwin Bhatkal
dc248794d9 test(e2e): stop .env.local clobbering an exported env var
The config loaded .env then .env.local with override:true, so a generated file beat
the process environment — the opposite of the comment above it, and it silently sent
a run at whatever stale port .env.local still held. dotenv never overwrites a var
that is already set, so loading .env.local first and .env second gives the intended
precedence: real env > .env.local > .env.
2026-08-05 23:50:57 +05:30
Ashwin Bhatkal
bd2e0aaa18 test(e2e): one registry for parked specs, guarded against rot
The dashboards suite was excluded by a blanket `testIgnore: ['**/tests/dashboards/**']`,
and inside it 26 tests had also been left as `test.skip` placeholders — invisible,
since the files were not collected at all. Two more sit in trace-details, skipped
inside a spec that IS collected.

Specs are now parked one by one in parked-specs.json, each with a reason, and
`pnpm guard:specs` fails on a skipped, fixme'd or .only test in any spec that is
NOT parked, plus on a parked entry matching no file. So a spec is either running
and complete, or parked with a reason — and every entry removed by the PRs that
migrate these specs to V2 permanently closes the door behind it.

Collection is unchanged: same 8 specs, same 19 tests.
2026-08-05 23:26:14 +05:30
Ashwin Bhatkal
5a210c114d test(e2e): make the suite's own typecheck run
`pnpm typecheck` exited on a config error before it checked anything —
moduleResolution 'bundler' is invalid with module 'commonjs' (TS5095) — so type
errors in specs and helpers have been invisible. Fixing that surfaced a second
one: helpers evaluate code in the page (`document`, `window`, `localStorage`)
with no DOM lib. Both are one-liners; the suite now typechecks clean at 0 errors.

module moves to esnext rather than moduleResolution to node10, which oxlint's
tsgolint rejects outright.
2026-08-05 23:26:14 +05:30
26 changed files with 1100 additions and 2043 deletions

View File

@@ -10247,9 +10247,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- dashboard:update
- ADMIN
- tokenizer:
- dashboard:update
- ADMIN
summary: Delete public dashboard
tags:
- dashboard
@@ -10298,9 +10298,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- dashboard:read
- ADMIN
- tokenizer:
- dashboard:read
- ADMIN
summary: Get public dashboard
tags:
- dashboard
@@ -10355,9 +10355,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- dashboard:update
- ADMIN
- tokenizer:
- dashboard:update
- ADMIN
summary: Create public dashboard
tags:
- dashboard
@@ -10399,9 +10399,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- dashboard:update
- ADMIN
- tokenizer:
- dashboard:update
- ADMIN
summary: Update public dashboard
tags:
- dashboard
@@ -14739,9 +14739,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- dashboard:list
- VIEWER
- tokenizer:
- dashboard:list
- VIEWER
summary: List dashboard saved views
tags:
- dashboard
@@ -14796,9 +14796,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- dashboard:list
- EDITOR
- tokenizer:
- dashboard:list
- EDITOR
summary: Create dashboard saved view
tags:
- dashboard
@@ -14849,9 +14849,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- dashboard:list
- EDITOR
- tokenizer:
- dashboard:list
- EDITOR
summary: Delete dashboard saved view
tags:
- dashboard
@@ -14917,9 +14917,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- dashboard:list
- EDITOR
- tokenizer:
- dashboard:list
- EDITOR
summary: Update dashboard saved view
tags:
- dashboard
@@ -14994,9 +14994,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- dashboard:list
- VIEWER
- tokenizer:
- dashboard:list
- VIEWER
summary: List dashboards (v2)
tags:
- dashboard
@@ -15051,9 +15051,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- dashboard:create
- EDITOR
- tokenizer:
- dashboard:create
- EDITOR
summary: Create dashboard (v2)
tags:
- dashboard
@@ -15104,9 +15104,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- dashboard:delete
- EDITOR
- tokenizer:
- dashboard:delete
- EDITOR
summary: Delete dashboard (v2)
tags:
- dashboard
@@ -15167,9 +15167,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- dashboard:read
- VIEWER
- tokenizer:
- dashboard:read
- VIEWER
summary: Get dashboard (v2)
tags:
- dashboard
@@ -15241,9 +15241,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- dashboard:update
- EDITOR
- tokenizer:
- dashboard:update
- EDITOR
summary: Patch dashboard (v2)
tags:
- dashboard
@@ -15310,9 +15310,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- dashboard:update
- EDITOR
- tokenizer:
- dashboard:update
- EDITOR
summary: Update dashboard (v2)
tags:
- dashboard
@@ -15378,11 +15378,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- dashboard:read
- dashboard:create
- EDITOR
- tokenizer:
- dashboard:read
- dashboard:create
- EDITOR
summary: Clone dashboard (v2)
tags:
- dashboard
@@ -15433,9 +15431,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- dashboard:update
- EDITOR
- tokenizer:
- dashboard:update
- EDITOR
summary: Unlock dashboard (v2)
tags:
- dashboard
@@ -15485,9 +15483,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- dashboard:update
- EDITOR
- tokenizer:
- dashboard:update
- EDITOR
summary: Lock dashboard (v2)
tags:
- dashboard
@@ -15551,9 +15549,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- dashboard:update
- EDITOR
- tokenizer:
- dashboard:update
- EDITOR
summary: Migrate dashboard to v2
tags:
- dashboard
@@ -23697,9 +23695,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- dashboard:list
- VIEWER
- tokenizer:
- dashboard:list
- VIEWER
summary: List dashboards for the current user (v2)
tags:
- dashboard
@@ -23744,9 +23742,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- dashboard:list
- VIEWER
- tokenizer:
- dashboard:list
- VIEWER
summary: Unpin a dashboard for the current user (v2)
tags:
- dashboard
@@ -23803,9 +23801,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- dashboard:list
- VIEWER
- tokenizer:
- dashboard:list
- VIEWER
summary: Pin a dashboard for the current user (v2)
tags:
- dashboard

View File

@@ -37,7 +37,6 @@ export default function ChartWrapper({
groupByPerQuery,
customTooltip,
pinnedTooltipElement,
tooltipPortalRoot,
'data-testid': testId,
}: ChartProps): JSX.Element {
const plotInstanceRef = useRef<uPlot | null>(null);
@@ -119,7 +118,6 @@ export default function ChartWrapper({
syncMetadata={syncMetadata}
render={renderTooltipCallback}
pinnedTooltipElement={pinnedTooltipElement}
portalRoot={tooltipPortalRoot}
/>
)}
</UPlotChart>

View File

@@ -30,7 +30,6 @@ interface BaseChartProps {
pinnedTooltipElement?: (clickData: ChartClickData) => React.ReactNode;
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => React.ReactNode;
customTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
tooltipPortalRoot?: HTMLElement | null;
'data-testid'?: string;
}
interface UPlotBasedChartProps {

View File

@@ -140,7 +140,6 @@ function Hosts(): JSX.Element {
records: data.records,
total: data.total,
endTimeBeforeRetention: data.endTimeBeforeRetention,
warning: data.warning,
};
} catch (error) {
return {

View File

@@ -40,9 +40,6 @@ export type {
// TODO(H4ad): Improve this on component level
const DRAWER_TRANSITION = { duration: 0.3, ease: [0.25, 0.1, 0.25, 1] };
// Be careful when changing these props, this must be animated with transform but later
// replaced with none, otherwise, the tooltip of the chart will be not positioned correctly
// due to how the tooltip positioning calculation works
const DRAWER_MOTION_PROPS = {
onOpenAutoFocus: (e: Event): void => e.preventDefault(),
initial: { opacity: 0, transform: 'translateX(100%)' },
@@ -50,7 +47,6 @@ const DRAWER_MOTION_PROPS = {
opacity: 1,
transform: 'translateX(0%)',
transition: DRAWER_TRANSITION,
transitionEnd: { transform: 'none' },
},
exit: {
opacity: 0,

View File

@@ -1,38 +0,0 @@
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-5);
padding: var(--spacing-4) var(--spacing-6);
border-top: 1px dashed var(--l2-border);
background: var(--l1-background);
border-radius: 0 0 6px 6px;
}
.hintList {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-2) var(--spacing-8);
margin: 0;
}
.hint {
--typography-text-display: flex;
align-items: center;
gap: var(--spacing-3);
position: relative;
&[data-active='false']::before {
content: '';
position: absolute;
top: 50%;
left: -12px;
transform: translateY(-50%);
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--l2-foreground);
opacity: 0.5;
}
}

View File

@@ -1,82 +0,0 @@
import { MousePointerClick, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Kbd } from '@signozhq/ui/kbd';
import logEvent from 'api/common/logEvent';
import { Events } from 'constants/events';
import { DEFAULT_PIN_TOOLTIP_KEY } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import styles from './ChartTooltipFooter.module.scss';
import { Typography } from '@signozhq/ui/typography';
interface ChartTooltipFooterProps {
id: string;
pinKey?: string;
isPinned: boolean;
canSelectTimeRange?: boolean;
dismiss: () => void;
}
export default function ChartTooltipFooter({
id,
pinKey = DEFAULT_PIN_TOOLTIP_KEY,
isPinned,
canSelectTimeRange = true,
dismiss,
}: ChartTooltipFooterProps): JSX.Element {
const handleUnpinClick = (): void => {
void logEvent(Events.TOOLTIP_UNPINNED, {
id,
});
dismiss();
};
return (
<output className={styles.footer} data-testid="entity-chart-tooltip-footer">
<div>
{isPinned ? (
<Typography.Text className={styles.hint} size="small">
<span>Press</span>
<Kbd active>{pinKey.toUpperCase()}</Kbd>
<span>or</span>
<Kbd active>Esc</Kbd>
<span>to unpin</span>
</Typography.Text>
) : (
<div className={styles.hintList}>
{canSelectTimeRange && (
<Typography.Text
className={styles.hint}
size="small"
data-active="false"
>
<Kbd>
<MousePointerClick size={12} />
</Kbd>
<span>Click and drag to zoom into a time range</span>
</Typography.Text>
)}
<Typography.Text className={styles.hint} size="small" data-active="false">
<span>Press</span>
<Kbd>{pinKey.toUpperCase()}</Kbd>
<span>to pin the tooltip</span>
</Typography.Text>
</div>
)}
</div>
{isPinned && (
<Button
variant="outlined"
color="secondary"
size="sm"
onClick={handleUnpinClick}
aria-label="Unpin tooltip"
data-testid="entity-chart-tooltip-unpin"
>
<X size={10} />
<span>Unpin</span>
</Button>
)}
</output>
);
}

View File

@@ -1,14 +1,11 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { useCallback, useMemo, useRef } from 'react';
import { UseQueryResult } from 'react-query';
import { Skeleton } from 'antd';
import cx from 'classnames';
import { InfraMonitoringEvents } from 'constants/events';
import { PANEL_TYPES } from 'constants/queryBuilder';
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
import {
IRenderTooltipFooterArgs,
LegendPosition,
} from 'lib/uPlotV2/components/types';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import {
InfraMonitoringEntity,
VIEW_TYPES,
@@ -36,7 +33,6 @@ import { isKeyNotFoundError } from '../utils';
import styles from './EntityMetrics.module.scss';
import { MetricsTable } from './MetricsTable';
import { logInfraExplorerNavigatedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
import ChartTooltipFooter from './ChartTooltipFooter';
interface EntityMetricsProps<T> {
entity: T;
@@ -82,10 +78,6 @@ function EntityMetrics<T>({
category,
});
const [tooltipPortalEl, setTooltipPortalEl] = useState<HTMLDivElement | null>(
null,
);
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
const graphRef = useRef<HTMLDivElement>(null);
@@ -179,18 +171,6 @@ function EntityMetrics<T>({
height={dimensions.height}
timezone={timezone}
yAxisUnit={entityWidgetInfo[idx].yAxisUnit}
canPinTooltip
tooltipPortalRoot={tooltipPortalEl ?? undefined}
renderTooltipFooter={({
isPinned,
dismiss,
}: IRenderTooltipFooterArgs) => (
<ChartTooltipFooter
id={configs[idx]?.getId() || idx.toString()}
isPinned={isPinned}
dismiss={dismiss}
/>
)}
/>
)
)}
@@ -200,8 +180,6 @@ function EntityMetrics<T>({
return (
<>
<div ref={setTooltipPortalEl} style={{ display: 'contents' }} />
<div className={styles.metricsHeader}>
<EntityDateTimeSelector
eventEntity={eventEntity}

View File

@@ -51,7 +51,6 @@ export default function TooltipPlugin({
canPinTooltip = false,
pinKey = DEFAULT_PIN_TOOLTIP_KEY,
onClick,
portalRoot: portalRootProp,
}: TooltipPluginProps): JSX.Element | null {
const containerRef = useRef<HTMLDivElement>(null);
const rafId = useRef<number | null>(null);
@@ -60,9 +59,7 @@ export default function TooltipPlugin({
const renderRef = useRef(render);
renderRef.current = render;
const [portalRoot, setPortalRoot] = useState<HTMLElement>(
portalRootProp ??
(document.fullscreenElement as HTMLElement) ??
document.body,
(document.fullscreenElement as HTMLElement) ?? document.body,
);
// React-managed snapshot of what should be rendered. The controller
@@ -467,12 +464,8 @@ export default function TooltipPlugin({
}, [config]);
const resolvePortalRoot = useCallback((): void => {
setPortalRoot(
portalRootProp ??
(document.fullscreenElement as HTMLElement) ??
document.body,
);
}, [portalRootProp]);
setPortalRoot((document.fullscreenElement as HTMLElement) ?? document.body);
}, []);
useLayoutEffect((): (() => void) => {
resolvePortalRoot();

View File

@@ -67,7 +67,6 @@ export interface TooltipPluginProps {
pinnedTooltipElement?: (clickData: ChartClickData) => ReactNode;
maxWidth?: number;
maxHeight?: number;
portalRoot?: HTMLElement | null;
}
export interface ChartClickData {

8
go.mod
View File

@@ -223,7 +223,7 @@ require (
github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/cel-go v0.29.0 // indirect
github.com/google/cel-go v0.28.0 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
github.com/googleapis/gax-go/v2 v2.18.0 // indirect
@@ -392,9 +392,9 @@ require (
golang.org/x/sys v0.45.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.44.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478
google.golang.org/grpc v1.82.1 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d
google.golang.org/grpc v1.80.0 // indirect
gopkg.in/telebot.v3 v3.3.8 // indirect
k8s.io/client-go v0.35.3 // indirect
k8s.io/klog/v2 v2.140.0 // indirect

20
go.sum
View File

@@ -247,8 +247,8 @@ github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWH
github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
github.com/coder/quartz v0.3.0 h1:bUoSEJ77NBfKtUqv6CPSC0AS8dsjqAqqAv7bN02m1mg=
github.com/coder/quartz v0.3.0/go.mod h1:BgE7DOj/8NfvRgvKw0jPLDQH/2Lya2kxcTaNJ8X0rZk=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
@@ -507,8 +507,8 @@ github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Z
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/cel-go v0.29.0 h1:fEG+Ja3YRwNOqnQxTyJwoByAUAvTuxUGiro/jhrm4F4=
github.com/google/cel-go v0.29.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8=
github.com/google/cel-go v0.28.0 h1:KjSWstCpz/MN5t4a8gnGJNIYUsJRpdi/r97xWDphIQc=
github.com/google/cel-go v0.28.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8=
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
@@ -1856,10 +1856,10 @@ google.golang.org/genproto v0.0.0-20220505152158-f39f71e6c8f3/go.mod h1:RAyBrSAP
google.golang.org/genproto v0.0.0-20220519153652-3a47de7e79bd/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d h1:vsOm753cOAMkt76efriTCDKjpCbK18XGHMJHo0JUKhc=
google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:0oz9d7g9QLSdv9/lgbIjowW1JoxMbxmBVNe8i6tORJI=
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec=
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d h1:wT2n40TBqFY6wiwazVK9/iTWbsQrgk5ZfCSVFLO9LQA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
@@ -1892,8 +1892,8 @@ google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ5
google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ=
google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=

View File

@@ -14,592 +14,371 @@ import (
)
func (provider *provider) addDashboardRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/dashboards", handler.New(
provider.authzMiddleware.CheckResources(provider.dashboardHandler.ListV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "ListDashboardsV2",
Tags: []string{"dashboard"},
Summary: "List dashboards (v2)",
Description: "Returns a page of v2-shape dashboards for the org. This is the pure, user-independent list — it carries no pin state. Use ListDashboardsForUserV2 for the personalized, pin-aware list. Supports a filter DSL (`query`), sort (`updated_at`/`created_at`/`name`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`).",
Request: nil,
RequestQuery: new(dashboardtypes.ListDashboardsV2Params),
RequestContentType: "",
Response: new(dashboardtypes.ListableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceDashboard,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryDataAccess,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v2/dashboards", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.ListV2), handler.OpenAPIDef{
ID: "ListDashboardsV2",
Tags: []string{"dashboard"},
Summary: "List dashboards (v2)",
Description: "Returns a page of v2-shape dashboards for the org. This is the pure, user-independent list — it carries no pin state. Use ListDashboardsForUserV2 for the personalized, pin-aware list. Supports a filter DSL (`query`), sort (`updated_at`/`created_at`/`name`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`).",
Request: nil,
RequestQuery: new(dashboardtypes.ListDashboardsV2Params),
RequestContentType: "",
Response: new(dashboardtypes.ListableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/users/me/dashboards", handler.New(
provider.authzMiddleware.CheckResources(provider.dashboardHandler.ListForUserV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "ListDashboardsForUserV2",
Tags: []string{"dashboard"},
Summary: "List dashboards for the current user (v2)",
Description: "Same as ListDashboardsV2 but personalized for the calling user: each dashboard carries the caller's `pinned` state, and pinned dashboards float to the top of the requested ordering. Supports the same filter DSL, sort, order, and pagination.",
Request: nil,
RequestQuery: new(dashboardtypes.ListDashboardsV2Params),
RequestContentType: "",
Response: new(dashboardtypes.ListableDashboardForUserV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceDashboard,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryDataAccess,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v2/users/me/dashboards", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.ListForUserV2), handler.OpenAPIDef{
ID: "ListDashboardsForUserV2",
Tags: []string{"dashboard"},
Summary: "List dashboards for the current user (v2)",
Description: "Same as ListDashboardsV2 but personalized for the calling user: each dashboard carries the caller's `pinned` state, and pinned dashboards float to the top of the requested ordering. Supports the same filter DSL, sort, order, and pagination.",
Request: nil,
RequestQuery: new(dashboardtypes.ListDashboardsV2Params),
RequestContentType: "",
Response: new(dashboardtypes.ListableDashboardForUserV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards", handler.New(
provider.authzMiddleware.CheckResources(provider.dashboardHandler.CreateV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "CreateDashboardV2",
Tags: []string{"dashboard"},
Summary: "Create dashboard (v2)",
Description: "This endpoint creates a dashboard in the v2 format that follows Perses spec.",
Request: new(dashboardtypes.PostableDashboardV2),
RequestContentType: "application/json",
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
// TODO: add http.StatusConflict once the dashboard name unique index is added.
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbCreate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceDashboard,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.ResponseJSONPath("data.id"),
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v2/dashboards", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.CreateV2), handler.OpenAPIDef{
ID: "CreateDashboardV2",
Tags: []string{"dashboard"},
Summary: "Create dashboard (v2)",
Description: "This endpoint creates a dashboard in the v2 format that follows Perses spec.",
Request: new(dashboardtypes.PostableDashboardV2),
RequestContentType: "application/json",
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
// TODO: add http.StatusConflict once the dashboard name unique index is added.
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
// Two defs: the clone reads the source dashboard and creates a new one, so
// both read on the source id and create on the collection are checked.
if err := router.Handle("/api/v2/dashboards/{id}/clone", handler.New(
provider.authzMiddleware.CheckResources(provider.dashboardHandler.CloneV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "CloneDashboardV2",
Tags: []string{"dashboard"},
Summary: "Clone dashboard (v2)",
Description: "This endpoint clones an existing v2-shape dashboard. User and integration dashboards can be cloned; system dashboards are rejected. The clone keeps the source's display name, panels, and tags, but gets a freshly generated unique internal name and is always created as an unlocked user dashboard owned by the caller.",
Request: nil,
RequestContentType: "",
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{
coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbRead),
coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbCreate),
}),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceDashboard,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
},
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceDashboard,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.ResponseJSONPath("data.id"),
Selector: coretypes.WildcardSelector,
},
),
)).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v2/dashboards/{id}/clone", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.CloneV2), handler.OpenAPIDef{
ID: "CloneDashboardV2",
Tags: []string{"dashboard"},
Summary: "Clone dashboard (v2)",
Description: "This endpoint clones an existing v2-shape dashboard. User and integration dashboards can be cloned; system dashboards are rejected. The clone keeps the source's display name, panels, and tags, but gets a freshly generated unique internal name and is always created as an unlocked user dashboard owned by the caller.",
Request: nil,
RequestContentType: "",
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}/migrate", handler.New(
provider.authzMiddleware.CheckResources(provider.dashboardHandler.MigrateV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "MigrateDashboardV2",
Tags: []string{"dashboard"},
Summary: "Migrate dashboard to v2",
Description: "This endpoint retries the v1→v2 (Perses) migration on a dashboard still stored in the v1 schema and returns the v2-shape result. It is idempotent: a dashboard already in the v2 schema is returned unchanged.",
Request: nil,
RequestContentType: "",
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceDashboard,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v2/dashboards/{id}/migrate", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.MigrateV2), handler.OpenAPIDef{
ID: "MigrateDashboardV2",
Tags: []string{"dashboard"},
Summary: "Migrate dashboard to v2",
Description: "This endpoint retries the v1→v2 (Perses) migration on a dashboard still stored in the v1 schema and returns the v2-shape result. It is idempotent: a dashboard already in the v2 schema is returned unchanged.",
Request: nil,
RequestContentType: "",
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.dashboardHandler.GetV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetDashboardV2",
Tags: []string{"dashboard"},
Summary: "Get dashboard (v2)",
Description: "This endpoint returns a v2-shape dashboard.",
Request: nil,
RequestContentType: "",
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceDashboard,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.GetV2), handler.OpenAPIDef{
ID: "GetDashboardV2",
Tags: []string{"dashboard"},
Summary: "Get dashboard (v2)",
Description: "This endpoint returns a v2-shape dashboard.",
Request: nil,
RequestContentType: "",
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.dashboardHandler.UpdateV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "UpdateDashboardV2",
Tags: []string{"dashboard"},
Summary: "Update dashboard (v2)",
Description: "This endpoint updates a v2-shape dashboard's metadata, data, and tag set. Locked dashboards are rejected.",
Request: new(dashboardtypes.UpdatableDashboardV2),
RequestContentType: "application/json",
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceDashboard,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodPut).GetError(); err != nil {
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.UpdateV2), handler.OpenAPIDef{
ID: "UpdateDashboardV2",
Tags: []string{"dashboard"},
Summary: "Update dashboard (v2)",
Description: "This endpoint updates a v2-shape dashboard's metadata, data, and tag set. Locked dashboards are rejected.",
Request: new(dashboardtypes.UpdatableDashboardV2),
RequestContentType: "application/json",
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.dashboardHandler.PatchV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "PatchDashboardV2",
Tags: []string{"dashboard"},
Summary: "Patch dashboard (v2)",
Description: "This endpoint applies an RFC 6902 JSON Patch to a v2-shape dashboard. The patch is applied against the postable view of the dashboard (metadata, data, tags), so individual panels, queries, variables, layouts, or tags can be updated without re-sending the rest of the dashboard. Apply is lenient: `remove` on a missing path is a no-op (idempotent) and `add` creates any missing parent objects, rather than failing as strict RFC 6902 would. The resulting dashboard is still validated. Locked dashboards are rejected.",
Request: new(dashboardtypes.PatchableDashboardV2),
// Strictly per RFC 6902 the content type is `application/json-patch+json`,
// but our OpenAPI generator only reflects schemas for content types it
// understands (application/json, form-urlencoded, multipart) — anything
// else degrades to `type: string`. Declaring application/json here keeps
// the array-of-ops schema visible to spec consumers; the runtime decoder
// parses JSON regardless of the request's actual Content-Type header.
RequestContentType: "application/json",
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceDashboard,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodPatch).GetError(); err != nil {
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.PatchV2), handler.OpenAPIDef{
ID: "PatchDashboardV2",
Tags: []string{"dashboard"},
Summary: "Patch dashboard (v2)",
Description: "This endpoint applies an RFC 6902 JSON Patch to a v2-shape dashboard. The patch is applied against the postable view of the dashboard (metadata, data, tags), so individual panels, queries, variables, layouts, or tags can be updated without re-sending the rest of the dashboard. Apply is lenient: `remove` on a missing path is a no-op (idempotent) and `add` creates any missing parent objects, rather than failing as strict RFC 6902 would. The resulting dashboard is still validated. Locked dashboards are rejected.",
Request: new(dashboardtypes.PatchableDashboardV2),
// Strictly per RFC 6902 the content type is `application/json-patch+json`,
// but our OpenAPI generator only reflects schemas for content types it
// understands (application/json, form-urlencoded, multipart) — anything
// else degrades to `type: string`. Declaring application/json here keeps
// the array-of-ops schema visible to spec consumers; the runtime decoder
// parses JSON regardless of the request's actual Content-Type header.
RequestContentType: "application/json",
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPatch).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.dashboardHandler.DeleteV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "DeleteDashboardV2",
Tags: []string{"dashboard"},
Summary: "Delete dashboard (v2)",
Description: "This endpoint deletes a v2-shape dashboard along with its tag relations. Locked dashboards are rejected.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbDelete)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceDashboard,
Verb: coretypes.VerbDelete,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.DeleteV2), handler.OpenAPIDef{
ID: "DeleteDashboardV2",
Tags: []string{"dashboard"},
Summary: "Delete dashboard (v2)",
Description: "This endpoint deletes a v2-shape dashboard along with its tag relations. Locked dashboards are rejected.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}/lock", handler.New(
provider.authzMiddleware.CheckResources(provider.dashboardHandler.LockV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "LockDashboardV2",
Tags: []string{"dashboard"},
Summary: "Lock dashboard (v2)",
Description: "This endpoint locks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceDashboard,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodPut).GetError(); err != nil {
if err := router.Handle("/api/v2/dashboards/{id}/lock", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.LockV2), handler.OpenAPIDef{
ID: "LockDashboardV2",
Tags: []string{"dashboard"},
Summary: "Lock dashboard (v2)",
Description: "This endpoint locks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}/lock", handler.New(
provider.authzMiddleware.CheckResources(provider.dashboardHandler.UnlockV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "UnlockDashboardV2",
Tags: []string{"dashboard"},
Summary: "Unlock dashboard (v2)",
Description: "This endpoint unlocks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceDashboard,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v2/dashboards/{id}/lock", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.UnlockV2), handler.OpenAPIDef{
ID: "UnlockDashboardV2",
Tags: []string{"dashboard"},
Summary: "Unlock dashboard (v2)",
Description: "This endpoint unlocks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
// Pinning mutates the calling user's pin list, not the dashboard, so it rides
// on the collection-level list permission rather than a per-dashboard check.
// The id is still extracted, for audit.
if err := router.Handle("/api/v2/users/me/dashboards/{id}/pins", handler.New(
provider.authzMiddleware.CheckResources(provider.dashboardHandler.PinV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "PinDashboardV2",
Tags: []string{"dashboard"},
Summary: "Pin a dashboard for the current user (v2)",
Description: "Pins the dashboard for the calling user. A user can pin at most 10 dashboards; pinning when at the limit returns 409. Re-pinning an already-pinned dashboard is a no-op success.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceDashboard,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryDataAccess,
ID: coretypes.PathParam("id"),
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodPut).GetError(); err != nil {
// ViewAccess: pinning only mutates the calling user's pin list, not the
// dashboard itself — anyone who can view a dashboard can bookmark it.
if err := router.Handle("/api/v2/users/me/dashboards/{id}/pins", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.PinV2), handler.OpenAPIDef{
ID: "PinDashboardV2",
Tags: []string{"dashboard"},
Summary: "Pin a dashboard for the current user (v2)",
Description: "Pins the dashboard for the calling user. A user can pin at most 10 dashboards; pinning when at the limit returns 409. Re-pinning an already-pinned dashboard is a no-op success.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/users/me/dashboards/{id}/pins", handler.New(
provider.authzMiddleware.CheckResources(provider.dashboardHandler.UnpinV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "UnpinDashboardV2",
Tags: []string{"dashboard"},
Summary: "Unpin a dashboard for the current user (v2)",
Description: "Removes the pin for the calling user. Idempotent — unpinning a dashboard that wasn't pinned still returns 204.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceDashboard,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryDataAccess,
ID: coretypes.PathParam("id"),
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v2/users/me/dashboards/{id}/pins", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.UnpinV2), handler.OpenAPIDef{
ID: "UnpinDashboardV2",
Tags: []string{"dashboard"},
Summary: "Unpin a dashboard for the current user (v2)",
Description: "Removes the pin for the calling user. Idempotent — unpinning a dashboard that wasn't pinned still returns 204.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
// Saved views hold dashboard listing state (query, sort, order) rather than any
// one dashboard, so all four ride on the collection-level list permission.
if err := router.Handle("/api/v2/dashboard_views", handler.New(
provider.authzMiddleware.CheckResources(provider.dashboardHandler.ListViews, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "ListDashboardViews",
Tags: []string{"dashboard"},
Summary: "List dashboard saved views",
Description: "Returns every saved view in the calling user's org. Saved views are shared org-wide.",
Request: nil,
RequestContentType: "",
Response: new(dashboardtypes.ListableDashboardView),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceDashboard,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryDataAccess,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v2/dashboard_views", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.ListViews), handler.OpenAPIDef{
ID: "ListDashboardViews",
Tags: []string{"dashboard"},
Summary: "List dashboard saved views",
Description: "Returns every saved view in the calling user's org. Saved views are shared org-wide.",
Request: nil,
RequestContentType: "",
Response: new(dashboardtypes.ListableDashboardView),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboard_views", handler.New(
provider.authzMiddleware.CheckResources(provider.dashboardHandler.CreateView, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "CreateDashboardView",
Tags: []string{"dashboard"},
Summary: "Create dashboard saved view",
Description: "Persists the calling user's dashboard listing state (query, sort, order) as a named, reusable view shared across the org.",
Request: new(dashboardtypes.PostableDashboardView),
RequestContentType: "application/json",
Response: new(dashboardtypes.DashboardView),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceDashboard,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryConfigurationChange,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v2/dashboard_views", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.CreateView), handler.OpenAPIDef{
ID: "CreateDashboardView",
Tags: []string{"dashboard"},
Summary: "Create dashboard saved view",
Description: "Persists the calling user's dashboard listing state (query, sort, order) as a named, reusable view shared across the org.",
Request: new(dashboardtypes.PostableDashboardView),
RequestContentType: "application/json",
Response: new(dashboardtypes.DashboardView),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboard_views/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.dashboardHandler.UpdateView, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "UpdateDashboardView",
Tags: []string{"dashboard"},
Summary: "Update dashboard saved view",
Description: "Replaces a saved view's name and data. Saved views are shared org-wide.",
Request: new(dashboardtypes.UpdatableDashboardView),
RequestContentType: "application/json",
Response: new(dashboardtypes.DashboardView),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceDashboard,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryConfigurationChange,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodPut).GetError(); err != nil {
if err := router.Handle("/api/v2/dashboard_views/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.UpdateView), handler.OpenAPIDef{
ID: "UpdateDashboardView",
Tags: []string{"dashboard"},
Summary: "Update dashboard saved view",
Description: "Replaces a saved view's name and data. Saved views are shared org-wide.",
Request: new(dashboardtypes.UpdatableDashboardView),
RequestContentType: "application/json",
Response: new(dashboardtypes.DashboardView),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboard_views/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.dashboardHandler.DeleteView, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "DeleteDashboardView",
Tags: []string{"dashboard"},
Summary: "Delete dashboard saved view",
Description: "Removes a saved view. Saved views are shared org-wide. Deleting a non-existent view returns 404.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceDashboard,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryConfigurationChange,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v2/dashboard_views/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.DeleteView), handler.OpenAPIDef{
ID: "DeleteDashboardView",
Tags: []string{"dashboard"},
Summary: "Delete dashboard saved view",
Description: "Removes a saved view. Saved views are shared org-wide. Deleting a non-existent view returns 404.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/dashboards/{id}/public", handler.New(
provider.authzMiddleware.CheckResources(provider.dashboardHandler.CreatePublic, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreatePublicDashboard",
Tags: []string{"dashboard"},
Summary: "Create public dashboard",
Description: "This endpoint creates public sharing config and enables public sharing of the dashboard",
Request: new(dashboardtypes.PostablePublicDashboard),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceDashboard,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v1/dashboards/{id}/public", handler.New(provider.authzMiddleware.AdminAccess(provider.dashboardHandler.CreatePublic), handler.OpenAPIDef{
ID: "CreatePublicDashboard",
Tags: []string{"dashboard"},
Summary: "Create public dashboard",
Description: "This endpoint creates public sharing config and enables public sharing of the dashboard",
Request: new(dashboardtypes.PostablePublicDashboard),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/dashboards/{id}/public", handler.New(
provider.authzMiddleware.CheckResources(provider.dashboardHandler.GetPublic, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetPublicDashboard",
Tags: []string{"dashboard"},
Summary: "Get public dashboard",
Description: "This endpoint returns public sharing config for a dashboard",
Request: nil,
RequestContentType: "",
Response: new(dashboardtypes.GettablePublicDasbhboard),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceDashboard,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v1/dashboards/{id}/public", handler.New(provider.authzMiddleware.AdminAccess(provider.dashboardHandler.GetPublic), handler.OpenAPIDef{
ID: "GetPublicDashboard",
Tags: []string{"dashboard"},
Summary: "Get public dashboard",
Description: "This endpoint returns public sharing config for a dashboard",
Request: nil,
RequestContentType: "",
Response: new(dashboardtypes.GettablePublicDasbhboard),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/dashboards/{id}/public", handler.New(
provider.authzMiddleware.CheckResources(provider.dashboardHandler.UpdatePublic, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "UpdatePublicDashboard",
Tags: []string{"dashboard"},
Summary: "Update public dashboard",
Description: "This endpoint updates the public sharing config for a dashboard",
Request: new(dashboardtypes.UpdatablePublicDashboard),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceDashboard,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodPut).GetError(); err != nil {
if err := router.Handle("/api/v1/dashboards/{id}/public", handler.New(provider.authzMiddleware.AdminAccess(provider.dashboardHandler.UpdatePublic), handler.OpenAPIDef{
ID: "UpdatePublicDashboard",
Tags: []string{"dashboard"},
Summary: "Update public dashboard",
Description: "This endpoint updates the public sharing config for a dashboard",
Request: new(dashboardtypes.UpdatablePublicDashboard),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/dashboards/{id}/public", handler.New(
provider.authzMiddleware.CheckResources(provider.dashboardHandler.DeletePublic, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "DeletePublicDashboard",
Tags: []string{"dashboard"},
Summary: "Delete public dashboard",
Description: "This endpoint deletes the public sharing config and disables the public sharing of a dashboard",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceDashboard,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v1/dashboards/{id}/public", handler.New(provider.authzMiddleware.AdminAccess(provider.dashboardHandler.DeletePublic), handler.OpenAPIDef{
ID: "DeletePublicDashboard",
Tags: []string{"dashboard"},
Summary: "Delete public dashboard",
Description: "This endpoint deletes the public sharing config and disables the public sharing of a dashboard",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}

View File

@@ -234,7 +234,6 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewUpdateRoleTransactionGroupsFactory(),
sqlmigration.NewFillDashboardSpecCollectionsFactory(sqlstore, dashboardStore),
sqlmigration.NewScrubEmailChannelTransportFactory(sqlstore),
sqlmigration.NewAddDashboardTuplesFactory(sqlstore),
)
}

View File

@@ -1,149 +0,0 @@
package sqlmigration
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/oklog/ulid/v2"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
"github.com/uptrace/bun/migrate"
)
type addDashboardTuples struct {
sqlstore sqlstore.SQLStore
}
func NewAddDashboardTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_dashboard_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addDashboardTuples{sqlstore: sqlstore}, nil
})
}
func (migration *addDashboardTuples) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addDashboardTuples) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var storeID string
err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID)
if err != nil {
return err
}
var orgIDs []string
rows, err := tx.QueryContext(ctx, `SELECT id FROM organizations`)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var orgID string
if err := rows.Scan(&orgID); err != nil {
return err
}
orgIDs = append(orgIDs, orgID)
}
isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG
// The dashboard routes, including the public sharing config, now authorize per
// resource, so every org needs the managed-role dashboard tuples. Orgs
// bootstrapped before these entries landed in ManagedRoleToTransactions never
// got them.
tuples := []migrationTuple{
{authtypes.SigNozAdminRoleName, "metaresource", "dashboard", "read"},
{authtypes.SigNozAdminRoleName, "metaresource", "dashboard", "update"},
{authtypes.SigNozAdminRoleName, "metaresource", "dashboard", "delete"},
{authtypes.SigNozAdminRoleName, "metaresource", "dashboard", "create"},
{authtypes.SigNozAdminRoleName, "metaresource", "dashboard", "list"},
{authtypes.SigNozEditorRoleName, "metaresource", "dashboard", "read"},
{authtypes.SigNozEditorRoleName, "metaresource", "dashboard", "update"},
{authtypes.SigNozEditorRoleName, "metaresource", "dashboard", "delete"},
{authtypes.SigNozEditorRoleName, "metaresource", "dashboard", "create"},
{authtypes.SigNozEditorRoleName, "metaresource", "dashboard", "list"},
{authtypes.SigNozViewerRoleName, "metaresource", "dashboard", "read"},
{authtypes.SigNozViewerRoleName, "metaresource", "dashboard", "list"},
}
for _, orgID := range orgIDs {
for _, tuple := range tuples {
entropy := ulid.DefaultEntropy()
now := time.Now().UTC()
tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String()
objectID := "organization/" + orgID + "/" + tuple.objectName + "/*"
roleSubject := "organization/" + orgID + "/role/" + tuple.roleName
if isPG {
user := "role:" + roleSubject + "#assignee"
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, 0, tupleID, now,
)
if err != nil {
return err
}
} else {
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", 0, tupleID, now,
)
if err != nil {
return err
}
}
}
}
return tx.Commit()
}
func (migration *addDashboardTuples) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -54,7 +54,7 @@ var (
ResourceMetaResourceCloudIntegration = NewResourceMetaResource(KindCloudIntegration)
ResourceMetaResourceCloudIntegrationService = NewResourceMetaResource(KindCloudIntegrationService)
ResourceMetaResourceIntegration = NewResourceMetaResource(KindIntegration)
ResourceMetaResourceDashboard = NewResourceMetaResource(KindDashboard, VerbCreate, VerbList, VerbRead, VerbUpdate, VerbDelete)
ResourceMetaResourceDashboard = NewResourceMetaResource(KindDashboard)
ResourceMetaResourcePublicDashboard = NewResourceMetaResource(KindPublicDashboard)
ResourceMetaResourceIngestionKey = NewResourceMetaResource(KindIngestionKey)
ResourceMetaResourceIngestionLimit = NewResourceMetaResource(KindIngestionLimit)

View File

@@ -0,0 +1,299 @@
import type { APIRequestContext, Locator, Page } from '@playwright/test';
import { expect } from '@playwright/test';
import { authToken } from './dashboards';
// Helpers for the V2 dashboard detail page (`DashboardPageV2`), which now serves
// /dashboard/:id unconditionally. The V1 helpers in ./dashboards.ts still cover
// seeding through the v1 API and the list page.
//
// Interaction contracts encoded here rather than in each spec:
// - a multi-select variable commits on dropdown CLOSE, not per toggle;
// - an ALL selection renders as an overlay reading "ALL", not as tags;
// - a variable is only settled once its options have arrived.
export const dashboardV2Path = (id: string): string => `/dashboard/${id}`;
/** Perses-style spec the v2 API stores. Only what the specs need is typed. */
export interface DashboardV2Spec {
display: { name: string; description?: string };
layouts: unknown[];
panels: Record<string, unknown>;
variables: unknown[];
[key: string]: unknown;
}
export const SCHEMA_VERSION = 'v6';
/** An empty but valid spec — the base every fixture spreads over. */
export function emptyV2Spec(name: string): DashboardV2Spec {
return { display: { name }, layouts: [], panels: {}, variables: [] };
}
// ─── Seeding through the v2 API ───────────────────────────────────────────
//
// Specs seed the shape they assert against, rather than relying on whatever the
// v1 -> v2 migration happens to produce or on telemetry that ambient data may or
// may not contain. Migration output is covered on its own, from the v1 fixtures.
export async function createDashboardV2ViaApi(
page: Page,
name: string,
spec?: Partial<DashboardV2Spec>,
): Promise<string> {
const token = await authToken(page);
const res = await page.request.post('/api/v2/dashboards', {
data: {
name,
schemaVersion: SCHEMA_VERSION,
tags: [],
// `name` wins over any display name the fixture carries, so a fixture can be
// seeded twice under two titles and each spec can still find its own.
spec: {
...emptyV2Spec(name),
...spec,
display: { ...spec?.display, name },
},
},
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok()) {
throw new Error(
`POST /api/v2/dashboards ${res.status()}: ${await res.text()}`,
);
}
const body = (await res.json()) as { data?: { id?: string } };
const id = body.data?.id;
if (!id) {
throw new Error(
`POST /api/v2/dashboards returned no id: ${JSON.stringify(body)}`,
);
}
return id;
}
export async function getDashboardV2(
page: Page,
id: string,
): Promise<{ spec: DashboardV2Spec; [key: string]: unknown }> {
const token = await authToken(page);
const res = await page.request.get(`/api/v2/dashboards/${id}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok()) {
throw new Error(
`GET /api/v2/dashboards/${id} ${res.status()}: ${await res.text()}`,
);
}
const body = (await res.json()) as {
data: { spec: DashboardV2Spec; [key: string]: unknown };
};
return body.data;
}
export async function deleteDashboardV2ViaApi(
request: APIRequestContext,
id: string,
token: string,
): Promise<void> {
await request.delete(`/api/v2/dashboards/${id}`, {
headers: { Authorization: `Bearer ${token}` },
});
}
// ─── Variables bar ────────────────────────────────────────────────────────
export const variablesBar = (page: Page): Locator =>
page.getByTestId('dashboard-variables-bar');
/** The pill for one variable: its name, the control, and (while loading) a spinner. */
export const variablePill = (page: Page, name: string): Locator =>
page.getByTestId(`variable-${name}`);
/** List variables (query / custom / dynamic) — the select control. */
export const variableControl = (page: Page, name: string): Locator =>
page.getByTestId(`variable-select-${name}`);
/** Text variables — a plain input, not a select. */
export const variableTextInput = (page: Page, name: string): Locator =>
page.getByTestId(`variable-input-${name}`);
/**
* The bar collapses variables that do not fit into a "+N" button. At the config's
* 1280px viewport that starts with the second variable, so a spec asserting on
* several pills at once must widen the viewport:
*
* test.use({ viewport: WIDE_VIEWPORT });
*/
export const WIDE_VIEWPORT = { width: 1920, height: 1080 };
/**
* The overflow ("+N") tooltip listing the collapsed variables. `.first()` because the
* tooltip primitive renders its content twice — once visible, once as an a11y copy —
* so an unscoped locator is a strict-mode violation rather than a missing element.
*/
export const hiddenVariablesTooltip = (page: Page): Locator =>
page.getByTestId('hidden-variables-tooltip').first();
/** Resolved when the variable's options have arrived and its spinner is gone. */
export async function awaitVariableSettled(
page: Page,
name: string,
): Promise<void> {
await expect(variablePill(page, name)).toBeVisible();
await expect(page.getByTestId(`variable-loading-${name}`)).toBeHidden();
}
/**
* What a list variable's closed control shows — "ALL" for an ALL selection, else its
* tags. Asserts the control exists first: a missing one (wrong name, or a text
* variable, which uses {@link variableTextInput}) otherwise hangs until the test
* times out with nothing to point at.
*/
export async function readVariableSelection(
page: Page,
name: string,
): Promise<string> {
const pill = variablePill(page, name);
await expect(pill).toBeVisible();
// The ALL overlay sits in the control's wrapper, as a SIBLING of the element
// carrying the testid — scope from the pill, or an ALL selection reads as empty.
const allOverlay = pill.locator('.all-text');
if ((await allOverlay.count()) > 0 && (await allOverlay.isVisible())) {
return (await allOverlay.textContent())?.trim() ?? '';
}
return (await variableControl(page, name).innerText()).trim();
}
/** The open option list, whichever control opened it. */
export const anyDropdown = (page: Page): Locator =>
page.locator('.custom-multiselect-dropdown, .custom-select-dropdown');
export async function openVariableDropdown(
page: Page,
name: string,
): Promise<void> {
await awaitVariableSettled(page, name);
await variableControl(page, name).click();
await expect(anyDropdown(page)).toBeVisible();
}
/**
* Close the open dropdown, which is what commits a multi-select edit. Pressing
* Escape leaves the control focused without re-opening it, unlike clicking away.
*/
export async function closeVariableDropdown(page: Page): Promise<void> {
// Escape closes it when the control still holds focus, which a row click can move.
// Falling back to a click outside covers that, and is what a user does anyway —
// either way the close is what commits the edit.
await page.keyboard.press('Escape');
if (await anyDropdown(page).first().isVisible()) {
await page.getByTestId('dashboard-title').click();
}
await expect(anyDropdown(page).first()).toBeHidden();
}
const escapeForRegExp = (value: string): string =>
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
/**
* One option row in the open dropdown, matched on its label element rather than the
* row's accessible name: hovering reveals the Only / Toggle buttons, whose text joins
* that name, so a name-based locator stops matching halfway through an interaction.
*/
export function optionRow(page: Page, value: string): Locator {
const exact = new RegExp(`^${escapeForRegExp(value)}$`);
// Multi-select rows carry a `.option-label-text` and, once hovered, Only / Toggle
// buttons whose text would break an exact match on the row itself. Single-select
// rows have neither — just the label — so each shape needs its own matcher.
const multi = page
.locator('.custom-multiselect-dropdown .option-item')
.filter({ has: page.locator('.option-label-text', { hasText: exact }) });
const single = page
.locator('.custom-select-dropdown .option-item')
.filter({ hasText: exact });
return multi.or(single);
}
export async function pickVariableValues(
page: Page,
name: string,
values: string[],
): Promise<void> {
await openVariableDropdown(page, name);
const [first, ...rest] = values;
// Start from the row's "Only" button rather than its checkbox: an ALL selection
// opens with every option checked, so a click would UNcheck the wanted value and
// leave the rest selected. "Only" collapses to exactly this option either way,
// and the clear icon is deliberately unavailable while the draft is all.
// Wait for each target to be visible before acting: the dropdown re-renders as
// options resolve, and a click on a row that is still arriving (or has just been
// replaced) hangs until the test times out.
const firstRow = optionRow(page, first);
await expect(firstRow).toBeVisible();
await firstRow.hover();
const onlyButton = firstRow.locator('.only-btn');
await expect(onlyButton).toBeVisible();
await onlyButton.click();
// Additional values then add to that selection. Click the row's checkbox, not the
// row: the row body carries no toggle handler, so clicking it leaves the option
// unchecked and the value silently absent from the commit.
for (const value of rest) {
const row = optionRow(page, value);
await expect(row).toBeVisible();
const checkbox = row.locator('.option-checkbox');
if ((await checkbox.count()) > 0) {
await checkbox.first().click();
} else {
await row.click();
}
}
await closeVariableDropdown(page);
}
/** Type a value the option list does not offer, and commit it. */
export async function typeVariableValue(
page: Page,
name: string,
value: string,
): Promise<void> {
await openVariableDropdown(page, name);
await page.keyboard.type(value);
const dropdown = page.locator('.custom-multiselect-dropdown');
await dropdown.getByText(value, { exact: true }).first().click();
await closeVariableDropdown(page);
}
// ─── Panels and sections ──────────────────────────────────────────────────
export const panelByTitle = (page: Page, title: string): Locator =>
page.locator('[data-panel-id]').filter({ hasText: title });
export const sectionByName = (page: Page, name: string): Locator =>
page.locator('[data-section-id]').filter({ hasText: name });
/** Resolved when no panel on the page is still fetching. */
export async function awaitPanelsSettled(page: Page): Promise<void> {
await expect(page.getByTestId('panel-refetching')).toHaveCount(0);
}
/**
* The values currently checked in a multi-select's list, read from the open dropdown —
* the closed control shows at most one tag plus a "+N", so it cannot confirm a
* multi-value selection on its own. Excludes the aggregate ALL row.
*/
export async function readCheckedOptions(
page: Page,
name: string,
): Promise<string[]> {
await openVariableDropdown(page, name);
const labels = await page
.locator(
'.custom-multiselect-dropdown .option-item[aria-selected="true"]:not(.all-option) .option-label-text',
)
.allInnerTexts();
await closeVariableDropdown(page);
return labels.map((label) => label.trim());
}

View File

@@ -21,6 +21,7 @@
"fmt:check": "oxfmt --check .",
"lint": "oxlint .",
"lint:fix": "oxlint . --fix",
"guard:specs": "node scripts/guard-specs.mjs",
"typecheck": "tsc --noEmit"
},
"keywords": [

View File

@@ -0,0 +1,20 @@
{
"$comment": "Specs not currently running, and why. This is the ONLY place a spec may be excluded from the suite: `playwright.config.ts` feeds `specs` to `testIgnore`, and `pnpm guard:specs` fails if any spec that is NOT listed here contains a skipped, fixme'd or .only test. So a spec is either running and complete, or parked here with a reason — nothing rots quietly in between. Every entry is removed by the PR that migrates it; the list only shrinks.",
"specs": [
"**/tests/dashboards/details/03-viewing.spec.ts",
"**/tests/dashboards/details/12-sections.spec.ts",
"**/tests/dashboards/details/21-panel-actions.spec.ts",
"**/tests/dashboards/details/35-add-panel.spec.ts",
"**/tests/dashboards/details/44-edit-panel.spec.ts",
"**/tests/dashboards/details/56-time-range.spec.ts",
"**/tests/dashboards/details/67-variables.spec.ts",
"**/tests/dashboards/details/78-edit-mode.spec.ts",
"**/tests/dashboards/details/87-configure.spec.ts",
"**/tests/dashboards/details/95-edge-cases.spec.ts",
"**/tests/trace-details/preview-fields.spec.ts"
],
"reasons": {
"**/tests/dashboards/**": "Written against V1 dashboard behaviour; the V1 -> V2 migration changed what they assert. Being rewritten area by area — see the E0-E8 plan.",
"**/tests/trace-details/preview-fields.spec.ts": "Entirely `describe.skip` since it was added: the hover card's preview field needs seeded telemetry the suite does not provide yet."
}
}

View File

@@ -2,22 +2,23 @@ import { defineConfig, devices } from '@playwright/test';
import dotenv from 'dotenv';
import path from 'path';
// .env holds user-provided defaults (staging creds).
// .env.local is written by tests/e2e/bootstrap/setup.py when the pytest
// lifecycle brings the backend up locally; override=true so local-backend
// coordinates win over any stale .env values. Subprocess-injected env
// (e.g. when pytest shells out to `pnpm test`) still takes priority —
// dotenv doesn't touch vars that are already set in process.env.
import parkedSpecs from './parked-specs.json';
// Precedence: real env > .env.local > .env. dotenv never overwrites a var that is
// already set, so loading in that order gives local-backend coordinates (.env.local,
// written by bootstrap/setup.py) priority over the staging defaults in .env, while an
// explicitly exported var still wins over both — which is what lets a run be pointed
// at another environment without editing a generated file.
dotenv.config({ path: path.resolve(__dirname, '.env.local') });
dotenv.config({ path: path.resolve(__dirname, '.env') });
dotenv.config({ path: path.resolve(__dirname, '.env.local'), override: true });
export default defineConfig({
testDir: './tests',
// Temporarily excluded: the V1 -> V2 dashboard migration changes the
// behaviour the dashboards specs assert against, so they fail as written.
// Remove this once they are updated for the V2 dashboard.
testIgnore: ['**/tests/dashboards/**'],
// Parked specs, listed one by one with a reason in parked-specs.json — not a
// blanket glob, so nothing new can land inside an excluded directory unnoticed.
// `pnpm guard:specs` keeps this list and the suite honest.
testIgnore: parkedSpecs.specs,
// All Playwright output lands under artifacts/. One subdir per reporter
// plus results/ for per-test artifacts (traces/screenshots/videos).

View File

@@ -0,0 +1,91 @@
// Keeps the suite honest: a spec is either running and complete, or parked in
// parked-specs.json with a reason. Fails on a skipped/fixme'd/only test in a spec that
// is not parked, and on a parked entry that no longer matches anything.
//
// Run: pnpm guard:specs
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const parked = JSON.parse(
readFileSync(join(root, 'parked-specs.json'), 'utf8'),
);
/** Every *.spec.ts under tests/, repo-relative with forward slashes. */
function specFiles(dir) {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
return specFiles(full);
}
return entry.name.endsWith('.spec.ts') ? [full] : [];
});
}
/** A parked glob (`**/tests/x/y.spec.ts`) matched against an absolute path. */
function matchesGlob(glob, absolutePath) {
const ANY_DIRS = '\u0000';
const pattern = glob
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*\*\//g, ANY_DIRS)
// Single `*` never crosses a path separator; do this before expanding ANY_DIRS,
// whose replacement itself contains a `*`.
.replace(/\*/g, '[^/]*')
.split(ANY_DIRS)
.join('(?:.*/)?');
return new RegExp(`^${pattern}$`).test(absolutePath.split('\\').join('/'));
}
// Declaration form only — `test.skip(condition, reason)` inside a test body is a
// legitimate runtime guard, not a parked test.
const OFFENDERS = [
{ label: 'test.skip', re: /(?<![\w.])test\.skip\(\s*['"`]/g },
{ label: 'test.fixme', re: /(?<![\w.])test\.fixme\(\s*['"`]/g },
{ label: 'describe.skip', re: /describe\.skip\(/g },
{ label: 'describe.fixme', re: /describe\.fixme\(/g },
{ label: 'test.only', re: /(?<![\w.])test\.only\(/g },
{ label: 'describe.only', re: /describe\.only\(/g },
];
const testsDir = join(root, 'tests');
const files = statSync(testsDir, { throwIfNoEntry: false })
? specFiles(testsDir)
: [];
const failures = [];
for (const file of files) {
if (parked.specs.some((glob) => matchesGlob(glob, file))) {
continue;
}
const source = readFileSync(file, 'utf8');
for (const { label, re } of OFFENDERS) {
const hits = source.match(re);
if (hits) {
failures.push(
`${relative(root, file)}: ${hits.length} × ${label} — finish it, or park the spec in parked-specs.json with a reason`,
);
}
}
}
for (const glob of parked.specs) {
if (!files.some((file) => matchesGlob(glob, file))) {
failures.push(
`parked-specs.json: "${glob}" matches no spec — drop the stale entry`,
);
}
}
if (failures.length > 0) {
console.error(
`\nguard:specs failed\n\n${failures.map((f) => `${f}`).join('\n')}\n`,
);
process.exit(1);
}
// eslint-disable-next-line no-console
console.log(
`guard:specs ok — ${files.length - parked.specs.length}/${files.length} specs running, ${parked.specs.length} parked`,
);

View File

@@ -0,0 +1,48 @@
{
"$comment": "V2 (Perses-shape) dashboard spec seeded through POST /api/v2/dashboards. Only text and custom variables, so it resolves without telemetry: option lists are fixed by the definition, which keeps assertions on them deterministic. Query and dynamic variables are seeded per-spec alongside the telemetry they need.",
"spec": {
"display": { "name": "variables-v2", "description": "" },
"layouts": [],
"panels": {},
"variables": [
{
"kind": "TextVariable",
"spec": {
"name": "tb_env",
"display": { "name": "tb_env", "description": "Free-text environment" },
"value": "prod",
"constant": false
}
},
{
"kind": "ListVariable",
"spec": {
"name": "cu_service",
"display": { "name": "cu_service", "description": "Multi-select with ALL" },
"allowMultiple": true,
"allowAllValue": true,
"sort": "none",
"plugin": {
"kind": "signoz/CustomVariable",
"spec": { "customValue": "checkout,payments,cart" }
}
}
},
{
"kind": "ListVariable",
"spec": {
"name": "cu_region",
"display": { "name": "cu_region", "description": "Single-select with a default" },
"allowMultiple": false,
"allowAllValue": false,
"sort": "none",
"defaultValue": "eu-west",
"plugin": {
"kind": "signoz/CustomVariable",
"spec": { "customValue": "us-east,eu-west" }
}
}
}
]
}
}

View File

@@ -0,0 +1,110 @@
import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import { authToken } from '../../../helpers/dashboards';
import {
createDashboardV2ViaApi,
dashboardV2Path,
deleteDashboardV2ViaApi,
pickVariableValues,
readVariableSelection,
variablePill,
variablesBar,
variableTextInput,
WIDE_VIEWPORT,
} from '../../../helpers/dashboards-v2';
import customVariables from '../../../testdata/variables-dashboard-v2.json';
// The foundation the other dashboards specs build on: seeding a V2 spec through the
// v2 API, opening it, and driving the variables bar. Everything here is deterministic
// — custom and text variables need no telemetry, so this spec cannot go red because
// of what the stack happens to hold.
test.use({ viewport: WIDE_VIEWPORT });
const seedIds = new Set<string>();
let dashboardId = '';
// Per worker: `beforeAll` runs once in each, and the v2 API rejects a duplicate name.
const SUITE_TITLE = `detail-smoke-suite-${process.env.TEST_WORKER_INDEX ?? '0'}`;
test.beforeAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
dashboardId = await createDashboardV2ViaApi(
page,
SUITE_TITLE,
customVariables.spec,
);
seedIds.add(dashboardId);
} finally {
await ctx.close();
}
});
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) {
return;
}
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of seedIds) {
await deleteDashboardV2ViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
test.describe('Dashboard detail — V2 foundation', () => {
test('TC-01 a seeded V2 dashboard opens with its title and variables bar', async ({
authedPage: page,
}) => {
await page.goto(dashboardV2Path(dashboardId));
await expect(page.getByTestId('dashboard-title')).toContainText(SUITE_TITLE);
await expect(variablesBar(page)).toBeVisible();
for (const name of ['tb_env', 'cu_service', 'cu_region']) {
await expect(variablePill(page, name)).toBeVisible();
}
});
test('TC-02 a text variable renders the value it was seeded with', async ({
authedPage: page,
}) => {
await page.goto(dashboardV2Path(dashboardId));
await expect(variableTextInput(page, 'tb_env')).toHaveValue('prod');
});
test('TC-03 an ALL-enabled multi-select reads ALL until a value is picked', async ({
authedPage: page,
}) => {
await page.goto(dashboardV2Path(dashboardId));
// Seeded with allowAllValue and no default, so it resolves to ALL.
await expect
.poll(() => readVariableSelection(page, 'cu_service'))
.toBe('ALL');
// A multi-select commits when the dropdown closes, not per toggle.
await pickVariableValues(page, 'cu_service', ['checkout']);
await expect
.poll(() => readVariableSelection(page, 'cu_service'))
.toContain('checkout');
});
test('TC-04 a single-select renders its configured default', async ({
authedPage: page,
}) => {
await page.goto(dashboardV2Path(dashboardId));
await expect
.poll(() => readVariableSelection(page, 'cu_region'))
.toContain('eu-west');
});
});

View File

@@ -1,57 +1,53 @@
import type { Page } from '@playwright/test';
import type { Locator, Page } from '@playwright/test';
import { expect, test } from '../../fixtures/auth';
import { newAdminContext } from '../../helpers/auth';
import { authToken } from '../../helpers/dashboards';
import {
APM_METRICS_TITLE,
authToken,
createDashboardViaApi,
DEFAULT_DASHBOARD_TITLE,
deleteDashboardViaApi,
findDashboardIdByTitle,
gotoDashboardsList,
importApmMetricsDashboardViaUI,
openDashboardActionMenu,
SEARCH_PLACEHOLDER,
} from '../../helpers/dashboards';
createDashboardV2ViaApi,
deleteDashboardV2ViaApi,
WIDE_VIEWPORT,
} from '../../helpers/dashboards-v2';
// Tests in this file mutate the dashboard list (create / delete). Run them
// serially within the worker so state from one test does not leak into
// another's assertions. Files still run in parallel via the project-level
// fullyParallel setting.
test.describe.configure({ mode: 'serial' });
// The V2 dashboards list: the views rail, the list itself, search, sort and pinning.
// Seeded through the v2 API, and every assertion is scoped to this suite's own
// dashboards — the workspace is shared, so counting rows or asserting on "the first
// row" would depend on what else exists.
test.use({ viewport: WIDE_VIEWPORT });
// ─── Suite-level seed registry ───────────────────────────────────────────
//
// Every dashboard a test creates is recorded here, and one `afterAll`
// deletes the lot at suite teardown. Individual tests do not need their
// own `try / finally` cleanup blocks.
const seedIds = new Set<string>();
const BASE_FIXTURE_TITLE = 'dashboards-list-base-fixture';
const RUN = `${Date.now()}-${process.env.TEST_WORKER_INDEX ?? '0'}`;
const listPath = '/dashboard';
/** Seed a dashboard via API and register it for suite cleanup. */
async function seed(page: Page, title: string): Promise<string> {
const id = await createDashboardViaApi(page, title);
/** A title unique to this run, so searches can only match what this suite made. */
const title = (label: string): string => `e2e-list-${label}-${RUN}`;
async function seed(page: Page, label: string): Promise<string> {
const id = await createDashboardV2ViaApi(page, title(label));
seedIds.add(id);
return id;
}
test.beforeAll(async ({ browser }) => {
// Persistent fixtures the read-only tests rely on:
// - A minimal base dashboard — keeps the list non-empty so the search
// input / sort button render. Seeded first via API so the workspace
// is populated before the UI import flow runs.
// - APM Metrics — a richer, real-world dashboard imported through the
// real Import JSON UI flow (file upload + Monaco editor + submit).
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
seedIds.add(await createDashboardViaApi(page, BASE_FIXTURE_TITLE));
seedIds.add(await importApmMetricsDashboardViaUI(page));
} finally {
await ctx.close();
}
});
async function gotoList(page: Page): Promise<void> {
await page.goto(listPath);
await expect(
page.getByRole('heading', { name: 'All dashboards' }),
).toBeVisible();
}
/** Rows are indexed, not keyed by name — find the row holding a given title. */
const rowByTitle = (page: Page, dashboardTitle: string): Locator =>
page.locator('[data-testid^="dashboard-title-"]').filter({
hasText: dashboardTitle,
});
/** Type into the list's query box and run it. */
async function search(page: Page, term: string): Promise<void> {
await page.getByTestId('dashboards-list-search').click();
await page.keyboard.type(term);
await page.getByTestId('dashboards-list-search-submit').click();
}
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) {
@@ -62,7 +58,7 @@ test.afterAll(async ({ browser }) => {
try {
const token = await authToken(page);
for (const id of seedIds) {
await deleteDashboardViaApi(ctx.request, id, token);
await deleteDashboardV2ViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
@@ -70,501 +66,146 @@ test.afterAll(async ({ browser }) => {
}
});
test.describe('Dashboards List Page', () => {
// ─── Page load and layout ────────────────────────────────────────────────
test.describe('Dashboards list', () => {
test('TC-01 page chrome and core controls render', async ({
authedPage: page,
}) => {
await gotoDashboardsList(page);
await gotoList(page);
await expect(page).toHaveURL('/dashboard');
await expect(page).toHaveTitle('SigNoz | All Dashboards');
await expect(
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
).toBeVisible();
await expect(
page.getByText('Create and manage dashboards for your workspace.'),
).toBeVisible();
await expect(page.getByPlaceholder(SEARCH_PLACEHOLDER)).toBeVisible();
await expect(page.getByText('All Dashboards')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Views' })).toBeVisible();
await expect(page.getByTestId('new-dashboard-cta')).toBeVisible();
await expect(page.getByTestId('dashboards-list-search')).toBeVisible();
await expect(page.getByTestId('sort-by')).toBeVisible();
await expect(page.getByAltText('dashboard-image').first()).toBeVisible();
await expect(page.getByRole('button', { name: 'Feedback' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
});
// ─── Search functionality ────────────────────────────────────────────────
test('TC-02 search by title returns matching dashboard', async ({
test('TC-02 every view in the rail is reachable', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-search-title';
await seed(page, name);
await gotoList(page);
await gotoDashboardsList(page);
const search = page.getByPlaceholder(SEARCH_PLACEHOLDER);
await search.fill(name);
await expect(page).toHaveURL(new RegExp(`search=${name}`));
await expect(search).toHaveValue(name);
await expect(page.getByAltText('dashboard-image').first()).toBeVisible();
await expect(page.getByText(name).first()).toBeVisible();
});
test('TC-03 search by tag returns the APM Metrics dashboard', async ({
authedPage: page,
}) => {
// APM Metrics carries multiple tags — searching by one of them ("apm")
// surfaces the imported dashboard. This exercises the tag-match branch
// in the filter, distinct from title-match.
await gotoDashboardsList(page);
const search = page.getByPlaceholder(SEARCH_PLACEHOLDER);
await search.fill('apm');
await expect(page).toHaveURL(/search=apm/);
await expect(page.getByText(APM_METRICS_TITLE).first()).toBeVisible();
});
test('TC-04 direct navigation with ?search= pre-fills the input and filters results', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-search-deeplink';
await seed(page, name);
await page.goto(`/dashboard?search=${name}`);
await page
.getByRole('heading', { name: 'Dashboards', level: 1 })
.waitFor({ state: 'visible' });
await expect(page.getByPlaceholder(SEARCH_PLACEHOLDER)).toHaveValue(name);
await expect(page.getByText(name).first()).toBeVisible();
});
test('TC-05 clearing search restores the full list', async ({
authedPage: page,
}) => {
await gotoDashboardsList(page);
const search = page.getByPlaceholder(SEARCH_PLACEHOLDER);
await search.fill('apm');
await expect(page).toHaveURL(/search=apm/);
await search.fill('');
// The app keeps the empty `search=` param in the URL — assert that no
// non-empty value remains and that rows are rendered again.
await expect(page).not.toHaveURL(/search=[^&]/);
await expect(search).toHaveValue('');
await expect(page.getByAltText('dashboard-image').first()).toBeVisible();
});
test('TC-06 search with no matching results shows empty state', async ({
authedPage: page,
}) => {
await gotoDashboardsList(page);
const search = page.getByPlaceholder(SEARCH_PLACEHOLDER);
await search.fill('xyznonexistent999');
await expect(page.getByAltText('dashboard-image')).toHaveCount(0);
await expect(search).toBeVisible();
await expect(search).toHaveValue('xyznonexistent999');
});
test('TC-07 search is case-insensitive', async ({ authedPage: page }) => {
await gotoDashboardsList(page);
const search = page.getByPlaceholder(SEARCH_PLACEHOLDER);
await search.fill(APM_METRICS_TITLE.toLowerCase());
await expect(page.getByAltText('dashboard-image').first()).toBeVisible();
await expect(page.getByText(APM_METRICS_TITLE).first()).toBeVisible();
});
// ─── Sorting ─────────────────────────────────────────────────────────────
//
// `sortHandle` in DashboardsList.tsx hard-codes `order: 'descend'` —
// ascending mode is not yet implemented. Both sort options ride the same
// descending-only path, so one parameterised test covers them.
test('TC-08 sort options write columnKey & order=descend to the URL', async ({
authedPage: page,
}) => {
for (const [optionTestId, columnKey] of [
['sort-by-last-updated', 'updatedAt'],
['sort-by-last-created', 'createdAt'],
] as const) {
await gotoDashboardsList(page);
await expect(page).not.toHaveURL(/columnKey/);
await page.getByTestId('sort-by').click();
const option = page.getByTestId(optionTestId);
await option.waitFor({ state: 'visible' });
await option.click();
await expect(page).toHaveURL(new RegExp(`columnKey=${columnKey}`));
await expect(page).toHaveURL(/order=descend/);
await expect(page).not.toHaveURL(/order=ascend/);
for (const view of ['mine', 'pinned', 'recent', 'all', 'locked']) {
await page.getByTestId(`dashboards-view-${view}`).click();
await expect(page.getByTestId(`dashboards-view-${view}`)).toBeVisible();
// The list frame survives every view switch, empty or not.
await expect(page.getByTestId('dashboards-list-search')).toBeVisible();
}
});
// ─── Row actions (context menu) ──────────────────────────────────────────
test('TC-09 admin sees all five options in the action menu', async ({
test('TC-03 a newly created dashboard is listed', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-actions-menu';
await seed(page, name);
await seed(page, 'listed');
await gotoList(page);
await gotoDashboardsList(page);
const tooltip = await openDashboardActionMenu(page, name);
await expect(tooltip).toBeVisible();
await expect(tooltip.getByRole('button', { name: 'View' })).toBeVisible();
await expect(
tooltip.getByRole('button', { name: 'Open in New Tab' }),
).toBeVisible();
await expect(
tooltip.getByRole('button', { name: 'Copy Link' }),
).toBeVisible();
await expect(
tooltip.getByRole('button', { name: 'Export JSON' }),
).toBeVisible();
// Delete is rendered as a generic, not a button.
await expect(tooltip.getByText('Delete dashboard')).toBeVisible();
await expect(rowByTitle(page, title('listed'))).toBeVisible();
});
test('TC-10 view action navigates to the dashboard detail page', async ({
test('TC-04 opening a dashboard from the list lands on its detail page', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-action-view';
await seed(page, name);
const id = await seed(page, 'open');
await gotoList(page);
await gotoDashboardsList(page);
const tooltip = await openDashboardActionMenu(page, name);
await tooltip.getByRole('button', { name: 'View' }).click();
await rowByTitle(page, title('open')).click();
await expect(page).toHaveURL(new RegExp(`/dashboard/${id}`));
await expect(page.getByTestId('dashboard-title')).toContainText(
title('open'),
);
});
test('TC-05 search narrows the list to a matching dashboard', async ({
authedPage: page,
}) => {
await seed(page, 'searchable');
await seed(page, 'other');
await gotoList(page);
await search(page, title('searchable'));
await expect(rowByTitle(page, title('searchable'))).toBeVisible();
await expect(rowByTitle(page, title('other'))).toBeHidden();
});
test('TC-06 a search matching nothing leaves no rows of ours', async ({
authedPage: page,
}) => {
await seed(page, 'nomatch');
await gotoList(page);
await search(page, `${title('nomatch')}-absent`);
await expect(rowByTitle(page, title('nomatch'))).toBeHidden();
});
test('TC-07 pinning a dashboard puts it in the Pinned view', async ({
authedPage: page,
}) => {
await seed(page, 'pin');
await gotoList(page);
const row = rowByTitle(page, title('pin'));
await expect(row).toBeVisible();
// The pin control is indexed like the title it sits beside.
const index = await row.getAttribute('data-testid');
const pinIndex = (index ?? '').replace('dashboard-title-', '');
await page.getByTestId(`dashboard-pin-${pinIndex}`).click();
await page.getByTestId('dashboards-view-pinned').click();
await expect(rowByTitle(page, title('pin'))).toBeVisible();
});
test('TC-08 the create CTA opens the new-dashboard modal', async ({
authedPage: page,
}) => {
await gotoList(page);
await page.getByTestId('new-dashboard-cta').click();
for (const field of [
'create-dashboard-name',
'create-dashboard-description',
'create-dashboard-tags',
]) {
await expect(page.getByTestId(field)).toBeVisible();
}
await expect(page.getByTestId('create-dashboard-submit')).toBeVisible();
});
test('TC-09 creating a dashboard through the modal lands on it', async ({
authedPage: page,
}) => {
await gotoList(page);
await page.getByTestId('new-dashboard-cta').click();
const name = title('via-modal');
await page.getByTestId('create-dashboard-name').fill(name);
await page.getByTestId('create-dashboard-submit').click();
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
await expect(page.getByTestId('dashboard-title')).toContainText(name);
// Created through the UI, so register it for cleanup by id from the URL.
const created = page.url().split('/dashboard/')[1]?.split('?')[0] ?? '';
expect(created).not.toBe('');
seedIds.add(created);
});
test('TC-11 open in new tab opens the dashboard in a new browser tab', async ({
test('TC-10 a deleted dashboard leaves the list', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-action-newtab';
await seed(page, name);
const id = await seed(page, 'deleted');
await gotoList(page);
await expect(rowByTitle(page, title('deleted'))).toBeVisible();
await gotoDashboardsList(page);
const tooltip = await openDashboardActionMenu(page, name);
// Use page.context() — the auth fixture creates its own context per
// test, which is not the same as the default `context` fixture.
const [newPage] = await Promise.all([
page.context().waitForEvent('page'),
tooltip.getByRole('button', { name: 'Open in New Tab' }).click(),
]);
await newPage.waitForLoadState();
await expect(newPage).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
await newPage.close();
});
test('TC-12 copy link copies the dashboard URL to the clipboard', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-action-copy';
await seed(page, name);
await gotoDashboardsList(page);
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
const tooltip = await openDashboardActionMenu(page, name);
await tooltip.getByRole('button', { name: 'Copy Link' }).click();
await expect(page.getByText(/copied|success/i)).toBeVisible();
const clipboardText = await page.evaluate(async () =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).navigator.clipboard.readText(),
);
expect(clipboardText).toMatch(/\/dashboard\/[0-9a-f-]+/);
});
test('TC-13 export JSON downloads the dashboard as a JSON file', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-action-export';
await seed(page, name);
await gotoDashboardsList(page);
const tooltip = await openDashboardActionMenu(page, name);
const [download] = await Promise.all([
page.waitForEvent('download'),
tooltip.getByRole('button', { name: 'Export JSON' }).click(),
]);
expect(download.suggestedFilename()).toMatch(/\.json$/);
});
test('TC-14 action menu closes when clicking outside the popover', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-action-dismiss';
await seed(page, name);
await gotoDashboardsList(page);
await openDashboardActionMenu(page, name);
await expect(page.getByRole('tooltip')).toBeVisible();
await page.getByRole('heading', { name: 'Dashboards', level: 1 }).click();
await expect(page.getByRole('tooltip')).not.toBeVisible();
await expect(page).toHaveURL(/\/dashboard($|\?)/);
});
// ─── Creating dashboards via "New dashboard" dropdown ─────────────────────
//
// The "Enter dashboard name…" inline input on the list page is a
// `RequestDashboardBtn` (template-request feedback form), not a create
// flow. The only UI create path is the "New dashboard" dropdown.
test('TC-15 New dashboard dropdown shows exactly three options', async ({
authedPage: page,
}) => {
await gotoDashboardsList(page);
await page.getByTestId('new-dashboard-cta').click();
const menu = page.getByRole('menu');
await expect(menu).toBeVisible();
await expect(menu.getByTestId('create-dashboard-menu-cta')).toBeVisible();
await expect(menu.getByTestId('import-json-menu-cta')).toBeVisible();
await expect(menu.getByTestId('view-templates-menu-cta')).toBeVisible();
});
test('TC-16 Create dashboard dropdown option creates a dashboard with the default name', async ({
authedPage: page,
}) => {
await gotoDashboardsList(page);
await page.getByTestId('new-dashboard-cta').click();
await page.getByTestId('create-dashboard-menu-cta').click();
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
await expect(page.getByText('Configure your new dashboard')).toBeVisible();
// "Configure" appears twice on the new-dashboard onboarding state — once
// in the toolbar and once in the empty-state section. The test only
// needs to confirm the onboarding rendered, so .first() is sufficient.
await expect(
page.getByRole('button', { name: 'Configure' }).first(),
).toBeVisible();
await expect(
page.getByRole('button', { name: /New Panel/ }).first(),
).toBeVisible();
// Register the UI-created dashboard with the suite teardown. After a
// successful "Create dashboard" the row must exist — assert that and
// then unconditionally register, so the test contains no `if`.
const sampleId = await findDashboardIdByTitle(page, DEFAULT_DASHBOARD_TITLE);
expect(
sampleId,
`${DEFAULT_DASHBOARD_TITLE} not found after UI create`,
).toBeDefined();
seedIds.add(sampleId as string);
});
test('TC-17 Import JSON dialog opens with code editor and upload button', async ({
authedPage: page,
}) => {
await gotoDashboardsList(page);
await page.getByTestId('new-dashboard-cta').click();
await page.getByTestId('import-json-menu-cta').click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog.getByText('Import Dashboard JSON')).toBeVisible();
// "Upload JSON file" appears twice — once as the Ant Upload's hidden
// span wrapper, once as the visible button. .first() is enough to
// confirm the upload affordance rendered.
await expect(
dialog.getByRole('button', { name: 'Upload JSON file' }).first(),
).toBeVisible();
await expect(
dialog.getByRole('button', { name: 'Import and Next' }),
).toBeVisible();
});
test('TC-18 Import JSON dialog dismisses via Escape and via the close button', async ({
authedPage: page,
}) => {
await gotoDashboardsList(page);
// Escape path — Monaco grabs focus on mount and swallows Escape; click
// the modal title first to blur Monaco so Ant's Modal `keyboard`
// handler picks up the keystroke.
await page.getByTestId('new-dashboard-cta').click();
await page.getByTestId('import-json-menu-cta').click();
let dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByText('Import Dashboard JSON').click();
await page.keyboard.press('Escape');
await expect(dialog).not.toBeVisible();
await expect(page).toHaveURL(/\/dashboard($|\?)/);
// Close-button path — re-open and dismiss via the X.
await page.getByTestId('new-dashboard-cta').click();
await page.getByTestId('import-json-menu-cta').click();
dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: /close/i }).click();
await expect(dialog).not.toBeVisible();
await expect(page).toHaveURL(/\/dashboard($|\?)/);
});
// ─── Deleting dashboards ─────────────────────────────────────────────────
//
// Known behaviour: clicking Cancel in the confirmation dialog navigates to
// the dashboard detail page rather than staying on the list.
test('TC-19 delete confirmation dialog shows dashboard name with Cancel and Delete buttons', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-delete-confirm';
await seed(page, name);
await gotoDashboardsList(page);
const tooltip = await openDashboardActionMenu(page, name);
// Ant's Popover can position the tooltip so the "Delete dashboard"
// item ends up outside the viewport (especially in CI, where font
// rendering shifts layout subtly). `click({ force: true })` skips
// actionability checks but Playwright still requires the click
// coordinates to land inside the viewport. `dispatchEvent('click')`
// fires the synthetic event directly on the DOM node — React's
// onClick handler runs normally — and bypasses coordinate checks
// entirely. This is the robust fix for Ant Popover positioning.
await tooltip.getByText('Delete dashboard').dispatchEvent('click');
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog.getByRole('heading')).toContainText(
'Are you sure you want to delete the',
);
await expect(dialog.getByRole('heading')).toContainText(name);
await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeVisible();
await expect(dialog.getByRole('button', { name: 'Delete' })).toBeVisible();
});
test('TC-20 cancelling delete navigates to the dashboard detail page (known behaviour)', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-delete-cancel';
await seed(page, name);
await gotoDashboardsList(page);
const tooltip = await openDashboardActionMenu(page, name);
await tooltip.getByText('Delete dashboard').dispatchEvent('click');
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('button', { name: 'Cancel' }).click();
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
});
test('TC-21 confirming delete removes the dashboard from the list', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-delete-confirmed';
const id = await seed(page, name);
await gotoDashboardsList(page);
const tooltip = await openDashboardActionMenu(page, name);
await tooltip.getByText('Delete dashboard').dispatchEvent('click');
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
// The Delete mutation is async — wait for the API response *and* the
// dialog to dismiss before navigating away, otherwise React Query's
// in-flight mutation gets cancelled by the navigation.
const deleteResponse = page.waitForResponse(
(r) => r.request().method() === 'DELETE' && /\/dashboards\//.test(r.url()),
);
await dialog.getByRole('button', { name: 'Delete' }).click();
await deleteResponse;
await expect(dialog).not.toBeVisible();
// After deletion, searching for the name should return no results.
await gotoDashboardsList(page);
await page.getByPlaceholder(SEARCH_PLACEHOLDER).fill(name);
await expect(page.getByAltText('dashboard-image')).toHaveCount(0);
// The UI delete already removed the resource — drop it from the
// suite-cleanup set so afterAll doesn't 404 on it.
const token = await authToken(page);
await deleteDashboardV2ViaApi(page.request, id, token);
seedIds.delete(id);
});
// ─── Row click navigation ────────────────────────────────────────────────
test('TC-22 clicking a dashboard row navigates to the detail page', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-row-click';
await seed(page, name);
await gotoDashboardsList(page);
await page.getByPlaceholder(SEARCH_PLACEHOLDER).fill(name);
await page.getByAltText('dashboard-image').first().click();
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
});
test('TC-23 sidebar Dashboards link navigates to the list page', async ({
authedPage: page,
}) => {
await page.goto('/home');
// Sidebar items are <div class="nav-item"> with the label as visible
// text — they're not <a role="link">, so getByRole won't reach them.
// Filter on the exact label to avoid matching nested items that
// happen to contain the substring.
await page
.locator('.nav-item')
.filter({ hasText: /^Dashboards$/ })
.click();
await expect(page).toHaveURL(/\/dashboard/);
await expect(page).toHaveTitle('SigNoz | All Dashboards');
});
// ─── URL state and deep linking ──────────────────────────────────────────
test('TC-24 browser Back after navigating to a dashboard restores search state', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-back-search';
await seed(page, name);
await page.goto(`/dashboard?search=${name}`);
await page
.getByRole('heading', { name: 'Dashboards', level: 1 })
.waitFor({ state: 'visible' });
await page.getByAltText('dashboard-image').first().click();
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
await page.goBack();
await expect(page).toHaveURL(new RegExp(`search=${name}`));
await expect(page.getByPlaceholder(SEARCH_PLACEHOLDER)).toHaveValue(name);
});
test('TC-25 direct navigation with sort params honours them on load', async ({
authedPage: page,
}) => {
await page.goto('/dashboard?columnKey=updatedAt&order=descend');
await page
.getByRole('heading', { name: 'Dashboards', level: 1 })
.waitFor({ state: 'visible' });
await expect(page).toHaveURL(/columnKey=updatedAt/);
await expect(page).toHaveURL(/order=descend/);
await page.reload();
await expect(
page.getByRole('heading', { name: 'All dashboards' }),
).toBeVisible();
await expect(rowByTitle(page, title('deleted'))).toBeHidden();
});
});

View File

@@ -1,9 +1,9 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"module": "esnext",
"moduleResolution": "bundler",
"lib": ["ES2020"],
"lib": ["ES2020", "DOM"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,

View File

@@ -1,623 +0,0 @@
from collections.abc import Callable
from http import HTTPStatus
import requests
from wiremock.resources.mappings import Mapping
from fixtures.auth import (
USER_ADMIN_EMAIL,
USER_ADMIN_PASSWORD,
add_license,
change_user_role,
create_active_user,
find_user_by_email,
)
from fixtures.role import transaction_group
from fixtures.types import Operation, SigNoz, TestContainerDocker
V2_BASE_URL = "/api/v2/dashboards"
MAX_LIST_LIMIT = 200
_EDITOR_EMAIL = "editor+dashboardauthz@integration.test"
_EDITOR_PASSWORD = "password123Z$"
_VIEWER_EMAIL = "viewer+dashboardauthz@integration.test"
_VIEWER_PASSWORD = "password123Z$"
_ACTOR_ROLE_NAME = "dashboard-fga-actor"
_ACTOR_EMAIL = "customrole+dashboardauthz@integration.test"
_ACTOR_PASSWORD = "password123Z$"
# Instance verbs are granted on _TARGET_A only; _TARGET_B must stay forbidden.
_TARGET_A = "dashboard-authz-target-a"
_TARGET_B = "dashboard-authz-target-b"
_CLONE_SOURCE = "dashboard-authz-clone-source"
_VIEW_NAME = "dashboard-authz-view"
_SPEC = {
"display": {"name": "Dashboard Authz"},
"duration": "1h",
"links": [],
"variables": [],
"panels": {},
"layouts": [],
}
# ─── managed roles (community-safe: no license, role gate only) ───────────────
def test_setup_managed_role_users(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# A rerun against a --reuse stack starts from the previous run's state, and
# inviting an existing address fails, so only invite what is missing.
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
existing_emails = {user["email"] for user in response.json()["data"]}
for email, role, password, name in (
(_EDITOR_EMAIL, "EDITOR", _EDITOR_PASSWORD, "dashboard authz editor"),
(_VIEWER_EMAIL, "VIEWER", _VIEWER_PASSWORD, "dashboard authz viewer"),
):
if email not in existing_emails:
create_active_user(signoz, admin_token, email=email, role=role, password=password, name=name)
# (org_id, name) is unique, so leftovers from an earlier run have to go before
# these are recreated.
response = requests.get(
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}?limit={MAX_LIST_LIMIT}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
for dashboard in response.json()["data"]["dashboards"]:
if dashboard["name"] in (_TARGET_A, _TARGET_B, _CLONE_SOURCE):
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{dashboard['id']}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
for name in (_TARGET_A, _TARGET_B, _CLONE_SOURCE):
response = requests.post(
signoz.self.host_configs["8080"].get(V2_BASE_URL),
json={"schemaVersion": "v6", "name": name, "spec": _SPEC, "tags": []},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
def test_viewer_allowed_on_reads_and_pins(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(_VIEWER_EMAIL, _VIEWER_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}?limit={MAX_LIST_LIMIT}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
ids = {dashboard["name"]: dashboard["id"] for dashboard in response.json()["data"]["dashboards"]}
response = requests.get(
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{ids[_TARGET_A]}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/users/me/dashboards/{ids[_TARGET_A]}/pins"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/users/me/dashboards/{ids[_TARGET_A]}/pins"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
def test_viewer_forbidden_on_mutations(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
token = get_token(_VIEWER_EMAIL, _VIEWER_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}?limit={MAX_LIST_LIMIT}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
ids = {dashboard["name"]: dashboard["id"] for dashboard in response.json()["data"]["dashboards"]}
target_id = ids[_TARGET_A]
response = requests.post(
signoz.self.host_configs["8080"].get(V2_BASE_URL),
json={"schemaVersion": "v6", "name": "viewer-denied", "spec": _SPEC, "tags": []},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, f"create: expected 403, got {response.status_code}: {response.text}"
response = requests.put(
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{target_id}"),
json={"schemaVersion": "v6", "name": _TARGET_A, "spec": _SPEC, "tags": []},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, f"update: expected 403, got {response.status_code}: {response.text}"
response = requests.patch(
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{target_id}"),
json=[{"op": "replace", "path": "/spec/display/name", "value": "Patched"}],
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, f"patch: expected 403, got {response.status_code}: {response.text}"
response = requests.post(
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{target_id}/clone"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, f"clone: expected 403, got {response.status_code}: {response.text}"
response = requests.post(
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{target_id}/migrate"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, f"migrate: expected 403, got {response.status_code}: {response.text}"
response = requests.put(
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{target_id}/lock"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, f"lock: expected 403, got {response.status_code}: {response.text}"
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{target_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, f"delete: expected 403, got {response.status_code}: {response.text}"
def test_editor_allowed_on_mutations(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(_EDITOR_EMAIL, _EDITOR_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}?limit={MAX_LIST_LIMIT}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
ids = {dashboard["name"]: dashboard["id"] for dashboard in response.json()["data"]["dashboards"]}
response = requests.put(
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{ids[_TARGET_A]}"),
json={"schemaVersion": "v6", "name": _TARGET_A, "spec": _SPEC, "tags": [{"key": "team", "value": "pulse"}]},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
response = requests.post(
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{ids[_CLONE_SOURCE]}/clone"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
clone_id = response.json()["data"]["id"]
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{clone_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
def test_viewer_forbidden_on_public_config(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
# The public config routes authorize on the dashboard itself, so a viewer is
# refused before the handler runs, licensed or not.
token = get_token(_VIEWER_EMAIL, _VIEWER_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}?limit={MAX_LIST_LIMIT}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
target_id = {dashboard["name"]: dashboard["id"] for dashboard in response.json()["data"]["dashboards"]}[_TARGET_A]
response = requests.post(
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{target_id}/public"),
json={"timeRangeEnabled": True, "defaultTimeRange": "10m"},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, f"create public: expected 403, got {response.status_code}: {response.text}"
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{target_id}/public"),
json={"timeRangeEnabled": False, "defaultTimeRange": "10m"},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, f"update public: expected 403, got {response.status_code}: {response.text}"
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{target_id}/public"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, f"delete public: expected 403, got {response.status_code}: {response.text}"
def test_viewer_allowed_on_saved_views(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
# Saved views ride on dashboard list, which a viewer holds, so the whole CRUD
# surface is open to them.
token = get_token(_VIEWER_EMAIL, _VIEWER_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/dashboard_views"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/dashboard_views"),
json={"name": _VIEW_NAME, "data": {"version": "v1", "sort": "updated_at", "order": "desc"}},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
view_id = response.json()["data"]["id"]
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/dashboard_views/{view_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
# ─── per-object FGA scoping (enterprise, custom role) ─────────────────────────
def test_apply_license(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
make_http_mocks: Callable[[TestContainerDocker, list[Mapping]], None],
get_token: Callable[[str, str], str],
) -> None:
add_license(signoz, make_http_mocks, get_token)
def test_editor_allowed_on_public_config(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
# An editor holds dashboard update, which is what the public config routes ask
# for, so publishing is open to editors and not just admins.
token = get_token(_EDITOR_EMAIL, _EDITOR_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}?limit={MAX_LIST_LIMIT}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
target_id = {dashboard["name"]: dashboard["id"] for dashboard in response.json()["data"]["dashboards"]}[_TARGET_B]
response = requests.post(
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{target_id}/public"),
json={"timeRangeEnabled": True, "defaultTimeRange": "10m"},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{target_id}/public"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
# Reading the config asks only for dashboard read, so a viewer sees it too.
viewer_token = get_token(_VIEWER_EMAIL, _VIEWER_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{target_id}/public"),
headers={"Authorization": f"Bearer {viewer_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{target_id}/public"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
def test_setup_scoped_actor(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_role: Callable[..., str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}?limit={MAX_LIST_LIMIT}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
ids = {dashboard["name"]: dashboard["id"] for dashboard in response.json()["data"]["dashboards"]}
create_role(
admin_token,
_ACTOR_ROLE_NAME,
[
transaction_group("read", "metaresource", "dashboard", [ids[_TARGET_A]]),
transaction_group("list", "metaresource", "dashboard", ["*"]),
],
)
user_id = create_active_user(signoz, admin_token, email=_ACTOR_EMAIL, role="VIEWER", password=_ACTOR_PASSWORD, name="dashboard fga actor")
change_user_role(signoz, admin_token, user_id, "signoz-viewer", _ACTOR_ROLE_NAME)
def test_read_scoped_to_granted_dashboard(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}?limit={MAX_LIST_LIMIT}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
ids = {dashboard["name"]: dashboard["id"] for dashboard in response.json()["data"]["dashboards"]}
response = requests.get(
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{ids[_TARGET_A]}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, f"read granted dashboard: {response.text}"
response = requests.get(
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{ids[_TARGET_B]}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, f"read other dashboard: expected 403, got {response.status_code}: {response.text}"
def test_list_returns_every_dashboard(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
# list is collection-scoped: list on "*" returns every dashboard, including
# the one the actor cannot read individually.
response = requests.get(
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}?limit={MAX_LIST_LIMIT}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
names = {dashboard["name"] for dashboard in response.json()["data"]["dashboards"]}
assert {_TARGET_A, _TARGET_B} <= names
def test_publish_and_unpublish_require_update_on_the_dashboard(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
actor_role_id = find_role_id(admin_token, _ACTOR_ROLE_NAME)
response = requests.get(
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}?limit={MAX_LIST_LIMIT}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
target_id = {dashboard["name"]: dashboard["id"] for dashboard in response.json()["data"]["dashboards"]}[_TARGET_A]
# read and list alone must not be enough to publish.
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{actor_role_id}"),
json={
"description": "",
"transactionGroups": [
transaction_group("read", "metaresource", "dashboard", [target_id]),
transaction_group("list", "metaresource", "dashboard", ["*"]),
],
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{target_id}/public"),
json={"timeRangeEnabled": True, "defaultTimeRange": "10m"},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, f"publish without dashboard update: expected 403, got {response.status_code}: {response.text}"
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{actor_role_id}"),
json={
"description": "",
"transactionGroups": [
transaction_group("read", "metaresource", "dashboard", [target_id]),
transaction_group("list", "metaresource", "dashboard", ["*"]),
transaction_group("update", "metaresource", "dashboard", [target_id]),
],
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.post(
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{target_id}/public"),
json={"timeRangeEnabled": True, "defaultTimeRange": "10m"},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, f"publish with dashboard update: {response.text}"
# Unpublishing asks for the same update grant.
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{actor_role_id}"),
json={
"description": "",
"transactionGroups": [
transaction_group("read", "metaresource", "dashboard", [target_id]),
transaction_group("list", "metaresource", "dashboard", ["*"]),
],
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{target_id}/public"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, f"unpublish without dashboard update: expected 403, got {response.status_code}: {response.text}"
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{actor_role_id}"),
json={
"description": "",
"transactionGroups": [
transaction_group("read", "metaresource", "dashboard", [target_id]),
transaction_group("list", "metaresource", "dashboard", ["*"]),
transaction_group("update", "metaresource", "dashboard", [target_id]),
],
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{target_id}/public"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, f"unpublish with dashboard update: {response.text}"
def test_dashboard_authz_cleanup(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
actor = find_user_by_email(signoz, admin_token, _ACTOR_EMAIL)
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{actor['id']}/roles"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
actor_entry = next((role for role in response.json()["data"] if role["name"] == _ACTOR_ROLE_NAME), None)
if actor_entry is not None:
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{actor['id']}/roles/{actor_entry['id']}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, f"remove role from user: {response.text}"
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{find_role_id(admin_token, _ACTOR_ROLE_NAME)}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, f"delete {_ACTOR_ROLE_NAME}: {response.text}"
response = requests.get(
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}?limit={MAX_LIST_LIMIT}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
ids = {dashboard["name"]: dashboard["id"] for dashboard in response.json()["data"]["dashboards"]}
for name in (_TARGET_A, _TARGET_B, _CLONE_SOURCE):
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{ids[name]}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, f"delete {name}: {response.text}"