mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-06 13:10:40 +01:00
Compare commits
7 Commits
fga/dashbo
...
test/e2e-d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4afdafdeb9 | ||
|
|
414e4285ba | ||
|
|
762ea483db | ||
|
|
6d60ea0c5e | ||
|
|
dc248794d9 | ||
|
|
bd2e0aaa18 | ||
|
|
5a210c114d |
@@ -96,8 +96,6 @@ func runGenerateAuthz(_ context.Context) error {
|
||||
coretypes.NewResourceRef(coretypes.ResourceServiceAccount).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceRole).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceMetaResourceFactorAPIKey).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceMetaResourceDashboard).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceMetaResourcePublicDashboard).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceLogs).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceTraces).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMetrics).String(): true,
|
||||
|
||||
@@ -10247,9 +10247,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:update
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- dashboard:update
|
||||
- ADMIN
|
||||
summary: Delete public dashboard
|
||||
tags:
|
||||
- dashboard
|
||||
@@ -10298,9 +10298,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:read
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- dashboard:read
|
||||
- ADMIN
|
||||
summary: Get public dashboard
|
||||
tags:
|
||||
- dashboard
|
||||
@@ -10355,9 +10355,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:update
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- dashboard:update
|
||||
- ADMIN
|
||||
summary: Create public dashboard
|
||||
tags:
|
||||
- dashboard
|
||||
@@ -10399,9 +10399,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:update
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- dashboard:update
|
||||
- ADMIN
|
||||
summary: Update public dashboard
|
||||
tags:
|
||||
- dashboard
|
||||
@@ -14739,9 +14739,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:list
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- dashboard:list
|
||||
- VIEWER
|
||||
summary: List dashboard saved views
|
||||
tags:
|
||||
- dashboard
|
||||
@@ -14796,9 +14796,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:list
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- dashboard:list
|
||||
- EDITOR
|
||||
summary: Create dashboard saved view
|
||||
tags:
|
||||
- dashboard
|
||||
@@ -14849,9 +14849,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:list
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- dashboard:list
|
||||
- EDITOR
|
||||
summary: Delete dashboard saved view
|
||||
tags:
|
||||
- dashboard
|
||||
@@ -14917,9 +14917,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:list
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- dashboard:list
|
||||
- EDITOR
|
||||
summary: Update dashboard saved view
|
||||
tags:
|
||||
- dashboard
|
||||
@@ -14994,9 +14994,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:list
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- dashboard:list
|
||||
- VIEWER
|
||||
summary: List dashboards (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
@@ -15051,9 +15051,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:create
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- dashboard:create
|
||||
- EDITOR
|
||||
summary: Create dashboard (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
@@ -15104,9 +15104,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:delete
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- dashboard:delete
|
||||
- EDITOR
|
||||
summary: Delete dashboard (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
@@ -15167,9 +15167,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:read
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- dashboard:read
|
||||
- VIEWER
|
||||
summary: Get dashboard (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
@@ -15241,9 +15241,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:update
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- dashboard:update
|
||||
- EDITOR
|
||||
summary: Patch dashboard (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
@@ -15310,9 +15310,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:update
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- dashboard:update
|
||||
- EDITOR
|
||||
summary: Update dashboard (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
@@ -15378,11 +15378,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:read
|
||||
- dashboard:create
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- dashboard:read
|
||||
- dashboard:create
|
||||
- EDITOR
|
||||
summary: Clone dashboard (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
@@ -15433,9 +15431,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:update
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- dashboard:update
|
||||
- EDITOR
|
||||
summary: Unlock dashboard (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
@@ -15485,9 +15483,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:update
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- dashboard:update
|
||||
- EDITOR
|
||||
summary: Lock dashboard (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
@@ -15551,9 +15549,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:update
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- dashboard:update
|
||||
- EDITOR
|
||||
summary: Migrate dashboard to v2
|
||||
tags:
|
||||
- dashboard
|
||||
@@ -23697,9 +23695,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:list
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- dashboard:list
|
||||
- VIEWER
|
||||
summary: List dashboards for the current user (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
@@ -23744,9 +23742,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:list
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- dashboard:list
|
||||
- VIEWER
|
||||
summary: Unpin a dashboard for the current user (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
@@ -23803,9 +23801,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:list
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- dashboard:list
|
||||
- VIEWER
|
||||
summary: Pin a dashboard for the current user (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
|
||||
@@ -37,7 +37,6 @@ export default function ChartWrapper({
|
||||
groupByPerQuery,
|
||||
customTooltip,
|
||||
pinnedTooltipElement,
|
||||
tooltipPortalRoot,
|
||||
'data-testid': testId,
|
||||
}: ChartProps): JSX.Element {
|
||||
const plotInstanceRef = useRef<uPlot | null>(null);
|
||||
@@ -119,7 +118,6 @@ export default function ChartWrapper({
|
||||
syncMetadata={syncMetadata}
|
||||
render={renderTooltipCallback}
|
||||
pinnedTooltipElement={pinnedTooltipElement}
|
||||
portalRoot={tooltipPortalRoot}
|
||||
/>
|
||||
)}
|
||||
</UPlotChart>
|
||||
|
||||
@@ -30,7 +30,6 @@ interface BaseChartProps {
|
||||
pinnedTooltipElement?: (clickData: ChartClickData) => React.ReactNode;
|
||||
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => React.ReactNode;
|
||||
customTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
|
||||
tooltipPortalRoot?: HTMLElement | null;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
interface UPlotBasedChartProps {
|
||||
|
||||
@@ -40,9 +40,6 @@ export type {
|
||||
|
||||
// TODO(H4ad): Improve this on component level
|
||||
const DRAWER_TRANSITION = { duration: 0.3, ease: [0.25, 0.1, 0.25, 1] };
|
||||
// Be careful when changing these props, this must be animated with transform but later
|
||||
// replaced with none, otherwise, the tooltip of the chart will be not positioned correctly
|
||||
// due to how the tooltip positioning calculation works
|
||||
const DRAWER_MOTION_PROPS = {
|
||||
onOpenAutoFocus: (e: Event): void => e.preventDefault(),
|
||||
initial: { opacity: 0, transform: 'translateX(100%)' },
|
||||
@@ -50,7 +47,6 @@ const DRAWER_MOTION_PROPS = {
|
||||
opacity: 1,
|
||||
transform: 'translateX(0%)',
|
||||
transition: DRAWER_TRANSITION,
|
||||
transitionEnd: { transform: 'none' },
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-5);
|
||||
padding: var(--spacing-4) var(--spacing-6);
|
||||
border-top: 1px dashed var(--l2-border);
|
||||
background: var(--l1-background);
|
||||
border-radius: 0 0 6px 6px;
|
||||
}
|
||||
|
||||
.hintList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-2) var(--spacing-8);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hint {
|
||||
--typography-text-display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
position: relative;
|
||||
|
||||
&[data-active='false']::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: -12px;
|
||||
transform: translateY(-50%);
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--l2-foreground);
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { MousePointerClick, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Kbd } from '@signozhq/ui/kbd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { Events } from 'constants/events';
|
||||
import { DEFAULT_PIN_TOOLTIP_KEY } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
import styles from './ChartTooltipFooter.module.scss';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
interface ChartTooltipFooterProps {
|
||||
id: string;
|
||||
pinKey?: string;
|
||||
isPinned: boolean;
|
||||
canSelectTimeRange?: boolean;
|
||||
dismiss: () => void;
|
||||
}
|
||||
|
||||
export default function ChartTooltipFooter({
|
||||
id,
|
||||
pinKey = DEFAULT_PIN_TOOLTIP_KEY,
|
||||
isPinned,
|
||||
canSelectTimeRange = true,
|
||||
dismiss,
|
||||
}: ChartTooltipFooterProps): JSX.Element {
|
||||
const handleUnpinClick = (): void => {
|
||||
void logEvent(Events.TOOLTIP_UNPINNED, {
|
||||
id,
|
||||
});
|
||||
dismiss();
|
||||
};
|
||||
|
||||
return (
|
||||
<output className={styles.footer} data-testid="entity-chart-tooltip-footer">
|
||||
<div>
|
||||
{isPinned ? (
|
||||
<Typography.Text className={styles.hint} size="small">
|
||||
<span>Press</span>
|
||||
<Kbd active>{pinKey.toUpperCase()}</Kbd>
|
||||
<span>or</span>
|
||||
<Kbd active>Esc</Kbd>
|
||||
<span>to unpin</span>
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<div className={styles.hintList}>
|
||||
{canSelectTimeRange && (
|
||||
<Typography.Text
|
||||
className={styles.hint}
|
||||
size="small"
|
||||
data-active="false"
|
||||
>
|
||||
<Kbd>
|
||||
<MousePointerClick size={12} />
|
||||
</Kbd>
|
||||
<span>Click and drag to zoom into a time range</span>
|
||||
</Typography.Text>
|
||||
)}
|
||||
<Typography.Text className={styles.hint} size="small" data-active="false">
|
||||
<span>Press</span>
|
||||
<Kbd>{pinKey.toUpperCase()}</Kbd>
|
||||
<span>to pin the tooltip</span>
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isPinned && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={handleUnpinClick}
|
||||
aria-label="Unpin tooltip"
|
||||
data-testid="entity-chart-tooltip-unpin"
|
||||
>
|
||||
<X size={10} />
|
||||
<span>Unpin</span>
|
||||
</Button>
|
||||
)}
|
||||
</output>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { Skeleton } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
|
||||
import {
|
||||
IRenderTooltipFooterArgs,
|
||||
LegendPosition,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import {
|
||||
InfraMonitoringEntity,
|
||||
VIEW_TYPES,
|
||||
@@ -36,7 +33,6 @@ import { isKeyNotFoundError } from '../utils';
|
||||
import styles from './EntityMetrics.module.scss';
|
||||
import { MetricsTable } from './MetricsTable';
|
||||
import { logInfraExplorerNavigatedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
|
||||
import ChartTooltipFooter from './ChartTooltipFooter';
|
||||
|
||||
interface EntityMetricsProps<T> {
|
||||
entity: T;
|
||||
@@ -82,10 +78,6 @@ function EntityMetrics<T>({
|
||||
category,
|
||||
});
|
||||
|
||||
const [tooltipPortalEl, setTooltipPortalEl] = useState<HTMLDivElement | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
@@ -179,18 +171,6 @@ function EntityMetrics<T>({
|
||||
height={dimensions.height}
|
||||
timezone={timezone}
|
||||
yAxisUnit={entityWidgetInfo[idx].yAxisUnit}
|
||||
canPinTooltip
|
||||
tooltipPortalRoot={tooltipPortalEl ?? undefined}
|
||||
renderTooltipFooter={({
|
||||
isPinned,
|
||||
dismiss,
|
||||
}: IRenderTooltipFooterArgs) => (
|
||||
<ChartTooltipFooter
|
||||
id={configs[idx]?.getId() || idx.toString()}
|
||||
isPinned={isPinned}
|
||||
dismiss={dismiss}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
@@ -200,8 +180,6 @@ function EntityMetrics<T>({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={setTooltipPortalEl} style={{ display: 'contents' }} />
|
||||
|
||||
<div className={styles.metricsHeader}>
|
||||
<EntityDateTimeSelector
|
||||
eventEntity={eventEntity}
|
||||
|
||||
@@ -3,29 +3,11 @@ export default {
|
||||
status: 'success',
|
||||
data: {
|
||||
resources: [
|
||||
{
|
||||
kind: 'dashboard',
|
||||
type: 'metaresource',
|
||||
allowedVerbs: ['create', 'delete', 'list', 'read', 'update'],
|
||||
},
|
||||
{
|
||||
kind: 'factor-api-key',
|
||||
type: 'metaresource',
|
||||
allowedVerbs: ['create', 'delete', 'list', 'read', 'update'],
|
||||
},
|
||||
{
|
||||
kind: 'public-dashboard',
|
||||
type: 'metaresource',
|
||||
allowedVerbs: [
|
||||
'attach',
|
||||
'create',
|
||||
'delete',
|
||||
'detach',
|
||||
'list',
|
||||
'read',
|
||||
'update',
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'role',
|
||||
type: 'role',
|
||||
|
||||
@@ -51,7 +51,6 @@ export default function TooltipPlugin({
|
||||
canPinTooltip = false,
|
||||
pinKey = DEFAULT_PIN_TOOLTIP_KEY,
|
||||
onClick,
|
||||
portalRoot: portalRootProp,
|
||||
}: TooltipPluginProps): JSX.Element | null {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const rafId = useRef<number | null>(null);
|
||||
@@ -60,9 +59,7 @@ export default function TooltipPlugin({
|
||||
const renderRef = useRef(render);
|
||||
renderRef.current = render;
|
||||
const [portalRoot, setPortalRoot] = useState<HTMLElement>(
|
||||
portalRootProp ??
|
||||
(document.fullscreenElement as HTMLElement) ??
|
||||
document.body,
|
||||
(document.fullscreenElement as HTMLElement) ?? document.body,
|
||||
);
|
||||
|
||||
// React-managed snapshot of what should be rendered. The controller
|
||||
@@ -467,12 +464,8 @@ export default function TooltipPlugin({
|
||||
}, [config]);
|
||||
|
||||
const resolvePortalRoot = useCallback((): void => {
|
||||
setPortalRoot(
|
||||
portalRootProp ??
|
||||
(document.fullscreenElement as HTMLElement) ??
|
||||
document.body,
|
||||
);
|
||||
}, [portalRootProp]);
|
||||
setPortalRoot((document.fullscreenElement as HTMLElement) ?? document.body);
|
||||
}, []);
|
||||
|
||||
useLayoutEffect((): (() => void) => {
|
||||
resolvePortalRoot();
|
||||
|
||||
@@ -67,7 +67,6 @@ export interface TooltipPluginProps {
|
||||
pinnedTooltipElement?: (clickData: ChartClickData) => ReactNode;
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
portalRoot?: HTMLElement | null;
|
||||
}
|
||||
|
||||
export interface ChartClickData {
|
||||
|
||||
8
go.mod
8
go.mod
@@ -223,7 +223,7 @@ require (
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/golang/snappy v1.0.0 // indirect
|
||||
github.com/google/btree v1.1.3 // indirect
|
||||
github.com/google/cel-go v0.29.0 // indirect
|
||||
github.com/google/cel-go v0.28.0 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.18.0 // indirect
|
||||
@@ -392,9 +392,9 @@ require (
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
golang.org/x/tools v0.44.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478
|
||||
google.golang.org/grpc v1.82.1 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d
|
||||
google.golang.org/grpc v1.80.0 // indirect
|
||||
gopkg.in/telebot.v3 v3.3.8 // indirect
|
||||
k8s.io/client-go v0.35.3 // indirect
|
||||
k8s.io/klog/v2 v2.140.0 // indirect
|
||||
|
||||
20
go.sum
20
go.sum
@@ -247,8 +247,8 @@ github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWH
|
||||
github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||
github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
|
||||
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
|
||||
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
|
||||
github.com/coder/quartz v0.3.0 h1:bUoSEJ77NBfKtUqv6CPSC0AS8dsjqAqqAv7bN02m1mg=
|
||||
github.com/coder/quartz v0.3.0/go.mod h1:BgE7DOj/8NfvRgvKw0jPLDQH/2Lya2kxcTaNJ8X0rZk=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
@@ -507,8 +507,8 @@ github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Z
|
||||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
|
||||
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
||||
github.com/google/cel-go v0.29.0 h1:fEG+Ja3YRwNOqnQxTyJwoByAUAvTuxUGiro/jhrm4F4=
|
||||
github.com/google/cel-go v0.29.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8=
|
||||
github.com/google/cel-go v0.28.0 h1:KjSWstCpz/MN5t4a8gnGJNIYUsJRpdi/r97xWDphIQc=
|
||||
github.com/google/cel-go v0.28.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8=
|
||||
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
|
||||
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
@@ -1856,10 +1856,10 @@ google.golang.org/genproto v0.0.0-20220505152158-f39f71e6c8f3/go.mod h1:RAyBrSAP
|
||||
google.golang.org/genproto v0.0.0-20220519153652-3a47de7e79bd/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
|
||||
google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d h1:vsOm753cOAMkt76efriTCDKjpCbK18XGHMJHo0JUKhc=
|
||||
google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:0oz9d7g9QLSdv9/lgbIjowW1JoxMbxmBVNe8i6tORJI=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d h1:wT2n40TBqFY6wiwazVK9/iTWbsQrgk5ZfCSVFLO9LQA=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
@@ -1892,8 +1892,8 @@ google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ5
|
||||
google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ=
|
||||
google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
|
||||
google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
|
||||
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
|
||||
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
|
||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
|
||||
@@ -14,592 +14,371 @@ import (
|
||||
)
|
||||
|
||||
func (provider *provider) addDashboardRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v2/dashboards", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.ListV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "ListDashboardsV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "List dashboards (v2)",
|
||||
Description: "Returns a page of v2-shape dashboards for the org. This is the pure, user-independent list — it carries no pin state. Use ListDashboardsForUserV2 for the personalized, pin-aware list. Supports a filter DSL (`query`), sort (`updated_at`/`created_at`/`name`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`).",
|
||||
Request: nil,
|
||||
RequestQuery: new(dashboardtypes.ListDashboardsV2Params),
|
||||
RequestContentType: "",
|
||||
Response: new(dashboardtypes.ListableDashboardV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/dashboards", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.ListV2), handler.OpenAPIDef{
|
||||
ID: "ListDashboardsV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "List dashboards (v2)",
|
||||
Description: "Returns a page of v2-shape dashboards for the org. This is the pure, user-independent list — it carries no pin state. Use ListDashboardsForUserV2 for the personalized, pin-aware list. Supports a filter DSL (`query`), sort (`updated_at`/`created_at`/`name`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`).",
|
||||
Request: nil,
|
||||
RequestQuery: new(dashboardtypes.ListDashboardsV2Params),
|
||||
RequestContentType: "",
|
||||
Response: new(dashboardtypes.ListableDashboardV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/users/me/dashboards", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.ListForUserV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "ListDashboardsForUserV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "List dashboards for the current user (v2)",
|
||||
Description: "Same as ListDashboardsV2 but personalized for the calling user: each dashboard carries the caller's `pinned` state, and pinned dashboards float to the top of the requested ordering. Supports the same filter DSL, sort, order, and pagination.",
|
||||
Request: nil,
|
||||
RequestQuery: new(dashboardtypes.ListDashboardsV2Params),
|
||||
RequestContentType: "",
|
||||
Response: new(dashboardtypes.ListableDashboardForUserV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/users/me/dashboards", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.ListForUserV2), handler.OpenAPIDef{
|
||||
ID: "ListDashboardsForUserV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "List dashboards for the current user (v2)",
|
||||
Description: "Same as ListDashboardsV2 but personalized for the calling user: each dashboard carries the caller's `pinned` state, and pinned dashboards float to the top of the requested ordering. Supports the same filter DSL, sort, order, and pagination.",
|
||||
Request: nil,
|
||||
RequestQuery: new(dashboardtypes.ListDashboardsV2Params),
|
||||
RequestContentType: "",
|
||||
Response: new(dashboardtypes.ListableDashboardForUserV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.CreateV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CreateDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Create dashboard (v2)",
|
||||
Description: "This endpoint creates a dashboard in the v2 format that follows Perses spec.",
|
||||
Request: new(dashboardtypes.PostableDashboardV2),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(dashboardtypes.GettableDashboardV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
// TODO: add http.StatusConflict once the dashboard name unique index is added.
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbCreate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbCreate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.ResponseJSONPath("data.id"),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/dashboards", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.CreateV2), handler.OpenAPIDef{
|
||||
ID: "CreateDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Create dashboard (v2)",
|
||||
Description: "This endpoint creates a dashboard in the v2 format that follows Perses spec.",
|
||||
Request: new(dashboardtypes.PostableDashboardV2),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(dashboardtypes.GettableDashboardV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
// TODO: add http.StatusConflict once the dashboard name unique index is added.
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Two defs: the clone reads the source dashboard and creates a new one, so
|
||||
// both read on the source id and create on the collection are checked.
|
||||
if err := router.Handle("/api/v2/dashboards/{id}/clone", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.CloneV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CloneDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Clone dashboard (v2)",
|
||||
Description: "This endpoint clones an existing v2-shape dashboard. User and integration dashboards can be cloned; system dashboards are rejected. The clone keeps the source's display name, panels, and tags, but gets a freshly generated unique internal name and is always created as an unlocked user dashboard owned by the caller.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(dashboardtypes.GettableDashboardV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{
|
||||
coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbRead),
|
||||
coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbCreate),
|
||||
}),
|
||||
},
|
||||
handler.WithResourceDefs(
|
||||
handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
},
|
||||
handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbCreate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.ResponseJSONPath("data.id"),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
},
|
||||
),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/dashboards/{id}/clone", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.CloneV2), handler.OpenAPIDef{
|
||||
ID: "CloneDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Clone dashboard (v2)",
|
||||
Description: "This endpoint clones an existing v2-shape dashboard. User and integration dashboards can be cloned; system dashboards are rejected. The clone keeps the source's display name, panels, and tags, but gets a freshly generated unique internal name and is always created as an unlocked user dashboard owned by the caller.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(dashboardtypes.GettableDashboardV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards/{id}/migrate", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.MigrateV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "MigrateDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Migrate dashboard to v2",
|
||||
Description: "This endpoint retries the v1→v2 (Perses) migration on a dashboard still stored in the v1 schema and returns the v2-shape result. It is idempotent: a dashboard already in the v2 schema is returned unchanged.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(dashboardtypes.GettableDashboardV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbUpdate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/dashboards/{id}/migrate", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.MigrateV2), handler.OpenAPIDef{
|
||||
ID: "MigrateDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Migrate dashboard to v2",
|
||||
Description: "This endpoint retries the v1→v2 (Perses) migration on a dashboard still stored in the v1 schema and returns the v2-shape result. It is idempotent: a dashboard already in the v2 schema is returned unchanged.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(dashboardtypes.GettableDashboardV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.GetV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Get dashboard (v2)",
|
||||
Description: "This endpoint returns a v2-shape dashboard.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(dashboardtypes.GettableDashboardV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.GetV2), handler.OpenAPIDef{
|
||||
ID: "GetDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Get dashboard (v2)",
|
||||
Description: "This endpoint returns a v2-shape dashboard.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(dashboardtypes.GettableDashboardV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.UpdateV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "UpdateDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Update dashboard (v2)",
|
||||
Description: "This endpoint updates a v2-shape dashboard's metadata, data, and tag set. Locked dashboards are rejected.",
|
||||
Request: new(dashboardtypes.UpdatableDashboardV2),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(dashboardtypes.GettableDashboardV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbUpdate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPut).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.UpdateV2), handler.OpenAPIDef{
|
||||
ID: "UpdateDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Update dashboard (v2)",
|
||||
Description: "This endpoint updates a v2-shape dashboard's metadata, data, and tag set. Locked dashboards are rejected.",
|
||||
Request: new(dashboardtypes.UpdatableDashboardV2),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(dashboardtypes.GettableDashboardV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.PatchV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "PatchDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Patch dashboard (v2)",
|
||||
Description: "This endpoint applies an RFC 6902 JSON Patch to a v2-shape dashboard. The patch is applied against the postable view of the dashboard (metadata, data, tags), so individual panels, queries, variables, layouts, or tags can be updated without re-sending the rest of the dashboard. Apply is lenient: `remove` on a missing path is a no-op (idempotent) and `add` creates any missing parent objects, rather than failing as strict RFC 6902 would. The resulting dashboard is still validated. Locked dashboards are rejected.",
|
||||
Request: new(dashboardtypes.PatchableDashboardV2),
|
||||
// Strictly per RFC 6902 the content type is `application/json-patch+json`,
|
||||
// but our OpenAPI generator only reflects schemas for content types it
|
||||
// understands (application/json, form-urlencoded, multipart) — anything
|
||||
// else degrades to `type: string`. Declaring application/json here keeps
|
||||
// the array-of-ops schema visible to spec consumers; the runtime decoder
|
||||
// parses JSON regardless of the request's actual Content-Type header.
|
||||
RequestContentType: "application/json",
|
||||
Response: new(dashboardtypes.GettableDashboardV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbUpdate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPatch).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.PatchV2), handler.OpenAPIDef{
|
||||
ID: "PatchDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Patch dashboard (v2)",
|
||||
Description: "This endpoint applies an RFC 6902 JSON Patch to a v2-shape dashboard. The patch is applied against the postable view of the dashboard (metadata, data, tags), so individual panels, queries, variables, layouts, or tags can be updated without re-sending the rest of the dashboard. Apply is lenient: `remove` on a missing path is a no-op (idempotent) and `add` creates any missing parent objects, rather than failing as strict RFC 6902 would. The resulting dashboard is still validated. Locked dashboards are rejected.",
|
||||
Request: new(dashboardtypes.PatchableDashboardV2),
|
||||
// Strictly per RFC 6902 the content type is `application/json-patch+json`,
|
||||
// but our OpenAPI generator only reflects schemas for content types it
|
||||
// understands (application/json, form-urlencoded, multipart) — anything
|
||||
// else degrades to `type: string`. Declaring application/json here keeps
|
||||
// the array-of-ops schema visible to spec consumers; the runtime decoder
|
||||
// parses JSON regardless of the request's actual Content-Type header.
|
||||
RequestContentType: "application/json",
|
||||
Response: new(dashboardtypes.GettableDashboardV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPatch).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.DeleteV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "DeleteDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Delete dashboard (v2)",
|
||||
Description: "This endpoint deletes a v2-shape dashboard along with its tag relations. Locked dashboards are rejected.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbDelete)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbDelete,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.DeleteV2), handler.OpenAPIDef{
|
||||
ID: "DeleteDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Delete dashboard (v2)",
|
||||
Description: "This endpoint deletes a v2-shape dashboard along with its tag relations. Locked dashboards are rejected.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards/{id}/lock", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.LockV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "LockDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Lock dashboard (v2)",
|
||||
Description: "This endpoint locks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbUpdate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPut).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/dashboards/{id}/lock", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.LockV2), handler.OpenAPIDef{
|
||||
ID: "LockDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Lock dashboard (v2)",
|
||||
Description: "This endpoint locks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards/{id}/lock", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.UnlockV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "UnlockDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Unlock dashboard (v2)",
|
||||
Description: "This endpoint unlocks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbUpdate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/dashboards/{id}/lock", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.UnlockV2), handler.OpenAPIDef{
|
||||
ID: "UnlockDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Unlock dashboard (v2)",
|
||||
Description: "This endpoint unlocks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Pinning mutates the calling user's pin list, not the dashboard, so it rides
|
||||
// on the collection-level list permission rather than a per-dashboard check.
|
||||
// The id is still extracted, for audit.
|
||||
if err := router.Handle("/api/v2/users/me/dashboards/{id}/pins", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.PinV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "PinDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Pin a dashboard for the current user (v2)",
|
||||
Description: "Pins the dashboard for the calling user. A user can pin at most 10 dashboards; pinning when at the limit returns 409. Re-pinning an already-pinned dashboard is a no-op success.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPut).GetError(); err != nil {
|
||||
// ViewAccess: pinning only mutates the calling user's pin list, not the
|
||||
// dashboard itself — anyone who can view a dashboard can bookmark it.
|
||||
if err := router.Handle("/api/v2/users/me/dashboards/{id}/pins", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.PinV2), handler.OpenAPIDef{
|
||||
ID: "PinDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Pin a dashboard for the current user (v2)",
|
||||
Description: "Pins the dashboard for the calling user. A user can pin at most 10 dashboards; pinning when at the limit returns 409. Re-pinning an already-pinned dashboard is a no-op success.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/users/me/dashboards/{id}/pins", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.UnpinV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "UnpinDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Unpin a dashboard for the current user (v2)",
|
||||
Description: "Removes the pin for the calling user. Idempotent — unpinning a dashboard that wasn't pinned still returns 204.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/users/me/dashboards/{id}/pins", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.UnpinV2), handler.OpenAPIDef{
|
||||
ID: "UnpinDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Unpin a dashboard for the current user (v2)",
|
||||
Description: "Removes the pin for the calling user. Idempotent — unpinning a dashboard that wasn't pinned still returns 204.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Saved views hold dashboard listing state (query, sort, order) rather than any
|
||||
// one dashboard, so all four ride on the collection-level list permission.
|
||||
if err := router.Handle("/api/v2/dashboard_views", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.ListViews, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "ListDashboardViews",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "List dashboard saved views",
|
||||
Description: "Returns every saved view in the calling user's org. Saved views are shared org-wide.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(dashboardtypes.ListableDashboardView),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/dashboard_views", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.ListViews), handler.OpenAPIDef{
|
||||
ID: "ListDashboardViews",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "List dashboard saved views",
|
||||
Description: "Returns every saved view in the calling user's org. Saved views are shared org-wide.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(dashboardtypes.ListableDashboardView),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboard_views", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.CreateView, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CreateDashboardView",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Create dashboard saved view",
|
||||
Description: "Persists the calling user's dashboard listing state (query, sort, order) as a named, reusable view shared across the org.",
|
||||
Request: new(dashboardtypes.PostableDashboardView),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(dashboardtypes.DashboardView),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/dashboard_views", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.CreateView), handler.OpenAPIDef{
|
||||
ID: "CreateDashboardView",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Create dashboard saved view",
|
||||
Description: "Persists the calling user's dashboard listing state (query, sort, order) as a named, reusable view shared across the org.",
|
||||
Request: new(dashboardtypes.PostableDashboardView),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(dashboardtypes.DashboardView),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboard_views/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.UpdateView, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "UpdateDashboardView",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Update dashboard saved view",
|
||||
Description: "Replaces a saved view's name and data. Saved views are shared org-wide.",
|
||||
Request: new(dashboardtypes.UpdatableDashboardView),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(dashboardtypes.DashboardView),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPut).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/dashboard_views/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.UpdateView), handler.OpenAPIDef{
|
||||
ID: "UpdateDashboardView",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Update dashboard saved view",
|
||||
Description: "Replaces a saved view's name and data. Saved views are shared org-wide.",
|
||||
Request: new(dashboardtypes.UpdatableDashboardView),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(dashboardtypes.DashboardView),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboard_views/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.DeleteView, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "DeleteDashboardView",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Delete dashboard saved view",
|
||||
Description: "Removes a saved view. Saved views are shared org-wide. Deleting a non-existent view returns 404.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/dashboard_views/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.DeleteView), handler.OpenAPIDef{
|
||||
ID: "DeleteDashboardView",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Delete dashboard saved view",
|
||||
Description: "Removes a saved view. Saved views are shared org-wide. Deleting a non-existent view returns 404.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/dashboards/{id}/public", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.CreatePublic, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CreatePublicDashboard",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Create public dashboard",
|
||||
Description: "This endpoint creates public sharing config and enables public sharing of the dashboard",
|
||||
Request: new(dashboardtypes.PostablePublicDashboard),
|
||||
RequestContentType: "",
|
||||
Response: new(types.Identifiable),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbUpdate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/dashboards/{id}/public", handler.New(provider.authzMiddleware.AdminAccess(provider.dashboardHandler.CreatePublic), handler.OpenAPIDef{
|
||||
ID: "CreatePublicDashboard",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Create public dashboard",
|
||||
Description: "This endpoint creates public sharing config and enables public sharing of the dashboard",
|
||||
Request: new(dashboardtypes.PostablePublicDashboard),
|
||||
RequestContentType: "",
|
||||
Response: new(types.Identifiable),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/dashboards/{id}/public", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.GetPublic, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetPublicDashboard",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Get public dashboard",
|
||||
Description: "This endpoint returns public sharing config for a dashboard",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(dashboardtypes.GettablePublicDasbhboard),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/dashboards/{id}/public", handler.New(provider.authzMiddleware.AdminAccess(provider.dashboardHandler.GetPublic), handler.OpenAPIDef{
|
||||
ID: "GetPublicDashboard",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Get public dashboard",
|
||||
Description: "This endpoint returns public sharing config for a dashboard",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(dashboardtypes.GettablePublicDasbhboard),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/dashboards/{id}/public", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.UpdatePublic, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "UpdatePublicDashboard",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Update public dashboard",
|
||||
Description: "This endpoint updates the public sharing config for a dashboard",
|
||||
Request: new(dashboardtypes.UpdatablePublicDashboard),
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbUpdate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPut).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/dashboards/{id}/public", handler.New(provider.authzMiddleware.AdminAccess(provider.dashboardHandler.UpdatePublic), handler.OpenAPIDef{
|
||||
ID: "UpdatePublicDashboard",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Update public dashboard",
|
||||
Description: "This endpoint updates the public sharing config for a dashboard",
|
||||
Request: new(dashboardtypes.UpdatablePublicDashboard),
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/dashboards/{id}/public", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.DeletePublic, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "DeletePublicDashboard",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Delete public dashboard",
|
||||
Description: "This endpoint deletes the public sharing config and disables the public sharing of a dashboard",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbUpdate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/dashboards/{id}/public", handler.New(provider.authzMiddleware.AdminAccess(provider.dashboardHandler.DeletePublic), handler.OpenAPIDef{
|
||||
ID: "DeletePublicDashboard",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Delete public dashboard",
|
||||
Description: "This endpoint deletes the public sharing config and disables the public sharing of a dashboard",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -234,7 +234,6 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewUpdateRoleTransactionGroupsFactory(),
|
||||
sqlmigration.NewFillDashboardSpecCollectionsFactory(sqlstore, dashboardStore),
|
||||
sqlmigration.NewScrubEmailChannelTransportFactory(sqlstore),
|
||||
sqlmigration.NewAddDashboardTuplesFactory(sqlstore),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/dialect"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type addDashboardTuples struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewAddDashboardTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("add_dashboard_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &addDashboardTuples{sqlstore: sqlstore}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *addDashboardTuples) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
func (migration *addDashboardTuples) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var storeID string
|
||||
err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var orgIDs []string
|
||||
rows, err := tx.QueryContext(ctx, `SELECT id FROM organizations`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var orgID string
|
||||
if err := rows.Scan(&orgID); err != nil {
|
||||
return err
|
||||
}
|
||||
orgIDs = append(orgIDs, orgID)
|
||||
}
|
||||
|
||||
isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG
|
||||
|
||||
// The dashboard routes, including the public sharing config, now authorize per
|
||||
// resource, so every org needs the managed-role dashboard tuples. Orgs
|
||||
// bootstrapped before these entries landed in ManagedRoleToTransactions never
|
||||
// got them.
|
||||
tuples := []migrationTuple{
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "dashboard", "read"},
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "dashboard", "update"},
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "dashboard", "delete"},
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "dashboard", "create"},
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "dashboard", "list"},
|
||||
{authtypes.SigNozEditorRoleName, "metaresource", "dashboard", "read"},
|
||||
{authtypes.SigNozEditorRoleName, "metaresource", "dashboard", "update"},
|
||||
{authtypes.SigNozEditorRoleName, "metaresource", "dashboard", "delete"},
|
||||
{authtypes.SigNozEditorRoleName, "metaresource", "dashboard", "create"},
|
||||
{authtypes.SigNozEditorRoleName, "metaresource", "dashboard", "list"},
|
||||
{authtypes.SigNozViewerRoleName, "metaresource", "dashboard", "read"},
|
||||
{authtypes.SigNozViewerRoleName, "metaresource", "dashboard", "list"},
|
||||
}
|
||||
|
||||
for _, orgID := range orgIDs {
|
||||
for _, tuple := range tuples {
|
||||
entropy := ulid.DefaultEntropy()
|
||||
now := time.Now().UTC()
|
||||
tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String()
|
||||
|
||||
objectID := "organization/" + orgID + "/" + tuple.objectName + "/*"
|
||||
roleSubject := "organization/" + orgID + "/role/" + tuple.roleName
|
||||
|
||||
if isPG {
|
||||
user := "role:" + roleSubject + "#assignee"
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, user, "userset", tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
continue
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, user, 0, tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", "userset", tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
continue
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", 0, tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *addDashboardTuples) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
@@ -54,7 +54,7 @@ var (
|
||||
ResourceMetaResourceCloudIntegration = NewResourceMetaResource(KindCloudIntegration)
|
||||
ResourceMetaResourceCloudIntegrationService = NewResourceMetaResource(KindCloudIntegrationService)
|
||||
ResourceMetaResourceIntegration = NewResourceMetaResource(KindIntegration)
|
||||
ResourceMetaResourceDashboard = NewResourceMetaResource(KindDashboard, VerbCreate, VerbList, VerbRead, VerbUpdate, VerbDelete)
|
||||
ResourceMetaResourceDashboard = NewResourceMetaResource(KindDashboard)
|
||||
ResourceMetaResourcePublicDashboard = NewResourceMetaResource(KindPublicDashboard)
|
||||
ResourceMetaResourceIngestionKey = NewResourceMetaResource(KindIngestionKey)
|
||||
ResourceMetaResourceIngestionLimit = NewResourceMetaResource(KindIngestionLimit)
|
||||
|
||||
299
tests/e2e/helpers/dashboards-v2.ts
Normal file
299
tests/e2e/helpers/dashboards-v2.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import type { APIRequestContext, Locator, Page } from '@playwright/test';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
import { authToken } from './dashboards';
|
||||
|
||||
// Helpers for the V2 dashboard detail page (`DashboardPageV2`), which now serves
|
||||
// /dashboard/:id unconditionally. The V1 helpers in ./dashboards.ts still cover
|
||||
// seeding through the v1 API and the list page.
|
||||
//
|
||||
// Interaction contracts encoded here rather than in each spec:
|
||||
// - a multi-select variable commits on dropdown CLOSE, not per toggle;
|
||||
// - an ALL selection renders as an overlay reading "ALL", not as tags;
|
||||
// - a variable is only settled once its options have arrived.
|
||||
|
||||
export const dashboardV2Path = (id: string): string => `/dashboard/${id}`;
|
||||
|
||||
/** Perses-style spec the v2 API stores. Only what the specs need is typed. */
|
||||
export interface DashboardV2Spec {
|
||||
display: { name: string; description?: string };
|
||||
layouts: unknown[];
|
||||
panels: Record<string, unknown>;
|
||||
variables: unknown[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export const SCHEMA_VERSION = 'v6';
|
||||
|
||||
/** An empty but valid spec — the base every fixture spreads over. */
|
||||
export function emptyV2Spec(name: string): DashboardV2Spec {
|
||||
return { display: { name }, layouts: [], panels: {}, variables: [] };
|
||||
}
|
||||
|
||||
// ─── Seeding through the v2 API ───────────────────────────────────────────
|
||||
//
|
||||
// Specs seed the shape they assert against, rather than relying on whatever the
|
||||
// v1 -> v2 migration happens to produce or on telemetry that ambient data may or
|
||||
// may not contain. Migration output is covered on its own, from the v1 fixtures.
|
||||
|
||||
export async function createDashboardV2ViaApi(
|
||||
page: Page,
|
||||
name: string,
|
||||
spec?: Partial<DashboardV2Spec>,
|
||||
): Promise<string> {
|
||||
const token = await authToken(page);
|
||||
const res = await page.request.post('/api/v2/dashboards', {
|
||||
data: {
|
||||
name,
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
tags: [],
|
||||
// `name` wins over any display name the fixture carries, so a fixture can be
|
||||
// seeded twice under two titles and each spec can still find its own.
|
||||
spec: {
|
||||
...emptyV2Spec(name),
|
||||
...spec,
|
||||
display: { ...spec?.display, name },
|
||||
},
|
||||
},
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`POST /api/v2/dashboards ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
const body = (await res.json()) as { data?: { id?: string } };
|
||||
const id = body.data?.id;
|
||||
if (!id) {
|
||||
throw new Error(
|
||||
`POST /api/v2/dashboards returned no id: ${JSON.stringify(body)}`,
|
||||
);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function getDashboardV2(
|
||||
page: Page,
|
||||
id: string,
|
||||
): Promise<{ spec: DashboardV2Spec; [key: string]: unknown }> {
|
||||
const token = await authToken(page);
|
||||
const res = await page.request.get(`/api/v2/dashboards/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`GET /api/v2/dashboards/${id} ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
const body = (await res.json()) as {
|
||||
data: { spec: DashboardV2Spec; [key: string]: unknown };
|
||||
};
|
||||
return body.data;
|
||||
}
|
||||
|
||||
export async function deleteDashboardV2ViaApi(
|
||||
request: APIRequestContext,
|
||||
id: string,
|
||||
token: string,
|
||||
): Promise<void> {
|
||||
await request.delete(`/api/v2/dashboards/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Variables bar ────────────────────────────────────────────────────────
|
||||
|
||||
export const variablesBar = (page: Page): Locator =>
|
||||
page.getByTestId('dashboard-variables-bar');
|
||||
|
||||
/** The pill for one variable: its name, the control, and (while loading) a spinner. */
|
||||
export const variablePill = (page: Page, name: string): Locator =>
|
||||
page.getByTestId(`variable-${name}`);
|
||||
|
||||
/** List variables (query / custom / dynamic) — the select control. */
|
||||
export const variableControl = (page: Page, name: string): Locator =>
|
||||
page.getByTestId(`variable-select-${name}`);
|
||||
|
||||
/** Text variables — a plain input, not a select. */
|
||||
export const variableTextInput = (page: Page, name: string): Locator =>
|
||||
page.getByTestId(`variable-input-${name}`);
|
||||
|
||||
/**
|
||||
* The bar collapses variables that do not fit into a "+N" button. At the config's
|
||||
* 1280px viewport that starts with the second variable, so a spec asserting on
|
||||
* several pills at once must widen the viewport:
|
||||
*
|
||||
* test.use({ viewport: WIDE_VIEWPORT });
|
||||
*/
|
||||
export const WIDE_VIEWPORT = { width: 1920, height: 1080 };
|
||||
|
||||
/**
|
||||
* The overflow ("+N") tooltip listing the collapsed variables. `.first()` because the
|
||||
* tooltip primitive renders its content twice — once visible, once as an a11y copy —
|
||||
* so an unscoped locator is a strict-mode violation rather than a missing element.
|
||||
*/
|
||||
export const hiddenVariablesTooltip = (page: Page): Locator =>
|
||||
page.getByTestId('hidden-variables-tooltip').first();
|
||||
|
||||
/** Resolved when the variable's options have arrived and its spinner is gone. */
|
||||
export async function awaitVariableSettled(
|
||||
page: Page,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
await expect(variablePill(page, name)).toBeVisible();
|
||||
await expect(page.getByTestId(`variable-loading-${name}`)).toBeHidden();
|
||||
}
|
||||
|
||||
/**
|
||||
* What a list variable's closed control shows — "ALL" for an ALL selection, else its
|
||||
* tags. Asserts the control exists first: a missing one (wrong name, or a text
|
||||
* variable, which uses {@link variableTextInput}) otherwise hangs until the test
|
||||
* times out with nothing to point at.
|
||||
*/
|
||||
export async function readVariableSelection(
|
||||
page: Page,
|
||||
name: string,
|
||||
): Promise<string> {
|
||||
const pill = variablePill(page, name);
|
||||
await expect(pill).toBeVisible();
|
||||
// The ALL overlay sits in the control's wrapper, as a SIBLING of the element
|
||||
// carrying the testid — scope from the pill, or an ALL selection reads as empty.
|
||||
const allOverlay = pill.locator('.all-text');
|
||||
if ((await allOverlay.count()) > 0 && (await allOverlay.isVisible())) {
|
||||
return (await allOverlay.textContent())?.trim() ?? '';
|
||||
}
|
||||
return (await variableControl(page, name).innerText()).trim();
|
||||
}
|
||||
|
||||
/** The open option list, whichever control opened it. */
|
||||
export const anyDropdown = (page: Page): Locator =>
|
||||
page.locator('.custom-multiselect-dropdown, .custom-select-dropdown');
|
||||
|
||||
export async function openVariableDropdown(
|
||||
page: Page,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
await awaitVariableSettled(page, name);
|
||||
await variableControl(page, name).click();
|
||||
await expect(anyDropdown(page)).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the open dropdown, which is what commits a multi-select edit. Pressing
|
||||
* Escape leaves the control focused without re-opening it, unlike clicking away.
|
||||
*/
|
||||
export async function closeVariableDropdown(page: Page): Promise<void> {
|
||||
// Escape closes it when the control still holds focus, which a row click can move.
|
||||
// Falling back to a click outside covers that, and is what a user does anyway —
|
||||
// either way the close is what commits the edit.
|
||||
await page.keyboard.press('Escape');
|
||||
if (await anyDropdown(page).first().isVisible()) {
|
||||
await page.getByTestId('dashboard-title').click();
|
||||
}
|
||||
await expect(anyDropdown(page).first()).toBeHidden();
|
||||
}
|
||||
|
||||
const escapeForRegExp = (value: string): string =>
|
||||
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
/**
|
||||
* One option row in the open dropdown, matched on its label element rather than the
|
||||
* row's accessible name: hovering reveals the Only / Toggle buttons, whose text joins
|
||||
* that name, so a name-based locator stops matching halfway through an interaction.
|
||||
*/
|
||||
export function optionRow(page: Page, value: string): Locator {
|
||||
const exact = new RegExp(`^${escapeForRegExp(value)}$`);
|
||||
// Multi-select rows carry a `.option-label-text` and, once hovered, Only / Toggle
|
||||
// buttons whose text would break an exact match on the row itself. Single-select
|
||||
// rows have neither — just the label — so each shape needs its own matcher.
|
||||
const multi = page
|
||||
.locator('.custom-multiselect-dropdown .option-item')
|
||||
.filter({ has: page.locator('.option-label-text', { hasText: exact }) });
|
||||
const single = page
|
||||
.locator('.custom-select-dropdown .option-item')
|
||||
.filter({ hasText: exact });
|
||||
return multi.or(single);
|
||||
}
|
||||
|
||||
export async function pickVariableValues(
|
||||
page: Page,
|
||||
name: string,
|
||||
values: string[],
|
||||
): Promise<void> {
|
||||
await openVariableDropdown(page, name);
|
||||
const [first, ...rest] = values;
|
||||
|
||||
// Start from the row's "Only" button rather than its checkbox: an ALL selection
|
||||
// opens with every option checked, so a click would UNcheck the wanted value and
|
||||
// leave the rest selected. "Only" collapses to exactly this option either way,
|
||||
// and the clear icon is deliberately unavailable while the draft is all.
|
||||
// Wait for each target to be visible before acting: the dropdown re-renders as
|
||||
// options resolve, and a click on a row that is still arriving (or has just been
|
||||
// replaced) hangs until the test times out.
|
||||
const firstRow = optionRow(page, first);
|
||||
await expect(firstRow).toBeVisible();
|
||||
await firstRow.hover();
|
||||
const onlyButton = firstRow.locator('.only-btn');
|
||||
await expect(onlyButton).toBeVisible();
|
||||
await onlyButton.click();
|
||||
|
||||
// Additional values then add to that selection. Click the row's checkbox, not the
|
||||
// row: the row body carries no toggle handler, so clicking it leaves the option
|
||||
// unchecked and the value silently absent from the commit.
|
||||
for (const value of rest) {
|
||||
const row = optionRow(page, value);
|
||||
await expect(row).toBeVisible();
|
||||
const checkbox = row.locator('.option-checkbox');
|
||||
if ((await checkbox.count()) > 0) {
|
||||
await checkbox.first().click();
|
||||
} else {
|
||||
await row.click();
|
||||
}
|
||||
}
|
||||
await closeVariableDropdown(page);
|
||||
}
|
||||
|
||||
/** Type a value the option list does not offer, and commit it. */
|
||||
export async function typeVariableValue(
|
||||
page: Page,
|
||||
name: string,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
await openVariableDropdown(page, name);
|
||||
await page.keyboard.type(value);
|
||||
const dropdown = page.locator('.custom-multiselect-dropdown');
|
||||
await dropdown.getByText(value, { exact: true }).first().click();
|
||||
await closeVariableDropdown(page);
|
||||
}
|
||||
|
||||
// ─── Panels and sections ──────────────────────────────────────────────────
|
||||
|
||||
export const panelByTitle = (page: Page, title: string): Locator =>
|
||||
page.locator('[data-panel-id]').filter({ hasText: title });
|
||||
|
||||
export const sectionByName = (page: Page, name: string): Locator =>
|
||||
page.locator('[data-section-id]').filter({ hasText: name });
|
||||
|
||||
/** Resolved when no panel on the page is still fetching. */
|
||||
export async function awaitPanelsSettled(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId('panel-refetching')).toHaveCount(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The values currently checked in a multi-select's list, read from the open dropdown —
|
||||
* the closed control shows at most one tag plus a "+N", so it cannot confirm a
|
||||
* multi-value selection on its own. Excludes the aggregate ALL row.
|
||||
*/
|
||||
export async function readCheckedOptions(
|
||||
page: Page,
|
||||
name: string,
|
||||
): Promise<string[]> {
|
||||
await openVariableDropdown(page, name);
|
||||
const labels = await page
|
||||
.locator(
|
||||
'.custom-multiselect-dropdown .option-item[aria-selected="true"]:not(.all-option) .option-label-text',
|
||||
)
|
||||
.allInnerTexts();
|
||||
await closeVariableDropdown(page);
|
||||
return labels.map((label) => label.trim());
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
"fmt:check": "oxfmt --check .",
|
||||
"lint": "oxlint .",
|
||||
"lint:fix": "oxlint . --fix",
|
||||
"guard:specs": "node scripts/guard-specs.mjs",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"keywords": [
|
||||
|
||||
20
tests/e2e/parked-specs.json
Normal file
20
tests/e2e/parked-specs.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$comment": "Specs not currently running, and why. This is the ONLY place a spec may be excluded from the suite: `playwright.config.ts` feeds `specs` to `testIgnore`, and `pnpm guard:specs` fails if any spec that is NOT listed here contains a skipped, fixme'd or .only test. So a spec is either running and complete, or parked here with a reason — nothing rots quietly in between. Every entry is removed by the PR that migrates it; the list only shrinks.",
|
||||
"specs": [
|
||||
"**/tests/dashboards/list.spec.ts",
|
||||
"**/tests/dashboards/details/03-viewing.spec.ts",
|
||||
"**/tests/dashboards/details/12-sections.spec.ts",
|
||||
"**/tests/dashboards/details/21-panel-actions.spec.ts",
|
||||
"**/tests/dashboards/details/35-add-panel.spec.ts",
|
||||
"**/tests/dashboards/details/44-edit-panel.spec.ts",
|
||||
"**/tests/dashboards/details/56-time-range.spec.ts",
|
||||
"**/tests/dashboards/details/67-variables.spec.ts",
|
||||
"**/tests/dashboards/details/78-edit-mode.spec.ts",
|
||||
"**/tests/dashboards/details/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."
|
||||
}
|
||||
}
|
||||
@@ -2,22 +2,23 @@ import { defineConfig, devices } from '@playwright/test';
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
|
||||
// .env holds user-provided defaults (staging creds).
|
||||
// .env.local is written by tests/e2e/bootstrap/setup.py when the pytest
|
||||
// lifecycle brings the backend up locally; override=true so local-backend
|
||||
// coordinates win over any stale .env values. Subprocess-injected env
|
||||
// (e.g. when pytest shells out to `pnpm test`) still takes priority —
|
||||
// dotenv doesn't touch vars that are already set in process.env.
|
||||
import parkedSpecs from './parked-specs.json';
|
||||
|
||||
// Precedence: real env > .env.local > .env. dotenv never overwrites a var that is
|
||||
// already set, so loading in that order gives local-backend coordinates (.env.local,
|
||||
// written by bootstrap/setup.py) priority over the staging defaults in .env, while an
|
||||
// explicitly exported var still wins over both — which is what lets a run be pointed
|
||||
// at another environment without editing a generated file.
|
||||
dotenv.config({ path: path.resolve(__dirname, '.env.local') });
|
||||
dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||
dotenv.config({ path: path.resolve(__dirname, '.env.local'), override: true });
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
|
||||
// Temporarily excluded: the V1 -> V2 dashboard migration changes the
|
||||
// behaviour the dashboards specs assert against, so they fail as written.
|
||||
// Remove this once they are updated for the V2 dashboard.
|
||||
testIgnore: ['**/tests/dashboards/**'],
|
||||
// Parked specs, listed one by one with a reason in parked-specs.json — not a
|
||||
// blanket glob, so nothing new can land inside an excluded directory unnoticed.
|
||||
// `pnpm guard:specs` keeps this list and the suite honest.
|
||||
testIgnore: parkedSpecs.specs,
|
||||
|
||||
// All Playwright output lands under artifacts/. One subdir per reporter
|
||||
// plus results/ for per-test artifacts (traces/screenshots/videos).
|
||||
|
||||
91
tests/e2e/scripts/guard-specs.mjs
Normal file
91
tests/e2e/scripts/guard-specs.mjs
Normal file
@@ -0,0 +1,91 @@
|
||||
// Keeps the suite honest: a spec is either running and complete, or parked in
|
||||
// parked-specs.json with a reason. Fails on a skipped/fixme'd/only test in a spec that
|
||||
// is not parked, and on a parked entry that no longer matches anything.
|
||||
//
|
||||
// Run: pnpm guard:specs
|
||||
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { dirname, join, relative, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const parked = JSON.parse(
|
||||
readFileSync(join(root, 'parked-specs.json'), 'utf8'),
|
||||
);
|
||||
|
||||
/** Every *.spec.ts under tests/, repo-relative with forward slashes. */
|
||||
function specFiles(dir) {
|
||||
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
return specFiles(full);
|
||||
}
|
||||
return entry.name.endsWith('.spec.ts') ? [full] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** A parked glob (`**/tests/x/y.spec.ts`) matched against an absolute path. */
|
||||
function matchesGlob(glob, absolutePath) {
|
||||
const ANY_DIRS = '\u0000';
|
||||
const pattern = glob
|
||||
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
||||
.replace(/\*\*\//g, ANY_DIRS)
|
||||
// Single `*` never crosses a path separator; do this before expanding ANY_DIRS,
|
||||
// whose replacement itself contains a `*`.
|
||||
.replace(/\*/g, '[^/]*')
|
||||
.split(ANY_DIRS)
|
||||
.join('(?:.*/)?');
|
||||
return new RegExp(`^${pattern}$`).test(absolutePath.split('\\').join('/'));
|
||||
}
|
||||
|
||||
// Declaration form only — `test.skip(condition, reason)` inside a test body is a
|
||||
// legitimate runtime guard, not a parked test.
|
||||
const OFFENDERS = [
|
||||
{ label: 'test.skip', re: /(?<![\w.])test\.skip\(\s*['"`]/g },
|
||||
{ label: 'test.fixme', re: /(?<![\w.])test\.fixme\(\s*['"`]/g },
|
||||
{ label: 'describe.skip', re: /describe\.skip\(/g },
|
||||
{ label: 'describe.fixme', re: /describe\.fixme\(/g },
|
||||
{ label: 'test.only', re: /(?<![\w.])test\.only\(/g },
|
||||
{ label: 'describe.only', re: /describe\.only\(/g },
|
||||
];
|
||||
|
||||
const testsDir = join(root, 'tests');
|
||||
const files = statSync(testsDir, { throwIfNoEntry: false })
|
||||
? specFiles(testsDir)
|
||||
: [];
|
||||
const failures = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (parked.specs.some((glob) => matchesGlob(glob, file))) {
|
||||
continue;
|
||||
}
|
||||
const source = readFileSync(file, 'utf8');
|
||||
for (const { label, re } of OFFENDERS) {
|
||||
const hits = source.match(re);
|
||||
if (hits) {
|
||||
failures.push(
|
||||
`${relative(root, file)}: ${hits.length} × ${label} — finish it, or park the spec in parked-specs.json with a reason`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const glob of parked.specs) {
|
||||
if (!files.some((file) => matchesGlob(glob, file))) {
|
||||
failures.push(
|
||||
`parked-specs.json: "${glob}" matches no spec — drop the stale entry`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(
|
||||
`\nguard:specs failed\n\n${failures.map((f) => ` ✗ ${f}`).join('\n')}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`guard:specs ok — ${files.length - parked.specs.length}/${files.length} specs running, ${parked.specs.length} parked`,
|
||||
);
|
||||
48
tests/e2e/testdata/variables-dashboard-v2.json
vendored
Normal file
48
tests/e2e/testdata/variables-dashboard-v2.json
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"$comment": "V2 (Perses-shape) dashboard spec seeded through POST /api/v2/dashboards. Only text and custom variables, so it resolves without telemetry: option lists are fixed by the definition, which keeps assertions on them deterministic. Query and dynamic variables are seeded per-spec alongside the telemetry they need.",
|
||||
"spec": {
|
||||
"display": { "name": "variables-v2", "description": "" },
|
||||
"layouts": [],
|
||||
"panels": {},
|
||||
"variables": [
|
||||
{
|
||||
"kind": "TextVariable",
|
||||
"spec": {
|
||||
"name": "tb_env",
|
||||
"display": { "name": "tb_env", "description": "Free-text environment" },
|
||||
"value": "prod",
|
||||
"constant": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "ListVariable",
|
||||
"spec": {
|
||||
"name": "cu_service",
|
||||
"display": { "name": "cu_service", "description": "Multi-select with ALL" },
|
||||
"allowMultiple": true,
|
||||
"allowAllValue": true,
|
||||
"sort": "none",
|
||||
"plugin": {
|
||||
"kind": "signoz/CustomVariable",
|
||||
"spec": { "customValue": "checkout,payments,cart" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "ListVariable",
|
||||
"spec": {
|
||||
"name": "cu_region",
|
||||
"display": { "name": "cu_region", "description": "Single-select with a default" },
|
||||
"allowMultiple": false,
|
||||
"allowAllValue": false,
|
||||
"sort": "none",
|
||||
"defaultValue": "eu-west",
|
||||
"plugin": {
|
||||
"kind": "signoz/CustomVariable",
|
||||
"spec": { "customValue": "us-east,eu-west" }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
110
tests/e2e/tests/dashboards/details/01-smoke.spec.ts
Normal file
110
tests/e2e/tests/dashboards/details/01-smoke.spec.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import { authToken } from '../../../helpers/dashboards';
|
||||
import {
|
||||
createDashboardV2ViaApi,
|
||||
dashboardV2Path,
|
||||
deleteDashboardV2ViaApi,
|
||||
pickVariableValues,
|
||||
readVariableSelection,
|
||||
variablePill,
|
||||
variablesBar,
|
||||
variableTextInput,
|
||||
WIDE_VIEWPORT,
|
||||
} from '../../../helpers/dashboards-v2';
|
||||
import customVariables from '../../../testdata/variables-dashboard-v2.json';
|
||||
|
||||
// The foundation the other dashboards specs build on: seeding a V2 spec through the
|
||||
// v2 API, opening it, and driving the variables bar. Everything here is deterministic
|
||||
// — custom and text variables need no telemetry, so this spec cannot go red because
|
||||
// of what the stack happens to hold.
|
||||
|
||||
test.use({ viewport: WIDE_VIEWPORT });
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
let dashboardId = '';
|
||||
|
||||
// Per worker: `beforeAll` runs once in each, and the v2 API rejects a duplicate name.
|
||||
const SUITE_TITLE = `detail-smoke-suite-${process.env.TEST_WORKER_INDEX ?? '0'}`;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
dashboardId = await createDashboardV2ViaApi(
|
||||
page,
|
||||
SUITE_TITLE,
|
||||
customVariables.spec,
|
||||
);
|
||||
seedIds.add(dashboardId);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of seedIds) {
|
||||
await deleteDashboardV2ViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Dashboard detail — V2 foundation', () => {
|
||||
test('TC-01 a seeded V2 dashboard opens with its title and variables bar', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(dashboardV2Path(dashboardId));
|
||||
|
||||
await expect(page.getByTestId('dashboard-title')).toContainText(SUITE_TITLE);
|
||||
await expect(variablesBar(page)).toBeVisible();
|
||||
for (const name of ['tb_env', 'cu_service', 'cu_region']) {
|
||||
await expect(variablePill(page, name)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-02 a text variable renders the value it was seeded with', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(dashboardV2Path(dashboardId));
|
||||
|
||||
await expect(variableTextInput(page, 'tb_env')).toHaveValue('prod');
|
||||
});
|
||||
|
||||
test('TC-03 an ALL-enabled multi-select reads ALL until a value is picked', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(dashboardV2Path(dashboardId));
|
||||
|
||||
// Seeded with allowAllValue and no default, so it resolves to ALL.
|
||||
await expect
|
||||
.poll(() => readVariableSelection(page, 'cu_service'))
|
||||
.toBe('ALL');
|
||||
|
||||
// A multi-select commits when the dropdown closes, not per toggle.
|
||||
await pickVariableValues(page, 'cu_service', ['checkout']);
|
||||
|
||||
await expect
|
||||
.poll(() => readVariableSelection(page, 'cu_service'))
|
||||
.toContain('checkout');
|
||||
});
|
||||
|
||||
test('TC-04 a single-select renders its configured default', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(dashboardV2Path(dashboardId));
|
||||
|
||||
await expect
|
||||
.poll(() => readVariableSelection(page, 'cu_region'))
|
||||
.toContain('eu-west');
|
||||
});
|
||||
});
|
||||
@@ -2,65 +2,67 @@ import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import { authToken } from '../../../helpers/dashboards';
|
||||
import {
|
||||
authToken,
|
||||
awaitVariablesResolved,
|
||||
createDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
createDashboardV2ViaApi,
|
||||
dashboardV2Path,
|
||||
deleteDashboardV2ViaApi,
|
||||
getDashboardV2,
|
||||
readVariableSelection,
|
||||
variablePill,
|
||||
variablesBar,
|
||||
WIDE_VIEWPORT,
|
||||
} from '../../../helpers/dashboards-v2';
|
||||
import variablesFixture from '../../../testdata/variables-dashboard-v2.json';
|
||||
|
||||
const TELEMETRY_DEPENDENT_VARS = ['q_env', 'q_service', 'd_namespace'];
|
||||
// Defining variables in dashboard settings: the list, the form, and what reaches the
|
||||
// runtime bar. Each test seeds its own dashboard, so a create or delete in one cannot
|
||||
// affect another and the file runs in parallel.
|
||||
|
||||
// `createVariablesDashboardViaApi` is added by the group-3 spec. Import lazily
|
||||
// so this file still compiles while it is missing — tests that need it skip
|
||||
// at runtime.
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const dashboardsHelpers = require('../../../helpers/dashboards') as {
|
||||
createVariablesDashboardViaApi?: (
|
||||
page: Page,
|
||||
title: string,
|
||||
) => Promise<string>;
|
||||
};
|
||||
const hasVariablesHelper =
|
||||
typeof dashboardsHelpers.createVariablesDashboardViaApi === 'function';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
test.use({ viewport: WIDE_VIEWPORT });
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
|
||||
async function seed(page: Page, title: string): Promise<string> {
|
||||
const id = await createDashboardViaApi(page, title);
|
||||
/** Seed a dashboard carrying the shared variable fixture, and open it. */
|
||||
async function seedAndOpen(page: Page, label: string): Promise<string> {
|
||||
const id = await createDashboardV2ViaApi(
|
||||
page,
|
||||
`detail-configure-${label}-${process.env.TEST_WORKER_INDEX ?? '0'}`,
|
||||
variablesFixture.spec,
|
||||
);
|
||||
seedIds.add(id);
|
||||
await page.goto(dashboardV2Path(id));
|
||||
await expect(variablesBar(page)).toBeVisible();
|
||||
return id;
|
||||
}
|
||||
|
||||
async function seedVariablesDashboard(
|
||||
page: Page,
|
||||
title: string,
|
||||
): Promise<string> {
|
||||
if (!dashboardsHelpers.createVariablesDashboardViaApi) {
|
||||
throw new Error('createVariablesDashboardViaApi helper is not available');
|
||||
}
|
||||
const id = await dashboardsHelpers.createVariablesDashboardViaApi(page, title);
|
||||
seedIds.add(id);
|
||||
// Wait for the seeded dashboard's variables to fully resolve before any
|
||||
// caller test acts on them. Variables with defaults already have
|
||||
// `selectedValue` set; Query/Dynamic variables can't resolve without
|
||||
// telemetry and are skipped.
|
||||
await awaitVariablesResolved(page, id, {
|
||||
skipNames: TELEMETRY_DEPENDENT_VARS,
|
||||
});
|
||||
return id;
|
||||
/**
|
||||
* Open the variables list. "Add variable" in the bar lands on the blank form, whose
|
||||
* "All variables" back-link is the list — one click fewer than going through the
|
||||
* settings drawer, and it does not depend on the drawer's tab layout.
|
||||
*/
|
||||
async function openVariablesList(page: Page): Promise<void> {
|
||||
await page.getByRole('button', { name: 'Add variable' }).click();
|
||||
await page.getByTestId('variable-form-back').click();
|
||||
await expect(page.getByTestId('variables-list')).toBeVisible();
|
||||
}
|
||||
|
||||
/** Open the blank variable form straight from the bar. */
|
||||
async function openNewVariableForm(page: Page): Promise<void> {
|
||||
await page.getByRole('button', { name: 'Add variable' }).click();
|
||||
await expect(page.getByTestId('variable-name')).toBeVisible();
|
||||
}
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) return;
|
||||
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 deleteDashboardViaApi(ctx.request, id, token);
|
||||
await deleteDashboardV2ViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
@@ -68,620 +70,153 @@ test.afterAll(async ({ browser }) => {
|
||||
}
|
||||
});
|
||||
|
||||
async function openConfigureDrawer(page: Page) {
|
||||
// An empty dashboard renders an onboarding canvas with a duplicate
|
||||
// `data-testid="show-drawer"` Configure CTA alongside the toolbar one.
|
||||
// Scope to the toolbar (`.dashboard-details .right-section`) to avoid the
|
||||
// strict-mode collision.
|
||||
await page
|
||||
.locator('.dashboard-details .right-section')
|
||||
.getByTestId('show-drawer')
|
||||
.click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
return dialog;
|
||||
}
|
||||
|
||||
async function deleteVariableByName(page: Page, varName: string) {
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
const nameCell = tabpanel.getByText(varName, { exact: true }).first();
|
||||
await nameCell.hover();
|
||||
// Walk up to the surrounding row container to scope the delete-button
|
||||
// search; `.variable-item` (or the variable row container) wraps the
|
||||
// hover-revealed delete button.
|
||||
await nameCell
|
||||
.locator('xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]')
|
||||
.locator('.delete-variable-button')
|
||||
.first()
|
||||
.dispatchEvent('click');
|
||||
const confirm = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: /delete variable/i })
|
||||
.last();
|
||||
await confirm.getByRole('button', { name: 'OK' }).click();
|
||||
await expect(tabpanel.getByText(varName, { exact: true })).toHaveCount(0);
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
}
|
||||
|
||||
test.describe('Dashboard Detail — Configure drawer', () => {
|
||||
test('TC-01 Configure drawer opens with three tabs and Overview is active', async ({
|
||||
test.describe('Dashboard settings — variables', () => {
|
||||
test('TC-01 the list shows every variable the dashboard defines', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'cfg-drawer-chrome');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await seedAndOpen(page, 'list');
|
||||
await openVariablesList(page);
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
|
||||
await expect(dialog.getByText('Dashboard Configuration')).toBeVisible();
|
||||
await expect(dialog.getByRole('tab', { name: 'Overview' })).toBeVisible();
|
||||
await expect(dialog.getByRole('tab', { name: 'Variables' })).toBeVisible();
|
||||
await expect(dialog.getByRole('tab', { name: 'Publish' })).toBeVisible();
|
||||
|
||||
await expect(dialog.getByRole('tab', { name: 'Overview' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
await expect(
|
||||
dialog.getByRole('tabpanel', { name: 'Overview' }),
|
||||
).toBeVisible();
|
||||
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-02 update name, description, and tag — persists across reload', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const ts = Date.now();
|
||||
const original = `cfg-overview-save-${ts}`;
|
||||
const updated = `Configured-${ts}`;
|
||||
const id = await seed(page, original);
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
|
||||
const nameInput = dialog.getByTestId('dashboard-name');
|
||||
await nameInput.click();
|
||||
await nameInput.fill('');
|
||||
await nameInput.fill(updated);
|
||||
|
||||
await dialog.getByTestId('dashboard-desc').fill('Automated test description');
|
||||
|
||||
const tagInput = dialog.getByPlaceholder('Start typing your tag name');
|
||||
await tagInput.fill(`e2e-tag-${ts}`);
|
||||
await tagInput.press('Enter');
|
||||
|
||||
const saveBtn = dialog.getByRole('button', { name: 'Save' });
|
||||
await saveBtn.scrollIntoViewIfNeeded();
|
||||
const [putResp] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
|
||||
),
|
||||
saveBtn.click({ force: true }),
|
||||
]);
|
||||
expect(putResp.ok()).toBeTruthy();
|
||||
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${updated}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-03 Discard reverts unsaved Overview changes', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const original = 'cfg-overview-discard';
|
||||
const id = await seed(page, original);
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
const nameInput = dialog.getByTestId('dashboard-name');
|
||||
await expect(nameInput).toHaveValue(original);
|
||||
|
||||
await nameInput.fill('Temp Modified Name');
|
||||
const discard = dialog.getByRole('button', { name: 'Discard' });
|
||||
await expect(discard).toBeVisible();
|
||||
await discard.click();
|
||||
|
||||
await expect(nameInput).toHaveValue(original);
|
||||
await expect(dialog.getByRole('button', { name: 'Save' })).not.toBeVisible();
|
||||
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
});
|
||||
|
||||
test('TC-04 Variables tab lists existing variables', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not yet available (lands with group 3)',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-variables-list');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
await expect(tabpanel).toBeVisible();
|
||||
|
||||
for (const varName of [
|
||||
'tb_env',
|
||||
'tb_service',
|
||||
'cu_env_all',
|
||||
'cu_services',
|
||||
'q_env',
|
||||
'q_service',
|
||||
'd_namespace',
|
||||
]) {
|
||||
// Variable rows render as plain text inside the Variables tab
|
||||
// (not a true Antd `Table` with role="row"). Locate via text.
|
||||
await expect(
|
||||
tabpanel.getByText(varName, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
for (const name of ['tb_env', 'cu_service', 'cu_region']) {
|
||||
await expect(page.getByTestId(`variable-row-${name}`)).toBeVisible();
|
||||
}
|
||||
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
});
|
||||
|
||||
test('TC-05 add a Textbox variable — appears in the variables bar and is interactive', async ({
|
||||
test('TC-02 a new custom variable reaches the runtime bar', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not yet available (lands with group 3)',
|
||||
);
|
||||
const id = await seedAndOpen(page, 'create');
|
||||
await openNewVariableForm(page);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-variables-add-textbox');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await page.getByTestId('variable-name').fill('cu_tier');
|
||||
await page.getByTestId('variable-type-custom').click();
|
||||
await page.getByTestId('variable-custom-input').fill('gold,silver');
|
||||
await page.getByTestId('variable-save').click();
|
||||
|
||||
const ts = Date.now();
|
||||
const varName = `tb_var_${ts}`;
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
await dialog.getByTestId('add-new-variable').click();
|
||||
|
||||
await dialog.getByPlaceholder('Unique name of the variable').fill(varName);
|
||||
await dialog.getByRole('button', { name: 'Textbox' }).click();
|
||||
|
||||
const saveBtn = dialog.getByRole('button', { name: 'Save Variable' });
|
||||
await expect(saveBtn).toBeEnabled();
|
||||
await saveBtn.click({ force: true });
|
||||
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
await expect(
|
||||
tabpanel.getByText(varName, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
|
||||
await expect(page.getByText(`$${varName}`)).toBeVisible();
|
||||
|
||||
const newTextbox = page.locator('input[placeholder="Enter value"]').last();
|
||||
await newTextbox.fill('test-value');
|
||||
await newTextbox.press('Enter');
|
||||
await expect(page).toHaveURL(/test-value/);
|
||||
|
||||
await deleteVariableByName(page, varName);
|
||||
});
|
||||
|
||||
test('TC-06 add a Custom variable — appears in the list', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not yet available (lands with group 3)',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-variables-add-custom');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const ts = Date.now();
|
||||
const varName = `custom_var_${ts}`;
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
await dialog.getByTestId('add-new-variable').click();
|
||||
|
||||
await dialog.getByPlaceholder('Unique name of the variable').fill(varName);
|
||||
await dialog.getByRole('button', { name: 'Custom' }).click();
|
||||
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Save Variable' })
|
||||
.click({ force: true });
|
||||
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
await expect(
|
||||
tabpanel.getByText(varName, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
|
||||
await deleteVariableByName(page, varName);
|
||||
});
|
||||
|
||||
// known limitation: TC-07 (add a Dynamic (Beta) variable) is intentionally
|
||||
// not implemented. Dynamic variables source from the SigNoz attribute
|
||||
// index — the bootstrap stack ingests no telemetry, so the field selector
|
||||
// renders an empty option list and Save Variable can never be enabled.
|
||||
// Re-add once the bootstrap seeds telemetry attributes.
|
||||
|
||||
test('TC-08 selecting Query type renders the query editor', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not yet available (lands with group 3)',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-variables-add-query');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const ts = Date.now();
|
||||
const varName = `query_var_${ts}`;
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
await dialog.getByTestId('add-new-variable').click();
|
||||
|
||||
await dialog.getByPlaceholder('Unique name of the variable').fill(varName);
|
||||
await dialog.getByRole('button', { name: /Query/ }).click();
|
||||
|
||||
// Monaco is lazy-loaded — its bundle chunk can take several seconds to
|
||||
// arrive under parallel-worker CI load, far longer than the default 5 s
|
||||
// locator timeout. 20 s is comfortable headroom without masking real
|
||||
// regressions.
|
||||
await expect(dialog.locator('.monaco-editor').first()).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
await dialog.getByRole('button', { name: 'Discard' }).click();
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
});
|
||||
|
||||
test('TC-09 Save Variable disabled when name is empty', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'cfg-variables-empty-name');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
await dialog.getByTestId('add-new-variable').click();
|
||||
|
||||
const nameField = dialog.getByPlaceholder('Unique name of the variable');
|
||||
await expect(nameField).toHaveValue('');
|
||||
await expect(
|
||||
dialog.getByRole('button', { name: 'Save Variable' }),
|
||||
).toBeDisabled();
|
||||
|
||||
await dialog.getByRole('button', { name: 'Discard' }).click();
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
});
|
||||
|
||||
test('TC-10 Publish tab shows private message and Publish button', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'cfg-publish');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Publish' }).click();
|
||||
await expect(dialog.getByRole('tabpanel', { name: 'Publish' })).toBeVisible();
|
||||
|
||||
await expect(
|
||||
dialog.getByText(
|
||||
'This dashboard is private. Publish it to make it accessible to anyone with the link.',
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByRole('checkbox', { name: 'Enable time range' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByText("Dashboard variables won't work in public dashboards"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByRole('button', { name: 'Publish dashboard' }),
|
||||
).toBeVisible();
|
||||
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
});
|
||||
|
||||
// ─── TBD coverage — placeholders to fill in when each feature lands ──────
|
||||
//
|
||||
// `test.skip` placeholders for behaviours not yet covered. Replace with
|
||||
// `test` and implement when the corresponding feature ships or the seed
|
||||
// gains the necessary state.
|
||||
|
||||
test('TC-11 edit existing variable — rename', async ({ authedPage: page }) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not available',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-rename-variable');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await expect(page.getByText('$tb_env', { exact: true })).toBeVisible();
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
|
||||
// Hover the row to reveal the edit button (Pylon overlay can intercept,
|
||||
// so dispatchEvent fires the click directly on the React onClick).
|
||||
const nameCell = tabpanel.getByText('tb_env', { exact: true }).first();
|
||||
await nameCell.hover();
|
||||
await nameCell
|
||||
.locator(
|
||||
'xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]',
|
||||
)
|
||||
.locator('.edit-variable-button')
|
||||
.first()
|
||||
.dispatchEvent('click');
|
||||
|
||||
// Editor form mounts; rename and save.
|
||||
const renamed = `tb_env_renamed_${Date.now()}`;
|
||||
const nameInput = dialog.getByPlaceholder('Unique name of the variable');
|
||||
await expect(nameInput).toHaveValue('tb_env');
|
||||
await nameInput.fill(renamed);
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Save Variable' })
|
||||
.click({ force: true });
|
||||
|
||||
// Variables bar reflects the rename; the original label is gone.
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
await expect(page.getByText(`$${renamed}`, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('$tb_env', { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-12 edit existing variable — change type (CUSTOM → QUERY)', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not available',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-change-type');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
const nameCell = tabpanel.getByText('cu_single', { exact: true }).first();
|
||||
await nameCell.hover();
|
||||
await nameCell
|
||||
.locator(
|
||||
'xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]',
|
||||
)
|
||||
.locator('.edit-variable-button')
|
||||
.first()
|
||||
.dispatchEvent('click');
|
||||
|
||||
// Change type from Custom to Query and verify the form swaps to the
|
||||
// Query editor (Monaco SQL editor mounts where the comma-separated
|
||||
// values input used to live).
|
||||
await dialog.getByRole('button', { name: /Query/ }).click();
|
||||
// Monaco is lazy-loaded — its bundle chunk can take several seconds to
|
||||
// arrive under parallel-worker CI load, far longer than the default 5 s
|
||||
// locator timeout. 20 s is comfortable headroom without masking real
|
||||
// regressions.
|
||||
await expect(dialog.locator('.monaco-editor').first()).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// The previous Custom-specific fields must no longer be visible.
|
||||
await expect(dialog.getByPlaceholder(/Comma separated values/i)).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
// Discard rather than save — saving without filling the new query
|
||||
// would leave a half-configured Query variable. The contract this TC
|
||||
// guards is "type switching swaps the form correctly", which the
|
||||
// assertions above already prove.
|
||||
await dialog.getByRole('button', { name: 'Discard' }).click();
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
});
|
||||
|
||||
test('TC-13 edit existing variable — change default textbox value persists across reload', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not available',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-change-default');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await expect(page.locator('input[value="otel-demo"]')).toBeVisible();
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
const nameCell = tabpanel.getByText('tb_env', { exact: true }).first();
|
||||
await nameCell.hover();
|
||||
await nameCell
|
||||
.locator(
|
||||
'xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]',
|
||||
)
|
||||
.locator('.edit-variable-button')
|
||||
.first()
|
||||
.dispatchEvent('click');
|
||||
|
||||
// Update the default textbox value. The Default Value input is the
|
||||
// second/third field (Name first); locate it via its placeholder.
|
||||
const defaultInput = dialog
|
||||
.getByPlaceholder(/Enter default value|Default value/i)
|
||||
.first();
|
||||
await defaultInput.fill('new-default');
|
||||
|
||||
// PUT confirms the variable persisted server-side before we close +
|
||||
// reload. Without this wait the reload races the save and the old
|
||||
// "otel-demo" default renders, producing the observed flake.
|
||||
const putResponse = page.waitForResponse(
|
||||
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
|
||||
);
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Save Variable' })
|
||||
.click({ force: true });
|
||||
await putResponse;
|
||||
|
||||
// Reload — the new default renders without URL state because it's
|
||||
// now the persisted seed value.
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
await page.reload();
|
||||
await expect(page.locator('input[value="new-default"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-14 delete variable — removed from variables bar', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not available',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-delete-variable');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await expect(page.getByText('$tb_env', { exact: true })).toBeVisible();
|
||||
|
||||
// Reuse the existing helper and assert the variables bar reflects
|
||||
// the deletion — `deleteVariableByName` covers the Configure-side
|
||||
// removal; the bar update is the new contract this TC adds.
|
||||
await deleteVariableByName(page, 'tb_env');
|
||||
await expect(page.getByText('$tb_env', { exact: true })).toHaveCount(0);
|
||||
// Sibling textbox is unaffected.
|
||||
await expect(page.getByText('$tb_service', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-15 variable name validation — duplicate name keeps Save disabled', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not available',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-validate-duplicate');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
await dialog.getByTestId('add-new-variable').click();
|
||||
|
||||
await dialog.getByPlaceholder('Unique name of the variable').fill('tb_env');
|
||||
await dialog.getByRole('button', { name: 'Textbox' }).click();
|
||||
|
||||
// Save Variable should refuse to enable while the name collides with
|
||||
// an existing variable. Assert the button stays disabled, OR a
|
||||
// validation message surfaces — UI may pick either signal.
|
||||
const saveBtn = dialog.getByRole('button', { name: 'Save Variable' });
|
||||
const errorMsg = dialog.getByText(/already exists|duplicate|in use/i);
|
||||
|
||||
// Either Save is disabled, or an explicit error is shown — both are
|
||||
// valid contracts. `Promise.race` between the two assertions tolerates
|
||||
// whichever the UI provides.
|
||||
// Persisted in the spec, and rendered by the bar.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const disabled = await saveBtn.isDisabled().catch(() => false);
|
||||
const err = await errorMsg.isVisible().catch(() => false);
|
||||
return disabled || err;
|
||||
const stored = await getDashboardV2(page, id);
|
||||
return (stored.spec.variables as { spec: { name: string } }[]).map(
|
||||
(variable) => variable.spec.name,
|
||||
);
|
||||
})
|
||||
.toBeTruthy();
|
||||
|
||||
await dialog.getByRole('button', { name: 'Discard' }).click();
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
.toContain('cu_tier');
|
||||
await expect(variablePill(page, 'cu_tier')).toBeVisible();
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-16 variable name validation — invalid characters / whitespace', async () => {
|
||||
// Names containing spaces, $-prefix, dots, etc. should be rejected
|
||||
// by the validator. Confirm Save Variable stays disabled with an
|
||||
// inline error message.
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-17 reorder variables via drag persists `order` in JSON', async () => {
|
||||
// The Variables tab supports drag handles. After a reorder, the
|
||||
// persisted `data.variables[*].order` reflects the new sequence and
|
||||
// the variables bar re-renders accordingly.
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-18 add a Dynamic (Beta) variable via Configure → pick seeded attribute', async () => {
|
||||
// Dynamic-variable resolution itself is covered by
|
||||
// `67-variables` TC-15 (seed metric → Dynamic dropdown lists the
|
||||
// namespace → URL state updates). What this TC adds is the Configure
|
||||
// drawer's *Add Variable → Dynamic* form, whose attribute-picker
|
||||
// uses a combobox whose stable locator hasn't been pinned in this
|
||||
// suite yet — leave skipped pending a snapshot pass.
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-19 Variable description renders in tooltip / inline metadata', async () => {
|
||||
// `description` field on each variable should be surfaced in the
|
||||
// variables bar tooltip and in the Variables tab's row.
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-20 Save Variable disabled while query is in flight', async () => {
|
||||
// For a Query variable mid-resolution, Save Variable should be
|
||||
// disabled until the query returns options. Otherwise we'd save
|
||||
// a variable with an empty option list.
|
||||
});
|
||||
|
||||
test('TC-21 cancel-mid-edit variable changes are not persisted', async ({
|
||||
test('TC-03 a duplicate name cannot be saved', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not available',
|
||||
const id = await seedAndOpen(page, 'dupe');
|
||||
await openNewVariableForm(page);
|
||||
|
||||
await page.getByTestId('variable-name').fill('cu_service');
|
||||
await page.getByTestId('variable-type-custom').click();
|
||||
await page.getByTestId('variable-custom-input').fill('a,b');
|
||||
|
||||
// The form refuses the duplicate outright, so Save never becomes available.
|
||||
await expect(page.getByTestId('variable-save')).toBeDisabled();
|
||||
|
||||
// And the dashboard does not end up with two `cu_service`.
|
||||
const names = (
|
||||
(await getDashboardV2(page, id)).spec.variables as {
|
||||
spec: { name: string };
|
||||
}[]
|
||||
).map((variable) => variable.spec.name);
|
||||
expect(names.filter((name) => name === 'cu_service')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('TC-04 an empty name cannot be saved', async ({ authedPage: page }) => {
|
||||
await seedAndOpen(page, 'noname');
|
||||
await openNewVariableForm(page);
|
||||
|
||||
await page.getByTestId('variable-type-custom').click();
|
||||
await page.getByTestId('variable-custom-input').fill('a,b');
|
||||
|
||||
await expect(page.getByTestId('variable-save')).toBeDisabled();
|
||||
});
|
||||
|
||||
test('TC-05 editing a custom variable changes the options it offers', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seedAndOpen(page, 'edit');
|
||||
await openVariablesList(page);
|
||||
|
||||
await page.getByTestId('variable-edit-cu_region').click();
|
||||
await page.getByTestId('variable-custom-input').fill('ap-south');
|
||||
await page.getByTestId('variable-save').click();
|
||||
|
||||
await expect
|
||||
.poll(async () =>
|
||||
JSON.stringify((await getDashboardV2(page, id)).spec.variables),
|
||||
)
|
||||
.toContain('ap-south');
|
||||
});
|
||||
|
||||
test('TC-06 deleting a variable takes it off the list and out of the bar', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seedAndOpen(page, 'delete');
|
||||
await openVariablesList(page);
|
||||
|
||||
await page.getByTestId('variable-delete-cu_region').click();
|
||||
await page.getByTestId('variable-delete-confirm-cu_region').click();
|
||||
|
||||
await expect(page.getByTestId('variable-row-cu_region')).toBeHidden();
|
||||
// The row goes optimistically; wait for the write before reloading, or the
|
||||
// reload can race it and legitimately still show the variable.
|
||||
await expect
|
||||
.poll(async () =>
|
||||
(
|
||||
(await getDashboardV2(page, id)).spec.variables as {
|
||||
spec: { name: string };
|
||||
}[]
|
||||
).map((variable) => variable.spec.name),
|
||||
)
|
||||
.not.toContain('cu_region');
|
||||
|
||||
await page.reload();
|
||||
await expect(variablesBar(page)).toBeVisible();
|
||||
await expect(variablePill(page, 'cu_region')).toBeHidden();
|
||||
});
|
||||
|
||||
test('TC-07 a variable saved as single-select does not render as ALL', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await seedAndOpen(page, 'single');
|
||||
await openNewVariableForm(page);
|
||||
|
||||
await page.getByTestId('variable-name').fill('cu_single_tier');
|
||||
await page.getByTestId('variable-type-custom').click();
|
||||
await page.getByTestId('variable-custom-input').fill('gold,silver');
|
||||
// ALL is only offered to a multi-select, so assert the switch state rather than
|
||||
// assuming the form's default.
|
||||
await expect(page.getByTestId('variable-multi-switch')).not.toBeChecked();
|
||||
await page.getByTestId('variable-save').click();
|
||||
|
||||
await expect(variablePill(page, 'cu_single_tier')).toBeVisible();
|
||||
await expect
|
||||
.poll(() => readVariableSelection(page, 'cu_single_tier'))
|
||||
.not.toBe('ALL');
|
||||
});
|
||||
|
||||
test('TC-08 discarding the form leaves the dashboard untouched', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seedAndOpen(page, 'discard');
|
||||
const before = JSON.stringify(
|
||||
(await getDashboardV2(page, id)).spec.variables,
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-cancel-edit-variable');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await expect(page.getByText('$tb_env', { exact: true })).toBeVisible();
|
||||
await openNewVariableForm(page);
|
||||
await page.getByTestId('variable-name').fill('cu_discarded');
|
||||
await page.getByTestId('variable-type-custom').click();
|
||||
await page.getByTestId('variable-custom-input').fill('x,y');
|
||||
await page.getByRole('button', { name: 'Discard' }).click();
|
||||
|
||||
// Open the editor for tb_env and dirty the Name field.
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
const nameCell = tabpanel.getByText('tb_env', { exact: true }).first();
|
||||
await nameCell.hover();
|
||||
await nameCell
|
||||
.locator(
|
||||
'xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]',
|
||||
)
|
||||
.locator('.edit-variable-button')
|
||||
.first()
|
||||
.dispatchEvent('click');
|
||||
|
||||
const nameInput = dialog.getByPlaceholder('Unique name of the variable');
|
||||
await expect(nameInput).toHaveValue('tb_env');
|
||||
await nameInput.fill('SHOULD_NOT_PERSIST');
|
||||
|
||||
// Discard, then re-open the same row. The Name must still be the
|
||||
// original — abandoned edits never reach the persisted JSON.
|
||||
await dialog.getByRole('button', { name: 'Discard' }).click();
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
await expect(page.getByText('$tb_env', { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('$SHOULD_NOT_PERSIST', { exact: true }),
|
||||
).toHaveCount(0);
|
||||
|
||||
// Reopen Configure → tb_env still has the original name.
|
||||
const dialog2 = await openConfigureDrawer(page);
|
||||
await dialog2.getByRole('tab', { name: 'Variables' }).click();
|
||||
await expect(
|
||||
dialog2
|
||||
.getByRole('tabpanel', { name: 'Variables' })
|
||||
.getByText('tb_env', { exact: true })
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
const after = JSON.stringify((await getDashboardV2(page, id)).spec.variables);
|
||||
expect(after).toBe(before);
|
||||
await expect(variablePill(page, 'cu_discarded')).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2020"],
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
@@ -1,623 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import requests
|
||||
from wiremock.resources.mappings import Mapping
|
||||
|
||||
from fixtures.auth import (
|
||||
USER_ADMIN_EMAIL,
|
||||
USER_ADMIN_PASSWORD,
|
||||
add_license,
|
||||
change_user_role,
|
||||
create_active_user,
|
||||
find_user_by_email,
|
||||
)
|
||||
from fixtures.role import transaction_group
|
||||
from fixtures.types import Operation, SigNoz, TestContainerDocker
|
||||
|
||||
V2_BASE_URL = "/api/v2/dashboards"
|
||||
MAX_LIST_LIMIT = 200
|
||||
|
||||
_EDITOR_EMAIL = "editor+dashboardauthz@integration.test"
|
||||
_EDITOR_PASSWORD = "password123Z$"
|
||||
_VIEWER_EMAIL = "viewer+dashboardauthz@integration.test"
|
||||
_VIEWER_PASSWORD = "password123Z$"
|
||||
|
||||
_ACTOR_ROLE_NAME = "dashboard-fga-actor"
|
||||
_ACTOR_EMAIL = "customrole+dashboardauthz@integration.test"
|
||||
_ACTOR_PASSWORD = "password123Z$"
|
||||
|
||||
# Instance verbs are granted on _TARGET_A only; _TARGET_B must stay forbidden.
|
||||
_TARGET_A = "dashboard-authz-target-a"
|
||||
_TARGET_B = "dashboard-authz-target-b"
|
||||
_CLONE_SOURCE = "dashboard-authz-clone-source"
|
||||
_VIEW_NAME = "dashboard-authz-view"
|
||||
|
||||
_SPEC = {
|
||||
"display": {"name": "Dashboard Authz"},
|
||||
"duration": "1h",
|
||||
"links": [],
|
||||
"variables": [],
|
||||
"panels": {},
|
||||
"layouts": [],
|
||||
}
|
||||
|
||||
|
||||
# ─── managed roles (community-safe: no license, role gate only) ───────────────
|
||||
|
||||
|
||||
def test_setup_managed_role_users(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# A rerun against a --reuse stack starts from the previous run's state, and
|
||||
# inviting an existing address fails, so only invite what is missing.
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
existing_emails = {user["email"] for user in response.json()["data"]}
|
||||
|
||||
for email, role, password, name in (
|
||||
(_EDITOR_EMAIL, "EDITOR", _EDITOR_PASSWORD, "dashboard authz editor"),
|
||||
(_VIEWER_EMAIL, "VIEWER", _VIEWER_PASSWORD, "dashboard authz viewer"),
|
||||
):
|
||||
if email not in existing_emails:
|
||||
create_active_user(signoz, admin_token, email=email, role=role, password=password, name=name)
|
||||
|
||||
# (org_id, name) is unique, so leftovers from an earlier run have to go before
|
||||
# these are recreated.
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}?limit={MAX_LIST_LIMIT}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
for dashboard in response.json()["data"]["dashboards"]:
|
||||
if dashboard["name"] in (_TARGET_A, _TARGET_B, _CLONE_SOURCE):
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{dashboard['id']}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
for name in (_TARGET_A, _TARGET_B, _CLONE_SOURCE):
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
json={"schemaVersion": "v6", "name": name, "spec": _SPEC, "tags": []},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
|
||||
|
||||
def test_viewer_allowed_on_reads_and_pins(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(_VIEWER_EMAIL, _VIEWER_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}?limit={MAX_LIST_LIMIT}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
ids = {dashboard["name"]: dashboard["id"] for dashboard in response.json()["data"]["dashboards"]}
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{ids[_TARGET_A]}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/users/me/dashboards/{ids[_TARGET_A]}/pins"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/users/me/dashboards/{ids[_TARGET_A]}/pins"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
|
||||
def test_viewer_forbidden_on_mutations(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
token = get_token(_VIEWER_EMAIL, _VIEWER_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}?limit={MAX_LIST_LIMIT}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
ids = {dashboard["name"]: dashboard["id"] for dashboard in response.json()["data"]["dashboards"]}
|
||||
target_id = ids[_TARGET_A]
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
json={"schemaVersion": "v6", "name": "viewer-denied", "spec": _SPEC, "tags": []},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, f"create: expected 403, got {response.status_code}: {response.text}"
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{target_id}"),
|
||||
json={"schemaVersion": "v6", "name": _TARGET_A, "spec": _SPEC, "tags": []},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, f"update: expected 403, got {response.status_code}: {response.text}"
|
||||
|
||||
response = requests.patch(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{target_id}"),
|
||||
json=[{"op": "replace", "path": "/spec/display/name", "value": "Patched"}],
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, f"patch: expected 403, got {response.status_code}: {response.text}"
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{target_id}/clone"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, f"clone: expected 403, got {response.status_code}: {response.text}"
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{target_id}/migrate"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, f"migrate: expected 403, got {response.status_code}: {response.text}"
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{target_id}/lock"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, f"lock: expected 403, got {response.status_code}: {response.text}"
|
||||
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{target_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, f"delete: expected 403, got {response.status_code}: {response.text}"
|
||||
|
||||
|
||||
def test_editor_allowed_on_mutations(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(_EDITOR_EMAIL, _EDITOR_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}?limit={MAX_LIST_LIMIT}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
ids = {dashboard["name"]: dashboard["id"] for dashboard in response.json()["data"]["dashboards"]}
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{ids[_TARGET_A]}"),
|
||||
json={"schemaVersion": "v6", "name": _TARGET_A, "spec": _SPEC, "tags": [{"key": "team", "value": "pulse"}]},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{ids[_CLONE_SOURCE]}/clone"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
clone_id = response.json()["data"]["id"]
|
||||
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{clone_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
|
||||
def test_viewer_forbidden_on_public_config(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
# The public config routes authorize on the dashboard itself, so a viewer is
|
||||
# refused before the handler runs, licensed or not.
|
||||
token = get_token(_VIEWER_EMAIL, _VIEWER_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}?limit={MAX_LIST_LIMIT}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
target_id = {dashboard["name"]: dashboard["id"] for dashboard in response.json()["data"]["dashboards"]}[_TARGET_A]
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{target_id}/public"),
|
||||
json={"timeRangeEnabled": True, "defaultTimeRange": "10m"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, f"create public: expected 403, got {response.status_code}: {response.text}"
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{target_id}/public"),
|
||||
json={"timeRangeEnabled": False, "defaultTimeRange": "10m"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, f"update public: expected 403, got {response.status_code}: {response.text}"
|
||||
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{target_id}/public"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, f"delete public: expected 403, got {response.status_code}: {response.text}"
|
||||
|
||||
|
||||
def test_viewer_allowed_on_saved_views(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
# Saved views ride on dashboard list, which a viewer holds, so the whole CRUD
|
||||
# surface is open to them.
|
||||
token = get_token(_VIEWER_EMAIL, _VIEWER_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/dashboard_views"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/dashboard_views"),
|
||||
json={"name": _VIEW_NAME, "data": {"version": "v1", "sort": "updated_at", "order": "desc"}},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
view_id = response.json()["data"]["id"]
|
||||
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/dashboard_views/{view_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
|
||||
# ─── per-object FGA scoping (enterprise, custom role) ─────────────────────────
|
||||
|
||||
|
||||
def test_apply_license(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
make_http_mocks: Callable[[TestContainerDocker, list[Mapping]], None],
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
add_license(signoz, make_http_mocks, get_token)
|
||||
|
||||
|
||||
def test_editor_allowed_on_public_config(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
# An editor holds dashboard update, which is what the public config routes ask
|
||||
# for, so publishing is open to editors and not just admins.
|
||||
token = get_token(_EDITOR_EMAIL, _EDITOR_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}?limit={MAX_LIST_LIMIT}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
target_id = {dashboard["name"]: dashboard["id"] for dashboard in response.json()["data"]["dashboards"]}[_TARGET_B]
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{target_id}/public"),
|
||||
json={"timeRangeEnabled": True, "defaultTimeRange": "10m"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{target_id}/public"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
# Reading the config asks only for dashboard read, so a viewer sees it too.
|
||||
viewer_token = get_token(_VIEWER_EMAIL, _VIEWER_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{target_id}/public"),
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{target_id}/public"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
|
||||
def test_setup_scoped_actor(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_role: Callable[..., str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}?limit={MAX_LIST_LIMIT}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
ids = {dashboard["name"]: dashboard["id"] for dashboard in response.json()["data"]["dashboards"]}
|
||||
|
||||
create_role(
|
||||
admin_token,
|
||||
_ACTOR_ROLE_NAME,
|
||||
[
|
||||
transaction_group("read", "metaresource", "dashboard", [ids[_TARGET_A]]),
|
||||
transaction_group("list", "metaresource", "dashboard", ["*"]),
|
||||
],
|
||||
)
|
||||
|
||||
user_id = create_active_user(signoz, admin_token, email=_ACTOR_EMAIL, role="VIEWER", password=_ACTOR_PASSWORD, name="dashboard fga actor")
|
||||
change_user_role(signoz, admin_token, user_id, "signoz-viewer", _ACTOR_ROLE_NAME)
|
||||
|
||||
|
||||
def test_read_scoped_to_granted_dashboard(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}?limit={MAX_LIST_LIMIT}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
ids = {dashboard["name"]: dashboard["id"] for dashboard in response.json()["data"]["dashboards"]}
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{ids[_TARGET_A]}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, f"read granted dashboard: {response.text}"
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{ids[_TARGET_B]}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, f"read other dashboard: expected 403, got {response.status_code}: {response.text}"
|
||||
|
||||
|
||||
def test_list_returns_every_dashboard(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
|
||||
|
||||
# list is collection-scoped: list on "*" returns every dashboard, including
|
||||
# the one the actor cannot read individually.
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}?limit={MAX_LIST_LIMIT}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
names = {dashboard["name"] for dashboard in response.json()["data"]["dashboards"]}
|
||||
assert {_TARGET_A, _TARGET_B} <= names
|
||||
|
||||
|
||||
def test_publish_and_unpublish_require_update_on_the_dashboard(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
find_role_id: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
actor_role_id = find_role_id(admin_token, _ACTOR_ROLE_NAME)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}?limit={MAX_LIST_LIMIT}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
target_id = {dashboard["name"]: dashboard["id"] for dashboard in response.json()["data"]["dashboards"]}[_TARGET_A]
|
||||
|
||||
# read and list alone must not be enough to publish.
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{actor_role_id}"),
|
||||
json={
|
||||
"description": "",
|
||||
"transactionGroups": [
|
||||
transaction_group("read", "metaresource", "dashboard", [target_id]),
|
||||
transaction_group("list", "metaresource", "dashboard", ["*"]),
|
||||
],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{target_id}/public"),
|
||||
json={"timeRangeEnabled": True, "defaultTimeRange": "10m"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, f"publish without dashboard update: expected 403, got {response.status_code}: {response.text}"
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{actor_role_id}"),
|
||||
json={
|
||||
"description": "",
|
||||
"transactionGroups": [
|
||||
transaction_group("read", "metaresource", "dashboard", [target_id]),
|
||||
transaction_group("list", "metaresource", "dashboard", ["*"]),
|
||||
transaction_group("update", "metaresource", "dashboard", [target_id]),
|
||||
],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{target_id}/public"),
|
||||
json={"timeRangeEnabled": True, "defaultTimeRange": "10m"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, f"publish with dashboard update: {response.text}"
|
||||
|
||||
# Unpublishing asks for the same update grant.
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{actor_role_id}"),
|
||||
json={
|
||||
"description": "",
|
||||
"transactionGroups": [
|
||||
transaction_group("read", "metaresource", "dashboard", [target_id]),
|
||||
transaction_group("list", "metaresource", "dashboard", ["*"]),
|
||||
],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{target_id}/public"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, f"unpublish without dashboard update: expected 403, got {response.status_code}: {response.text}"
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{actor_role_id}"),
|
||||
json={
|
||||
"description": "",
|
||||
"transactionGroups": [
|
||||
transaction_group("read", "metaresource", "dashboard", [target_id]),
|
||||
transaction_group("list", "metaresource", "dashboard", ["*"]),
|
||||
transaction_group("update", "metaresource", "dashboard", [target_id]),
|
||||
],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{target_id}/public"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, f"unpublish with dashboard update: {response.text}"
|
||||
|
||||
|
||||
def test_dashboard_authz_cleanup(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
find_role_id: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
actor = find_user_by_email(signoz, admin_token, _ACTOR_EMAIL)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/users/{actor['id']}/roles"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
actor_entry = next((role for role in response.json()["data"] if role["name"] == _ACTOR_ROLE_NAME), None)
|
||||
if actor_entry is not None:
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/users/{actor['id']}/roles/{actor_entry['id']}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, f"remove role from user: {response.text}"
|
||||
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{find_role_id(admin_token, _ACTOR_ROLE_NAME)}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, f"delete {_ACTOR_ROLE_NAME}: {response.text}"
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}?limit={MAX_LIST_LIMIT}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
ids = {dashboard["name"]: dashboard["id"] for dashboard in response.json()["data"]["dashboards"]}
|
||||
|
||||
for name in (_TARGET_A, _TARGET_B, _CLONE_SOURCE):
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{ids[name]}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, f"delete {name}: {response.text}"
|
||||
Reference in New Issue
Block a user