Compare commits

..

9 Commits

Author SHA1 Message Date
nikhilmantri0902
7774544809 test(alert-channel-integrations): recreate stale notification_channel cache lacking https:8443 2026-08-06 15:55:56 +05:30
nikhilmantri0902
2f48fc8ef8 test(alert-channel-integrations): drop unused 8443 host mapping for google chat wiremock 2026-08-06 15:37:36 +05:30
nikhilmantri0902
946058210b test(alert-channel-integrations): ruff format google chat test 2026-08-06 15:04:46 +05:30
Nikhil Mantri
113943771a Merge branch 'main' into feat/google_chat_alert_integration_tests 2026-08-06 15:02:23 +05:30
nikhilmantri0902
3c517e5bde chore: added alert integration tests for google chat 2026-08-06 14:46:09 +05:30
Naman Verma
e42e42ee71 feat: enable FGA for dashboards and their public config (#12408)
* feat: enable FGA for dashboards and their public config

* test: add integration test for dashboard FGA

* fix: fix permissions for public dashboards, pinning, views

* fix: allow viewers to manage views

* fix: remove edits to the public dashboard line
2026-08-06 07:19:21 +00:00
dependabot[bot]
b4e1af261b chore(deps): bump google.golang.org/grpc from 1.80.0 to 1.82.1 (#12222)
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.80.0 to 1.82.1.
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.80.0...v1.82.1)

---
updated-dependencies:
- dependency-name: google.golang.org/grpc
  dependency-version: 1.82.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 07:17:06 +00:00
dependabot[bot]
0de7f666e4 chore(deps): bump github.com/google/cel-go from 0.28.0 to 0.29.0 (#12305)
Bumps [github.com/google/cel-go](https://github.com/google/cel-go) from 0.28.0 to 0.29.0.
- [Release notes](https://github.com/google/cel-go/releases)
- [Commits](https://github.com/google/cel-go/compare/v0.28.0...v0.29.0)

---
updated-dependencies:
- dependency-name: github.com/google/cel-go
  dependency-version: 0.29.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 07:16:55 +00:00
Vinicius Lourenço
20606e274b feat(infrastructure-monitoring-details): add support to pin tooltip (#12414)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-08-06 02:25:45 +00:00
30 changed files with 2461 additions and 1439 deletions

View File

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

View File

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

View File

@@ -30,6 +30,7 @@ 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

@@ -40,6 +40,9 @@ 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%)' },
@@ -47,6 +50,7 @@ const DRAWER_MOTION_PROPS = {
opacity: 1,
transform: 'translateX(0%)',
transition: DRAWER_TRANSITION,
transitionEnd: { transform: 'none' },
},
exit: {
opacity: 0,

View File

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

@@ -0,0 +1,82 @@
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,11 +1,14 @@
import { useCallback, useMemo, useRef } from 'react';
import { useCallback, useMemo, useRef, useState } 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 { LegendPosition } from 'lib/uPlotV2/components/types';
import {
IRenderTooltipFooterArgs,
LegendPosition,
} from 'lib/uPlotV2/components/types';
import {
InfraMonitoringEntity,
VIEW_TYPES,
@@ -33,6 +36,7 @@ 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;
@@ -78,6 +82,10 @@ function EntityMetrics<T>({
category,
});
const [tooltipPortalEl, setTooltipPortalEl] = useState<HTMLDivElement | null>(
null,
);
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
const graphRef = useRef<HTMLDivElement>(null);
@@ -171,6 +179,18 @@ 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}
/>
)}
/>
)
)}
@@ -180,6 +200,8 @@ function EntityMetrics<T>({
return (
<>
<div ref={setTooltipPortalEl} style={{ display: 'contents' }} />
<div className={styles.metricsHeader}>
<EntityDateTimeSelector
eventEntity={eventEntity}

View File

@@ -51,6 +51,7 @@ 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);
@@ -59,7 +60,9 @@ export default function TooltipPlugin({
const renderRef = useRef(render);
renderRef.current = render;
const [portalRoot, setPortalRoot] = useState<HTMLElement>(
(document.fullscreenElement as HTMLElement) ?? document.body,
portalRootProp ??
(document.fullscreenElement as HTMLElement) ??
document.body,
);
// React-managed snapshot of what should be rendered. The controller
@@ -464,8 +467,12 @@ export default function TooltipPlugin({
}, [config]);
const resolvePortalRoot = useCallback((): void => {
setPortalRoot((document.fullscreenElement as HTMLElement) ?? document.body);
}, []);
setPortalRoot(
portalRootProp ??
(document.fullscreenElement as HTMLElement) ??
document.body,
);
}, [portalRootProp]);
useLayoutEffect((): (() => void) => {
resolvePortalRoot();

View File

@@ -67,6 +67,7 @@ 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.28.0 // indirect
github.com/google/cel-go v0.29.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-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d
google.golang.org/grpc v1.80.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
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-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/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/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.28.0 h1:KjSWstCpz/MN5t4a8gnGJNIYUsJRpdi/r97xWDphIQc=
github.com/google/cel-go v0.28.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8=
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/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-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/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/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.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
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/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,371 +14,592 @@ import (
)
func (provider *provider) addDashboardRoutes(router *mux.Router) error {
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 {
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 {
return err
}
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 {
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 {
return err
}
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 {
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 {
return err
}
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 {
// 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 {
return err
}
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 {
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 {
return err
}
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 {
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 {
return err
}
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 {
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 {
return err
}
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 {
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 {
return err
}
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 {
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 {
return err
}
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 {
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 {
return err
}
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 {
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 {
return err
}
// 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 {
// 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 {
return err
}
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 {
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 {
return err
}
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 {
// 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 {
return err
}
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 {
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 {
return err
}
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 {
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 {
return err
}
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 {
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 {
return err
}
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 {
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 {
return err
}
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 {
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 {
return err
}
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 {
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 {
return err
}
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 {
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 {
return err
}

View File

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

View File

@@ -0,0 +1,149 @@
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)
ResourceMetaResourceDashboard = NewResourceMetaResource(KindDashboard, VerbCreate, VerbList, VerbRead, VerbUpdate, VerbDelete)
ResourceMetaResourcePublicDashboard = NewResourceMetaResource(KindPublicDashboard)
ResourceMetaResourceIngestionKey = NewResourceMetaResource(KindIngestionKey)
ResourceMetaResourceIngestionLimit = NewResourceMetaResource(KindIngestionLimit)

View File

@@ -1,311 +0,0 @@
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 ──────────────────────────────────────────────────
// A section's id is derived from its first panel's key, e.g. panel `p-timeseries` gives
// section `sec-p-timeseries` — stable for a seeded fixture, since the keys are ours.
export const sectionId = (firstPanelKey: string): string =>
`sec-${firstPanelKey}`;
export const section = (page: Page, firstPanelKey: string): Locator =>
page.getByTestId(`dashboard-section-${sectionId(firstPanelKey)}`);
export const sectionToggle = (page: Page, firstPanelKey: string): Locator =>
page.getByTestId(`dashboard-section-toggle-${sectionId(firstPanelKey)}`);
export const panelActions = (page: Page, panelKey: string): Locator =>
page.getByTestId(`panel-actions-${panelKey}`);
/** A panel by its display name — panels carry no per-panel testid on the card itself. */
export const panelByTitle = (page: Page, title: string): Locator =>
page.getByText(title, { exact: true });
/** 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,7 +21,6 @@
"fmt:check": "oxfmt --check .",
"lint": "oxlint .",
"lint:fix": "oxlint . --fix",
"guard:specs": "node scripts/guard-specs.mjs",
"typecheck": "tsc --noEmit"
},
"keywords": [

View File

@@ -1,20 +0,0 @@
{
"$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/list.spec.ts",
"**/tests/dashboards/details/03-viewing.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,23 +2,22 @@ import { defineConfig, devices } from '@playwright/test';
import dotenv from 'dotenv';
import path from 'path';
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') });
// .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.
dotenv.config({ path: path.resolve(__dirname, '.env') });
dotenv.config({ path: path.resolve(__dirname, '.env.local'), override: true });
export default defineConfig({
testDir: './tests',
// 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,
// 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/**'],
// All Playwright output lands under artifacts/. One subdir per reporter
// plus results/ for per-test artifacts (traces/screenshots/videos).

View File

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

@@ -1,363 +0,0 @@
{
"$comment": "Three grid sections with two panels each, trimmed from a real V2 dashboard so the structure (sections, panels, layout refs, collapse state, plugin kinds) is faithful without depending on any particular telemetry. Panel queries target signals this stack may hold nothing for — that is fine: these specs assert on structure and chrome, never on chart values. Variables are text + custom, whose options come from the definition.",
"spec": {
"display": { "name": "sections-v2", "description": "" },
"variables": [
{
"kind": "TextVariable",
"spec": {
"name": "textbox.environment",
"display": { "name": "textbox.environment", "description": "" },
"value": "prod",
"constant": false
}
},
{
"kind": "ListVariable",
"spec": {
"name": "custom.service.name",
"display": { "name": "custom.service.name", "description": "" },
"allowMultiple": true,
"allowAllValue": true,
"sort": "alphabetical-asc",
"plugin": {
"kind": "signoz/CustomVariable",
"spec": { "customValue": "checkout,payments,cart" }
}
}
}
],
"panels": {
"p-timeseries": {
"kind": "Panel",
"spec": {
"display": { "name": "Requests over time", "description": "" },
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {
"visualization": { "timePreference": "global_time", "fillSpans": false },
"formatting": { "unit": "none", "decimalPrecision": "2" },
"legend": { "position": "bottom", "mode": "list", "customColors": null }
}
},
"queries": [
{
"kind": "time_series",
"spec": {
"name": "A",
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "logs",
"source": "",
"aggregations": [{ "expression": "count()" }],
"disabled": false,
"filter": { "expression": "service.name IN $custom.service.name" },
"groupBy": [
{
"name": "service.name",
"signal": "",
"fieldContext": "resource",
"fieldDataType": "string"
}
],
"order": [],
"having": { "expression": "" },
"functions": [],
"legend": ""
}
}
}
}
],
"links": []
}
},
"p-table": {
"kind": "Panel",
"spec": {
"display": { "name": "Requests by pod", "description": "" },
"plugin": {
"kind": "signoz/TablePanel",
"spec": {
"visualization": { "timePreference": "global_time" },
"formatting": { "columnUnits": { "A": "" }, "decimalPrecision": "2" },
"thresholds": null
}
},
"queries": [
{
"kind": "scalar",
"spec": {
"name": "A",
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "logs",
"source": "",
"aggregations": [{ "expression": "count()" }],
"disabled": false,
"filter": { "expression": "service.name IN $custom.service.name" },
"groupBy": [
{
"name": "k8s.pod.name",
"signal": "",
"fieldContext": "resource",
"fieldDataType": "string"
}
],
"order": [],
"having": { "expression": "" },
"functions": [],
"legend": ""
}
}
}
}
],
"links": []
}
},
"p-promql": {
"kind": "Panel",
"spec": {
"display": { "name": "Duration rate (PromQL)", "description": "" },
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {
"visualization": { "timePreference": "global_time", "fillSpans": false },
"formatting": { "unit": "none", "decimalPrecision": "2" },
"legend": { "position": "bottom", "mode": "list", "customColors": null }
}
},
"queries": [
{
"kind": "time_series",
"spec": {
"name": "A",
"plugin": {
"kind": "signoz/PromQLQuery",
"spec": {
"name": "A",
"query": "sum by (\"service.name\") (rate({\"http.server.duration.count\"}[5m]))",
"disabled": false,
"step": 0,
"stats": false,
"legend": ""
}
}
}
}
],
"links": []
}
},
"p-number": {
"kind": "Panel",
"spec": {
"display": { "name": "Total requests", "description": "" },
"plugin": {
"kind": "signoz/NumberPanel",
"spec": {
"visualization": { "timePreference": "global_time" },
"formatting": { "unit": "none", "decimalPrecision": "2" },
"thresholds": null
}
},
"queries": [
{
"kind": "scalar",
"spec": {
"name": "A",
"plugin": {
"kind": "signoz/PromQLQuery",
"spec": {
"name": "A",
"query": "sum(rate({\"http.server.duration.count\"}[5m]))",
"disabled": false,
"step": 0,
"stats": false,
"legend": ""
}
}
}
}
],
"links": []
}
},
"p-pie": {
"kind": "Panel",
"spec": {
"display": { "name": "Split by environment", "description": "" },
"plugin": {
"kind": "signoz/PieChartPanel",
"spec": {
"visualization": { "timePreference": "global_time" },
"formatting": { "unit": "none", "decimalPrecision": "2" },
"legend": { "position": "bottom", "mode": "list", "customColors": null }
}
},
"queries": [
{
"kind": "scalar",
"spec": {
"name": "A",
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "logs",
"source": "",
"aggregations": [{ "expression": "count()" }],
"disabled": false,
"filter": {
"expression": "deployment.environment = $textbox.environment"
},
"groupBy": [
{
"name": "service.name",
"signal": "",
"fieldContext": "resource",
"fieldDataType": "string"
}
],
"order": [],
"having": { "expression": "" },
"functions": [],
"legend": ""
}
}
}
}
],
"links": []
}
},
"p-bar": {
"kind": "Panel",
"spec": {
"display": { "name": "Stacked by pod", "description": "" },
"plugin": {
"kind": "signoz/BarChartPanel",
"spec": {
"visualization": {
"timePreference": "global_time",
"fillSpans": false,
"stackedBarChart": true
},
"formatting": { "unit": "none", "decimalPrecision": "2" },
"legend": { "position": "bottom", "mode": "list", "customColors": null },
"thresholds": null
}
},
"queries": [
{
"kind": "time_series",
"spec": {
"name": "A",
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "logs",
"source": "",
"aggregations": [{ "expression": "count()" }],
"disabled": false,
"filter": { "expression": "" },
"groupBy": [
{
"name": "k8s.pod.name",
"signal": "",
"fieldContext": "resource",
"fieldDataType": "string"
}
],
"order": [],
"having": { "expression": "" },
"functions": [],
"legend": ""
}
}
}
}
],
"links": []
}
}
},
"layouts": [
{
"kind": "Grid",
"spec": {
"display": { "title": "Query Builder", "collapse": { "open": true } },
"items": [
{
"x": 0,
"y": 0,
"width": 6,
"height": 6,
"content": { "$ref": "#/spec/panels/p-timeseries" }
},
{
"x": 6,
"y": 0,
"width": 6,
"height": 6,
"content": { "$ref": "#/spec/panels/p-table" }
}
]
}
},
{
"kind": "Grid",
"spec": {
"display": { "title": "PromQL", "collapse": { "open": true } },
"items": [
{
"x": 0,
"y": 0,
"width": 6,
"height": 6,
"content": { "$ref": "#/spec/panels/p-promql" }
},
{
"x": 6,
"y": 0,
"width": 6,
"height": 6,
"content": { "$ref": "#/spec/panels/p-number" }
}
]
}
},
{
"kind": "Grid",
"spec": {
"display": { "title": "Mixed", "collapse": { "open": true } },
"items": [
{
"x": 0,
"y": 0,
"width": 6,
"height": 6,
"content": { "$ref": "#/spec/panels/p-pie" }
},
{
"x": 6,
"y": 0,
"width": 6,
"height": 6,
"content": { "$ref": "#/spec/panels/p-bar" }
}
]
}
}
],
"duration": "",
"refreshInterval": "",
"links": []
}
}

View File

@@ -1,48 +0,0 @@
{
"$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

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

@@ -2,156 +2,538 @@ import type { Page } from '@playwright/test';
import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import { authToken } from '../../../helpers/dashboards';
import {
createDashboardV2ViaApi,
dashboardV2Path,
deleteDashboardV2ViaApi,
panelActions,
panelByTitle,
section,
sectionToggle,
variablesBar,
WIDE_VIEWPORT,
} from '../../../helpers/dashboards-v2';
import sectionsFixture from '../../../testdata/sections-dashboard-v2.json';
authToken,
createApmMetricsDashboardViaApi,
deleteDashboardViaApi,
} from '../../../helpers/dashboards';
// Sections and the panels inside them, seeded from a spec trimmed out of a real V2
// dashboard: three grid sections, six panels across six plugin kinds. Assertions are
// structural — titles, membership, collapse — never chart values, so nothing here
// depends on the stack holding telemetry.
// ─── Per-test seed lifecycle ────────────────────────────────────────────
//
// Each test gets its own freshly-seeded APM Metrics dashboard (4 sections,
// 16 panels — including the duplicate-named "Overview" sections, which the
// fixture intentionally ships). Per-test seeding eliminates the "previous
// test left the dashboard in a collapsed/renamed state" class of CI flakes
// that bit us repeatedly with `beforeAll`-shared seed: it is no longer
// possible for one test's restore PUT to race the next test's GET, because
// the next test does not see the previous test's dashboard at all.
//
// `serial` mode is no longer required for correctness (tests are hermetic)
// but we keep parallel runs intra-file because seed creation is the
// per-test cost — running them concurrently inside the worker would just
// pile up more concurrent dashboards without helping.
let apmDashboardId: string;
test.use({ viewport: WIDE_VIEWPORT });
test.beforeEach(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
apmDashboardId = await createApmMetricsDashboardViaApi(page);
} finally {
await ctx.close();
}
});
const seedIds = new Set<string>();
const SECTIONS = [
{ title: 'Query Builder', firstPanel: 'p-timeseries' },
{ title: 'PromQL', firstPanel: 'p-promql' },
{ title: 'Mixed', firstPanel: 'p-pie' },
];
const PANEL_TITLES = [
'Requests over time',
'Requests by pod',
'Duration rate (PromQL)',
'Total requests',
'Split by environment',
'Stacked by pod',
];
async function seedAndOpen(page: Page, label: string): Promise<string> {
const id = await createDashboardV2ViaApi(
page,
`detail-sections-${label}-${process.env.TEST_WORKER_INDEX ?? '0'}`,
sectionsFixture.spec,
);
seedIds.add(id);
await page.goto(dashboardV2Path(id));
await expect(variablesBar(page)).toBeVisible();
return id;
}
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) {
test.afterEach(async ({ browser }) => {
if (!apmDashboardId) {
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);
}
await deleteDashboardViaApi(ctx.request, apmDashboardId, token);
} catch {
// Best-effort cleanup — a failing delete should not mask test
// failures the user actually needs to see.
} finally {
apmDashboardId = '';
await ctx.close();
}
});
test.describe('Dashboard detail — sections and panels', () => {
test('TC-01 every section in the spec renders with its title', async ({
authedPage: page,
}) => {
await seedAndOpen(page, 'render');
/**
* Resolve the `.row-panel` container for a section by traversing up from its
* title text. The fixture ships two sections both literally named "Overview"
* — pass `index` to disambiguate. Two `..` hops reach `.row-panel`, which
* holds both the chevron and the settings-icon for that row.
*/
function sectionRow(
page: Page,
name: string | RegExp,
index = 0,
): ReturnType<Page['locator']> {
return page
.getByText(name, { exact: typeof name === 'string' })
.nth(index)
.locator('..')
.locator('..');
}
for (const { title, firstPanel } of SECTIONS) {
await expect(section(page, firstPanel)).toBeVisible();
await expect(section(page, firstPanel)).toContainText(title);
async function gotoApmDashboard(page: Page): Promise<void> {
await page.goto(`/dashboard/${apmDashboardId}`);
await page
.getByRole('button', { name: /dashboard-icon APM Metrics/ })
.waitFor({ state: 'visible' });
// `GridCardLayout`'s auto-save `useEffect` (line 226 of the source) is
// gated on `!isDashboardFetching` but `isDashboardFetching` is NOT in the
// effect's dep array. Concretely: if a chevron is clicked while any
// `[REACT_QUERY_KEY.DASHBOARD_BY_ID]` query is in flight, the effect runs
// once for the new `dashboardLayout`, sees `isDashboardFetching=true`, and
// returns early — and never re-runs when the GET later completes, because
// `dashboardLayout` didn't change again. The PUT is *never* fired and
// `toggleSectionAndWaitForPut` blocks until the 30 s test timeout.
//
// Wait until the in-flight dashboard GETs settle so the effect's gate
// evaluates to `false` on the next click. We assert this two ways: a panel
// from each visible section must render (proves data is hydrated), and
// `Latency` (the first panel of the first Overview section) must paint.
await expect(page.getByText('Latency', { exact: true }).first()).toBeVisible({
timeout: 20_000,
});
}
/**
* Click `.row-icon` (chevron) on a section row. The collapse/expand state is
* driven by React local state — `setDashboardLayout` updates synchronously
* and the (suffixed / unsuffixed) title appears on the next render. We do
* NOT wait for the auto-save PUT here: it's gated on `!isDashboardFetching`
* in `GridCardLayout.tsx` and can be skipped entirely under CI load.
* Persistence does not matter because each test seeds a fresh dashboard.
*
* `dispatchEvent('click')` — under CI viewport the expanded sidenav's
* `nav-item-data` subtree intercepts pointer events at the chevron's
* position (verified in CI run #26162502354). `.click({ force: true })`
* still lands the event at the visual centre and is swallowed by the
* overlay; dispatching the click directly on the SVG node bypasses hit
* testing entirely and triggers React's `onClick` handler.
*/
async function toggleSection(row: ReturnType<Page['locator']>): Promise<void> {
const chevron = row.locator('.row-icon');
await chevron.scrollIntoViewIfNeeded();
await expect(chevron).toBeVisible();
const page = chevron.page();
// Register a PUT listener BEFORE the click. The auto-save effect in
// `GridCardLayout` fires a PUT when `!isDashboardFetching` — if the PUT
// arrives, its `onSuccess` triggers a brief loading-state re-render that
// unmounts every `.row-panel`. The next toggle's chevron lookup either
// misses (locator times out) or grabs a transient node that detaches
// during scroll. Sequencing: dispatch click → await PUT (3 s short
// timeout in case auto-save was gated) → wait for the loading spinner
// to be absent.
const putSettled = page
.waitForResponse(
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
{ timeout: 3_000 },
)
.catch(() => null);
await chevron.dispatchEvent('click');
await putSettled;
await expect(page.getByAltText('loading')).toHaveCount(0, {
timeout: 20_000,
});
}
// Poll a section to the target collapsed/expanded state, re-clicking if a
// toggle is dropped under CI load. `name` has no regex metacharacters.
async function setSectionCollapsed(
page: Page,
name: string,
collapsed: boolean,
): Promise<void> {
const collapsedTitle = new RegExp(`^${name} \\(\\d+ widgets?\\)$`);
await expect(async () => {
const alreadyCollapsed = (await page.getByText(collapsedTitle).count()) > 0;
if (alreadyCollapsed === collapsed) {
return;
}
});
await toggleSection(
sectionRow(page, alreadyCollapsed ? collapsedTitle : name),
);
expect((await page.getByText(collapsedTitle).count()) > 0).toBe(collapsed);
}).toPass({ timeout: 30_000 });
}
test('TC-02 every panel in the spec renders with its title', async ({
/**
* Click the settings (⋮) icon on a section header, bypassing the sidenav's
* pointer-event interception via `dispatchEvent('click')` (same root cause
* as `toggleSectionAndWaitForPut`). The settings popover (Rename / New Panel
* / Remove Section) lives on the LEFT of the row at the same x-coordinate
* as the chevron, so it suffers the same overlap.
*/
async function clickSectionSettings(
row: ReturnType<Page['locator']>,
): Promise<void> {
const icon = row.locator('.settings-icon');
await icon.scrollIntoViewIfNeeded();
await expect(icon).toBeVisible();
await icon.dispatchEvent('click');
}
test.describe('Dashboard Detail — Sections', () => {
// ─── Collapse / expand chevron and widget-count suffix ───────────────────
// TODO(e2e): re-enable once CI consistently passes. Passes locally
// (including `STRESS=1 CI=1`) but flakes on GitHub Linux runner — the
// chevron click intermittently fails to land its auto-save PUT despite
// `dispatchEvent('click')` + `Latency` panel hydration gate. Suspect
// remaining race lives in `GridCardLayout`'s auto-save `useEffect` not
// listing `isDashboardFetching` in its deps. See CI-HARDENING.md item 5.
test.skip('TC-01 collapsing a section hides panels and shows widget count', async ({
authedPage: page,
}) => {
await seedAndOpen(page, 'panels');
await gotoApmDashboard(page);
for (const title of PANEL_TITLES) {
await expect(panelByTitle(page, title).first()).toBeVisible();
}
// "DB Metrics" is the third section in the APM fixture and lives below
// the fold on the 1280×720 CI viewport. Scroll its title into view and
// wait for visibility so the 14×14 chevron is actionable.
const dbMetricsTitle = page.getByText('DB Metrics', { exact: true }).first();
await dbMetricsTitle.scrollIntoViewIfNeeded();
await expect(dbMetricsTitle).toBeVisible();
await toggleSection(sectionRow(page, 'DB Metrics'));
// After collapse the section title is rewritten to include the count
// suffix; assert with a regex so the test is robust to widget-count
// drift in the fixture.
await expect(
page.getByText(/^DB Metrics \(\d+ widgets?\)$/).first(),
).toBeVisible();
// Restore: chevron-down is the row-icon variant rendered for collapsed
// sections. Re-resolve via the new (suffixed) title.
await toggleSection(sectionRow(page, /^DB Metrics \(\d+ widgets?\)$/));
await expect(page.getByText(/^DB Metrics \(\d+ widgets?\)$/)).toHaveCount(0);
});
test('TC-03 a panel belongs to the section that references it', async ({
test('TC-02 widget count matches number of panels visible before collapse', async ({
authedPage: page,
}) => {
await seedAndOpen(page, 'membership');
await gotoApmDashboard(page);
// The layout puts these two in "PromQL" and nothing else there.
const promql = section(page, 'p-promql');
await expect(promql).toContainText('Duration rate (PromQL)');
await expect(promql).toContainText('Total requests');
await expect(promql).not.toContainText('Requests over time');
// The first Overview section in the APM fixture holds these four
// panels — they're our ground truth for the count assertion below.
await expect(
page.getByText('Latency', { exact: true }).first(),
).toBeVisible();
await expect(
page.getByText('Request rate', { exact: true }).first(),
).toBeVisible();
await expect(
page.getByText('Error percentage', { exact: true }).first(),
).toBeVisible();
await expect(
page.getByText('Top operations', { exact: true }).first(),
).toBeVisible();
await toggleSection(sectionRow(page, 'Overview', 0));
await expect(
page.getByText('Overview (4 widgets)', { exact: true }).first(),
).toBeVisible();
// Restore.
await toggleSection(sectionRow(page, 'Overview (4 widgets)'));
await expect(
page.getByText('Overview (4 widgets)', { exact: true }),
).toHaveCount(0);
});
test('TC-04 collapsing a section hides the panels inside it', async ({
authedPage: page,
}) => {
await seedAndOpen(page, 'collapse');
await expect(panelByTitle(page, 'Requests over time').first()).toBeVisible();
test('TC-03 expanding restores panels', async ({ authedPage: page }) => {
await gotoApmDashboard(page);
await sectionToggle(page, 'p-timeseries').click();
// Collapse "DB Metrics" instead of the first Overview — its widgets
// have unique titles ("DB Calls RPS" / "Database Calls Avg Duration")
// so collapse/expand transitions can be asserted without colliding
// with the duplicate-titled panels in the two Overview sections.
// "DB Metrics" lives further down the canvas; scroll into view first
// so the panels actually mount (the canvas virtualises off-screen).
const dbCalls = page.getByText('DB Calls RPS', { exact: true }).first();
await dbCalls.scrollIntoViewIfNeeded();
await expect(dbCalls).toBeVisible({ timeout: 15_000 });
await toggleSection(sectionRow(page, 'DB Metrics'));
await expect(
page.getByText(/^DB Metrics \(\d+ widgets?\)$/).first(),
).toBeVisible();
await expect(panelByTitle(page, 'Requests over time').first()).toBeHidden();
// Its neighbours are untouched.
await expect(panelByTitle(page, 'Total requests').first()).toBeVisible();
// While collapsed, "DB Calls RPS" should fully unmount.
await expect(page.getByText('DB Calls RPS', { exact: true })).toHaveCount(0);
await toggleSection(sectionRow(page, /^DB Metrics \(\d+ widgets?\)$/));
await expect(
page.getByText('DB Calls RPS', { exact: true }).first(),
).toBeVisible();
await expect(page.getByText(/^DB Metrics \(\d+ widgets?\)$/)).toHaveCount(0);
});
test('TC-05 expanding a collapsed section brings its panels back', async ({
// ─── Section options menu (Rename / New Panel / Remove Section) ──────────
test('TC-04 section options menu shows Rename / New Panel / Remove Section', async ({
authedPage: page,
}) => {
await seedAndOpen(page, 'expand');
const toggle = sectionToggle(page, 'p-timeseries');
await gotoApmDashboard(page);
await toggle.click();
await expect(panelByTitle(page, 'Requests over time').first()).toBeHidden();
// Use DB Metrics — its settings popover is guaranteed to render all
// three buttons when the section is expanded. WidgetRow.tsx hides
// "Remove Section" while a section is collapsed.
await clickSectionSettings(sectionRow(page, 'DB Metrics'));
await toggle.click();
await expect(panelByTitle(page, 'Requests over time').first()).toBeVisible();
const tooltip = page.getByRole('tooltip');
await expect(tooltip).toBeVisible();
await expect(tooltip.getByRole('button', { name: 'Rename' })).toBeVisible();
await expect(
tooltip.getByRole('button', { name: 'New Panel', exact: true }),
).toBeVisible();
await expect(
tooltip.getByRole('button', { name: 'Remove Section' }),
).toBeVisible();
await page.keyboard.press('Escape');
});
test('TC-06 a panel exposes its actions menu', async ({
test('TC-05 rename a section, restore original name', async ({
authedPage: page,
}) => {
await seedAndOpen(page, 'actions');
await gotoApmDashboard(page);
await panelByTitle(page, 'Requests over time').first().hover();
await panelActions(page, 'p-timeseries').click();
const renamed = `Renamed Section ${Date.now()}`;
// Assert the affordances the menu offers rather than a container testid: the one
// in the source is not rendered on this path, and the items are what users act on.
await expect(page.getByRole('menu')).toBeVisible();
for (const item of ['View', 'Edit panel', 'Clone', 'Delete panel']) {
await expect(page.getByRole('menuitem', { name: item })).toBeVisible();
}
// DB Metrics has a unique name, avoiding the duplicate-Overview snag.
await clickSectionSettings(sectionRow(page, 'DB Metrics'));
await page
.getByRole('tooltip')
.getByRole('button', { name: 'Rename' })
.click();
const renameDialog = page.getByRole('dialog', { name: 'Rename Section' });
await expect(renameDialog).toBeVisible();
const nameInput = renameDialog.getByPlaceholder('Enter row name here...');
await nameInput.click();
await nameInput.fill(renamed);
await renameDialog.getByRole('button', { name: 'Apply Changes' }).click();
await expect(renameDialog).not.toBeVisible();
await expect(page.getByText(renamed, { exact: true }).first()).toBeVisible();
// Restore.
await clickSectionSettings(sectionRow(page, renamed));
await page
.getByRole('tooltip')
.getByRole('button', { name: 'Rename' })
.click();
const restoreDialog = page.getByRole('dialog', { name: 'Rename Section' });
const restoreInput = restoreDialog.getByPlaceholder('Enter row name here...');
await restoreInput.click();
await restoreInput.fill('DB Metrics');
await restoreDialog.getByRole('button', { name: 'Apply Changes' }).click();
await expect(restoreDialog).not.toBeVisible();
await expect(
page.getByText('DB Metrics', { exact: true }).first(),
).toBeVisible();
await expect(page.getByText(renamed, { exact: true })).toHaveCount(0);
});
test('TC-07 a panel with nothing to show renders its no-data state, not an error', async ({
test('TC-06 cancel section rename leaves name unchanged', async ({
authedPage: page,
}) => {
await seedAndOpen(page, 'nodata');
await gotoApmDashboard(page);
// The seeded queries target signals this stack holds nothing for, so the panels
// resolve empty — that must read as "no data", never as a failure.
await expect(page.getByTestId('panel-no-data').first()).toBeVisible();
await expect(page.getByTestId('panel-error')).toHaveCount(0);
await clickSectionSettings(sectionRow(page, 'External calls'));
await page
.getByRole('tooltip')
.getByRole('button', { name: 'Rename' })
.click();
const dialog = page.getByRole('dialog', { name: 'Rename Section' });
await expect(dialog).toBeVisible();
const input = dialog.getByPlaceholder('Enter row name here...');
await input.click();
await input.fill('Should Not Be Applied');
await dialog.getByRole('button', { name: 'Cancel' }).click();
await expect(dialog).not.toBeVisible();
await expect(
page.getByText('External calls', { exact: true }).first(),
).toBeVisible();
await expect(page.getByText('Should Not Be Applied')).toHaveCount(0);
});
// TODO(e2e): re-enable once CI consistently passes. Flaky because of hover interaction on menu, will be changing with new implementation with perses.
test.skip('TC-07 add a new panel to a section, then delete it', async ({
authedPage: page,
}) => {
await gotoApmDashboard(page);
const panelName = `Test Panel ${Date.now()}`;
await clickSectionSettings(sectionRow(page, 'DB Metrics'));
await page
.getByRole('tooltip')
.getByRole('button', { name: 'New Panel', exact: true })
.click();
const panelTypeDialog = page.getByRole('dialog', { name: 'New Panel' });
await expect(panelTypeDialog).toBeVisible();
await panelTypeDialog.getByTestId('panel-type-graph').click();
// We're now in the panel editor at /dashboard/:id/new?widgetId=…
await page.waitForURL(/\/new/);
await page.getByTestId('panel-name-input').fill(panelName);
// NewWidget renders TWO buttons with `data-testid="new-widget-save"` —
// a disabled variant when `isSaveDisabled` is true and an enabled
// variant when it is false. Under CI load the editor mounts with the
// disabled variant first; without `toBeEnabled` the click can hit the
// disabled button and the Save dialog never opens.
const saveBtn = page.getByTestId('new-widget-save');
await expect(saveBtn).toBeVisible();
await expect(saveBtn).toBeEnabled({ timeout: 20_000 });
// `dispatchEvent('click')` — sidenav overlap risk on CI; see the same
// rationale on `toggleSectionAndWaitForPut` above.
await saveBtn.dispatchEvent('click');
const saveDialog = page.getByRole('dialog', { name: 'Save Widget' });
await expect(saveDialog).toBeVisible();
// PUT confirms the panel persisted server-side — more reliable than
// waiting on redux state to propagate before navigating back.
const putResponse = page.waitForResponse(
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
);
await saveDialog.getByRole('button', { name: 'OK' }).click();
await putResponse;
await page.waitForURL((url) => !url.pathname.includes('/new'));
await expect(
page.getByText(panelName, { exact: true }).first(),
).toBeVisible();
// The panel ⋮ menu is a Radix `DropdownMenuSimple` — it opens on click,
// not hover (see `openPanelMoreMenu` in 21-panel-actions.spec.ts). The
// container hover only reveals the kebab (it's `visibility: hidden`
// until then); the click toggles the menu. Wait for the menu role to be
// visible before clicking Delete.
const panelTitle = page.getByText(panelName, { exact: true }).first();
await panelTitle.hover();
const panelContainer = panelTitle.locator('../..');
await panelContainer.scrollIntoViewIfNeeded();
await panelContainer.hover();
await panelContainer.getByTestId('widget-header-options').click();
const menu = page.getByRole('menu');
await menu.waitFor({ state: 'visible' });
await menu.getByRole('menuitem', { name: 'Delete', exact: true }).click();
const deleteDialog = page.getByRole('dialog', { name: 'Delete' });
await expect(deleteDialog).toBeVisible();
const deletePut = page.waitForResponse(
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
);
await deleteDialog.getByRole('button', { name: 'OK' }).click();
await deletePut;
await expect(deleteDialog).not.toBeVisible();
await expect(page.getByText(panelName, { exact: true })).toHaveCount(0);
});
// ─── New section in edit mode ────────────────────────────────────────────
test('TC-08 add a new section via edit mode, then remove it', async ({
authedPage: page,
}) => {
await gotoApmDashboard(page);
const sectionName = `Temp Section ${Date.now()}`;
await page.getByTestId('options').click();
await page.getByRole('button', { name: 'New section' }).click();
const newSectionDialog = page.getByRole('dialog', { name: 'New Section' });
await expect(newSectionDialog).toBeVisible();
await newSectionDialog.getByTestId('section-name').fill(sectionName);
await newSectionDialog
.getByRole('button', { name: 'Create Section' })
.click();
await expect(newSectionDialog).not.toBeVisible();
await expect(
page.getByText(sectionName, { exact: true }).first(),
).toBeVisible();
await clickSectionSettings(sectionRow(page, sectionName));
await page
.getByRole('tooltip')
.getByRole('button', { name: 'Remove Section' })
.click();
const deleteRowDialog = page.getByRole('dialog', { name: 'Delete Row' });
await expect(deleteRowDialog).toBeVisible();
await deleteRowDialog.getByRole('button', { name: 'OK' }).click();
await expect(deleteRowDialog).not.toBeVisible();
await expect(page.getByText(sectionName, { exact: true })).toHaveCount(0);
// Original sections are untouched.
await expect(
page.getByText('Overview', { exact: true }).first(),
).toBeVisible();
await expect(
page.getByText('DB Metrics', { exact: true }).first(),
).toBeVisible();
await expect(
page.getByText('External calls', { exact: true }).first(),
).toBeVisible();
});
// ─── Deep coverage ───────────────────────────────────────────────────────
test('TC-09 collapsing two sections in sequence shows both as collapsed', async ({
authedPage: page,
}) => {
await gotoApmDashboard(page);
await setSectionCollapsed(page, 'DB Metrics', true);
await expect(
page.getByText(/^DB Metrics \(\d+ widgets?\)$/).first(),
).toBeVisible();
await setSectionCollapsed(page, 'External calls', true);
await expect(
page.getByText(/^External calls \(\d+ widgets?\)$/).first(),
).toBeVisible();
// Restore both so the test leaves no state behind.
await setSectionCollapsed(page, 'DB Metrics', false);
await setSectionCollapsed(page, 'External calls', false);
await expect(page.getByText(/^DB Metrics \(\d+ widgets?\)$/)).toHaveCount(0);
await expect(page.getByText(/^External calls \(\d+ widgets?\)$/)).toHaveCount(
0,
);
});
test('TC-10 panels inside a collapsed section are not in the DOM', async ({
authedPage: page,
}) => {
await gotoApmDashboard(page);
// "DB Calls RPS" is a unique panel inside the "DB Metrics" section.
const dbPanel = page.getByText('DB Calls RPS', { exact: true });
await dbPanel.first().scrollIntoViewIfNeeded();
await expect(dbPanel.first()).toBeVisible();
await toggleSection(sectionRow(page, 'DB Metrics'));
await expect(
page.getByText(/^DB Metrics \(\d+ widgets?\)$/).first(),
).toBeVisible();
// Panels inside the collapsed section unmount, not just hidden.
await expect(dbPanel).toHaveCount(0);
// Restore.
await toggleSection(sectionRow(page, /^DB Metrics \(\d+ widgets?\)$/));
await expect(dbPanel.first()).toBeVisible();
});
});

View File

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

View File

@@ -335,27 +335,69 @@ def _is_json_subset(subset, superset) -> bool:
return subset == superset
def _match_query_params(expected: dict, req: dict) -> bool:
"""Match a wiremock request's query params. Each expected value may be a string
(exact), an re.Pattern (search), or None (presence only, e.g. a dynamic hash)."""
query_params = req.get("queryParams", {})
for name, want in expected.items():
if name not in query_params:
return False
values = query_params[name].get("values", [])
if want is None:
if not values:
return False
elif isinstance(want, re.Pattern):
if not any(want.search(v) for v in values):
return False
elif want not in values:
return False
return True
def verify_webhook_notification_expectation(
notification_channel: types.TestContainerDocker,
validation_data: dict,
) -> bool:
"""Check if wiremock received a request at the given path
whose JSON body is a superset of the expected json_body."""
"""Check that wiremock received the expected request(s) at the given path.
validation_data supports (all optional except path):
- path: request url path (matched as urlPath, so query strings are ignored)
- json_body: expected JSON subset of the request body
- query_params: {name: str|re.Pattern|None} matched against the request query
- count: exact number of requests required at the path
- min_count: minimum number of requests required (e.g. retries)
Body/query constraints must be satisfied by a single request; count constraints
apply to the total at the path."""
path = validation_data["path"]
json_body = validation_data["json_body"]
json_body = validation_data.get("json_body")
query_params = validation_data.get("query_params")
url = notification_channel.host_configs["8080"].get("__admin/requests/find")
try:
res = requests.post(url, json={"method": "POST", "url": path}, timeout=10)
# urlPath matches the path only; the notifier appends a dynamic threadKey.
res = requests.post(url, json={"method": "POST", "urlPath": path}, timeout=10)
except requests.exceptions.RequestException:
return False
if res.status_code != HTTPStatus.OK:
return False
for req in res.json()["requests"]:
body = json.loads(base64.b64decode(req["bodyAsBase64"]).decode("utf-8"))
if _is_json_subset(json_body, body):
return True
reqs = res.json()["requests"]
if "count" in validation_data and len(reqs) != validation_data["count"]:
return False
if "min_count" in validation_data and len(reqs) < validation_data["min_count"]:
return False
if json_body is None and query_params is None:
return True
for req in reqs:
if json_body is not None:
body = json.loads(base64.b64decode(req["bodyAsBase64"]).decode("utf-8"))
if not _is_json_subset(json_body, body):
continue
if query_params is not None and not _match_query_params(query_params, req):
continue
return True
return False
@@ -416,7 +458,7 @@ def _received_notifications(
continue
url = notification_channel.host_configs["8080"].get("__admin/requests/find")
try:
res = requests.post(url, json={"method": "POST", "url": validation.validation_data["path"]}, timeout=10)
res = requests.post(url, json={"method": "POST", "urlPath": validation.validation_data["path"]}, timeout=10)
webhook_bodies.extend(json.loads(base64.b64decode(req["bodyAsBase64"]).decode("utf-8")) for req in res.json()["requests"])
except requests.exceptions.RequestException as exc:
webhook_bodies.append(f"<failed to fetch wiremock journal: {exc}>")
@@ -455,4 +497,11 @@ def update_raw_channel_config(
path = urlparse(original_url).path
entry[url_field] = notification_channel.container_configs["8080"].get(path)
# Google Chat validates the webhook host, so route via the https alias config
# (chat.googleapis.com:8443) keeping the path, and skip tls for wiremock's cert.
for entry in config.get("googlechat_configs", []):
path = urlparse(entry["webhook_url"]).path
entry["webhook_url"] = notification_channel.container_configs["8443"].get(path)
entry.setdefault("http_config", {}).setdefault("tls_config", {})["insecure_skip_verify"] = True
return config

View File

@@ -18,6 +18,10 @@ from fixtures.maildev import MAILDEV_INCOMING_PASS, SMTP_TEST_FROM
logger = setup_logger(__name__)
# Google Chat validates the webhook host, so the WireMock container is aliased as
# this hostname on the docker network and channels point at https://<host>:8443/...
GOOGLE_CHAT_HOST = "chat.googleapis.com"
EMAIL_TRANSPORT_KEYS = [
"from",
@@ -124,6 +128,19 @@ email_default_config = {
}
def googlechat_config(space: str) -> dict:
"""Google Chat channel config for a per-test WireMock space path. Title/text are
omitted so the backend applies its default templates. The host + tls-skip are
injected at runtime by update_raw_channel_config."""
return {
"googlechat_configs": [
{
"webhook_url": f"/v1/spaces/{space}/messages", # host set on runtime
}
],
}
@pytest.fixture(name="notification_channel", scope="package")
def notification_channel(
network: Network,
@@ -134,9 +151,27 @@ def notification_channel(
Package-scoped fixture for WireMock container to receive notifications for Alert rules.
"""
# A --reuse cache from before the https:8443 alias was added lacks the "8443"
# config that Google Chat delivery needs (and its container lacks the port/alias).
# Drop such a stale cache + container so the fixture recreates a correct one,
# instead of raising KeyError on container_configs["8443"].
cached = pytestconfig.cache.get("notification_channel", None)
if cached and "8443" not in (cached.get("container_configs") or {}):
logger.info("Recreating stale notification_channel (cache missing https:8443)")
try:
docker.from_env().containers.get(cached["id"]).remove(force=True)
except docker.errors.NotFound:
pass
pytestconfig.cache.set("notification_channel", None)
def create() -> types.TestContainerDocker:
# http:8080 admin/webhook delivery, plus https:8443 aliased as
# chat.googleapis.com so Google Chat's validated webhook host routes here.
container = WireMockContainer(image="wiremock/wiremock:2.35.1-1", secure=False)
container.with_cli_arg("--https-port", "8443")
container.with_exposed_ports(8080) # 8443 reached in-network via the alias, no host mapping needed
container.with_network(network)
container.with_network_aliases(GOOGLE_CHAT_HOST)
container.start()
return types.TestContainerDocker(
@@ -148,7 +183,11 @@ def notification_channel(
container.get_exposed_port(8080),
)
},
container_configs={"8080": types.TestContainerUrlConfig("http", container.get_wrapped_container().name, 8080)},
container_configs={
"8080": types.TestContainerUrlConfig("http", container.get_wrapped_container().name, 8080),
# Google Chat delivery: https to the validated host via the network alias.
"8443": types.TestContainerUrlConfig("https", GOOGLE_CHAT_HOST, 8443),
},
)
def delete(container: types.TestContainerDocker):

View File

@@ -0,0 +1,232 @@
"""Google Chat notifier integration tests driven through the real alerting path:
create a rule pointing at a Google Chat channel, insert breaching telemetry, let
the ruler fire, and assert on the cardsV2 payload WireMock received.
WireMock stands in for chat.googleapis.com (network alias + https:8443, see the
notification_channel fixture). Assertions check the actual card structure, deep
links and threading query params so behavioural regressions are caught.
"""
import json
import re
import time
import uuid
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
import pytest
from wiremock.client import HttpMethods, Mapping, MappingRequest, MappingResponse
from fixtures import types
from fixtures.alerts import (
get_testdata_file_path,
update_raw_channel_config,
update_rule_channel_name,
verify_notification_expectation,
)
from fixtures.logger import setup_logger
from fixtures.notification_channel import googlechat_config
logger = setup_logger(__name__)
METRICS_DATA = "alerts/test_scenarios/threshold_above_at_least_once/alert_data.jsonl"
METRICS_RULE = "alerts/test_scenarios/threshold_above_at_least_once/rule.json"
LOGS_DATA = "alerts/test_scenarios/threshold_below_at_least_once/alert_data.jsonl"
LOGS_RULE = "alerts/test_scenarios/threshold_below_at_least_once/rule.json"
TRACES_DATA = "alerts/test_scenarios/threshold_above_average/alert_data.jsonl"
TRACES_RULE = "alerts/test_scenarios/threshold_above_average/rule.json"
# threading query params the notifier always appends
THREAD_QUERY = {
"messageReplyOption": "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD",
"threadKey": None, # dynamic hash; presence only
}
def _path(space: str) -> str:
return f"/v1/spaces/{space}/messages"
def _stub_200(path: str) -> list[Mapping]:
return [
Mapping(
request=MappingRequest(method=HttpMethods.POST, url_path=path),
response=MappingResponse(status=200, json_body={"name": "spaces/x/messages/x"}),
persistent=True,
)
]
def _stub_retry(path: str) -> list[Mapping]:
"""429 on the first call then 200, via a wiremock scenario transition."""
scenario = f"gc-retry-{path}"
return [
Mapping(
request=MappingRequest(method=HttpMethods.POST, url_path=path),
response=MappingResponse(status=429, json_body={"error": {"code": 429, "status": "RESOURCE_EXHAUSTED"}}),
scenario_name=scenario,
required_scenario_state="Started",
new_scenario_state="ok",
persistent=True,
),
Mapping(
request=MappingRequest(method=HttpMethods.POST, url_path=path),
response=MappingResponse(status=200, json_body={"name": "spaces/x/messages/x"}),
scenario_name=scenario,
required_scenario_state="ok",
persistent=True,
),
]
def _card_subset(alertname: str, buttons: list[tuple[str, str]]) -> dict:
"""A cardsV2 subset asserting title, firing banner, rendered body, and each
button's text AND deep-link url (as a regex), so a broken link is caught too.
buttons: list of (text, url_regex)."""
return {
"text": f"[FIRING:1] {alertname}",
"cardsV2": [
{
"cardId": "signoz-alert",
"card": {
"header": {"title": f"[FIRING:1] {alertname}"},
"sections": [
# firing banner
{"widgets": [{"textParagraph": {"text": re.compile("FIRING")}}]},
# rendered alert body mentions the alertname
{"widgets": [{"textParagraph": {"text": re.compile(re.escape(alertname))}}]},
]
+ [{"widgets": [{"buttonList": {"buttons": [{"text": text, "onClick": {"openLink": {"url": re.compile(url)}}}]}}]} for text, url in buttons],
},
}
],
}
GOOGLECHAT_CASES = [
types.AlertManagerNotificationTestCase(
name="googlechat_default_metrics_firing",
rule_path=METRICS_RULE,
alert_data=[types.AlertData(type="metrics", data_path=METRICS_DATA)],
channel_config=googlechat_config("gc-metrics"),
notification_expectation=types.AMNotificationExpectation(
should_notify=True,
wait_time_seconds=150,
notification_validations=[
types.NotificationValidation(
destination_type="webhook",
validation_data={
"path": _path("gc-metrics"),
"query_params": THREAD_QUERY,
"json_body": _card_subset("threshold_above_at_least_once", [("Open in SigNoz", r"/alerts/overview\?ruleId=")]),
},
),
],
),
),
types.AlertManagerNotificationTestCase(
name="googlechat_rich_card_logs",
rule_path=LOGS_RULE,
alert_data=[types.AlertData(type="logs", data_path=LOGS_DATA)],
channel_config=googlechat_config("gc-logs"),
notification_expectation=types.AMNotificationExpectation(
should_notify=True,
wait_time_seconds=150,
notification_validations=[
types.NotificationValidation(
destination_type="webhook",
validation_data={
"path": _path("gc-logs"),
"json_body": _card_subset(
"threshold_below_at_least_once",
[("View Related Logs", r"/logs/logs-explorer\?"), ("Open in SigNoz", r"/alerts/overview\?ruleId=")],
),
},
),
],
),
),
types.AlertManagerNotificationTestCase(
name="googlechat_rich_card_traces",
rule_path=TRACES_RULE,
alert_data=[types.AlertData(type="traces", data_path=TRACES_DATA)],
channel_config=googlechat_config("gc-traces"),
notification_expectation=types.AMNotificationExpectation(
should_notify=True,
wait_time_seconds=150,
notification_validations=[
types.NotificationValidation(
destination_type="webhook",
validation_data={
"path": _path("gc-traces"),
"json_body": _card_subset(
"threshold_above_average",
[("View Related Traces", r"traces-explorer\?"), ("Open in SigNoz", r"/alerts/overview\?ruleId=")],
),
},
),
],
),
),
types.AlertManagerNotificationTestCase(
name="googlechat_retry_429_then_200",
rule_path=METRICS_RULE,
alert_data=[types.AlertData(type="metrics", data_path=METRICS_DATA)],
channel_config=googlechat_config("gc-retry"),
notification_expectation=types.AMNotificationExpectation(
should_notify=True,
wait_time_seconds=150,
notification_validations=[
types.NotificationValidation(
destination_type="webhook",
validation_data={
# a retryable 429 is followed by a successful re-POST => >=2 hits
"path": _path("gc-retry"),
"min_count": 2,
"json_body": {"cardsV2": [{"cardId": "signoz-alert"}]},
},
),
],
),
),
]
# per-case wiremock stubs (retry needs a stateful scenario, the rest a plain 200)
CASE_STUBS: dict[str, Callable[[str], list[Mapping]]] = {
"googlechat_retry_429_then_200": _stub_retry,
}
@pytest.mark.parametrize(
"gc_test_case",
GOOGLECHAT_CASES,
ids=lambda c: c.name,
)
def test_googlechat_notifier( # pylint: disable=too-many-arguments,too-many-positional-arguments
notification_channel: types.TestContainerDocker,
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
create_notification_channel: Callable[[dict], str],
create_alert_rule: Callable[[dict], str],
insert_alert_data: Callable[[list[types.AlertData], datetime], None],
maildev: types.TestContainerDocker,
gc_test_case: types.AlertManagerNotificationTestCase,
) -> None:
channel_name = str(uuid.uuid4())
path = gc_test_case.notification_expectation.notification_validations[0].validation_data["path"]
channel_config = update_raw_channel_config(gc_test_case.channel_config, channel_name, notification_channel)
stub_factory = CASE_STUBS.get(gc_test_case.name, _stub_200)
make_http_mocks(notification_channel, stub_factory(path))
create_notification_channel(channel_config)
time.sleep(12) # org registration in alertmanager
insert_alert_data(gc_test_case.alert_data, base_time=datetime.now(tz=UTC) - timedelta(minutes=5))
with open(get_testdata_file_path(gc_test_case.rule_path), encoding="utf-8") as f:
rule_data = json.loads(f.read())
update_rule_channel_name(rule_data, channel_name)
create_alert_rule(rule_data)
verify_notification_expectation(notification_channel, maildev, gc_test_case.notification_expectation)

View File

@@ -0,0 +1,112 @@
"""Google Chat coverage for the testChannel API (POST /api/v1/testChannel).
testChannel drives the notifier once, synchronously, with a hardcoded test alert
and no retry. It is the button users click in the UI, and the deterministic place
to assert permanent-failure / no-retry behaviour. Rich-card and retry behaviour is
covered via the firing-rule path in alertmanager/04_googlechat.py.
"""
import base64
import json
import re
import time
import uuid
from collections.abc import Callable
from http import HTTPStatus
import pytest
import requests
from wiremock.client import HttpMethods, Mapping, MappingRequest, MappingResponse
from fixtures import types
from fixtures.alerts import update_raw_channel_config
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logger import setup_logger
from fixtures.notification_channel import googlechat_config
logger = setup_logger(__name__)
def _path(space: str) -> str:
return f"/v1/spaces/{space}/messages"
# name, space, stub status, stub body, expect testChannel 204
TEST_CHANNEL_CASES = [
("googlechat_test_channel_success", "gc-tc-ok", 200, {"name": "spaces/x/messages/x"}, True),
("googlechat_test_channel_permanent_400", "gc-tc-400", 400, {"error": {"code": 400, "status": "INVALID_ARGUMENT", "message": "Message cannot be empty."}}, False),
("googlechat_test_channel_permission_403", "gc-tc-403", 403, {"error": {"code": 403, "status": "PERMISSION_DENIED", "message": "Method doesn't allow unregistered callers"}}, False),
]
@pytest.mark.parametrize(
"name,space,status,body,expect_delivered",
TEST_CHANNEL_CASES,
ids=lambda v: v if isinstance(v, str) else "",
)
def test_googlechat_test_channel( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
create_user_admin: None, # pylint: disable=unused-argument
notification_channel: types.TestContainerDocker,
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
name: str, # pylint: disable=unused-argument
space: str,
status: int,
body: dict,
expect_delivered: bool,
) -> None:
path = _path(space)
make_http_mocks(
notification_channel,
[
Mapping(
request=MappingRequest(method=HttpMethods.POST, url_path=path),
response=MappingResponse(status=status, json_body=body),
persistent=True,
)
],
)
channel_name = str(uuid.uuid4())
receiver = update_raw_channel_config(googlechat_config(space), channel_name, notification_channel)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# org registration in alertmanager
time.sleep(10)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/testChannel"),
json=receiver,
headers={"Authorization": f"Bearer {admin_token}"},
timeout=30,
)
if expect_delivered:
assert response.status_code == HTTPStatus.NO_CONTENT, f"expected 204, got {response.status_code}: {response.text}"
else:
# a 400/403 is a permanent failure: testChannel surfaces it, does not retry
assert response.status_code != HTTPStatus.NO_CONTENT, f"expected failure status, got 204 for {status} stub"
# exactly one delivery attempt either way (testChannel never retries)
count = requests.post(
notification_channel.host_configs["8080"].get("/__admin/requests/count"),
json={"method": "POST", "urlPath": path},
timeout=10,
)
assert count.json()["count"] == 1, f"expected exactly 1 request (no retry), got {count.text}"
if expect_delivered:
find = requests.post(
notification_channel.host_configs["8080"].get("/__admin/requests/find"),
json={"method": "POST", "urlPath": path},
timeout=10,
)
req = find.json()["requests"][0]
# threading query params are always appended
assert "messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD" in req["url"]
assert "threadKey=" in req["url"]
# cardsV2 shape with the hardcoded test alert
card = json.loads(base64.b64decode(req["bodyAsBase64"]).decode("utf-8"))
assert card["cardsV2"][0]["cardId"] == "signoz-alert"
assert re.search(r"Test Alert \(", card["cardsV2"][0]["card"]["header"]["title"])

View File

@@ -0,0 +1,623 @@
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}"