mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-11 19:30:31 +01:00
Compare commits
19 Commits
feat/llm-o
...
feat/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ed852da93 | ||
|
|
23931f25b6 | ||
|
|
e1ad451003 | ||
|
|
9155c635f8 | ||
|
|
870a4b3dc3 | ||
|
|
7eb0095133 | ||
|
|
df26eb1c1d | ||
|
|
e9e7646403 | ||
|
|
36334309bb | ||
|
|
cfcd58b341 | ||
|
|
736a2a751e | ||
|
|
d01e8aff22 | ||
|
|
6ae1033752 | ||
|
|
0587fe41e4 | ||
|
|
75f5a8b82c | ||
|
|
2f541df60e | ||
|
|
45fedefbab | ||
|
|
01ae688b58 | ||
|
|
f4e1465c13 |
@@ -2436,13 +2436,6 @@ components:
|
||||
url:
|
||||
type: string
|
||||
type: object
|
||||
DashboardPanelDisplay:
|
||||
properties:
|
||||
description:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
type: object
|
||||
DashboardTextVariableSpec:
|
||||
properties:
|
||||
constant:
|
||||
@@ -2570,13 +2563,12 @@ components:
|
||||
$ref: '#/components/schemas/DashboardtypesDatasourceSpec'
|
||||
type: object
|
||||
display:
|
||||
$ref: '#/components/schemas/CommonDisplay'
|
||||
$ref: '#/components/schemas/DashboardtypesDisplay'
|
||||
duration:
|
||||
type: string
|
||||
layouts:
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardtypesLayout'
|
||||
nullable: true
|
||||
type: array
|
||||
links:
|
||||
items:
|
||||
@@ -2585,7 +2577,6 @@ components:
|
||||
panels:
|
||||
additionalProperties:
|
||||
$ref: '#/components/schemas/DashboardtypesPanel'
|
||||
nullable: true
|
||||
type: object
|
||||
refreshInterval:
|
||||
type: string
|
||||
@@ -2593,6 +2584,11 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardtypesVariable'
|
||||
type: array
|
||||
required:
|
||||
- display
|
||||
- variables
|
||||
- panels
|
||||
- layouts
|
||||
type: object
|
||||
DashboardtypesDatasourcePlugin:
|
||||
discriminator:
|
||||
@@ -2628,6 +2624,15 @@ components:
|
||||
plugin:
|
||||
$ref: '#/components/schemas/DashboardtypesDatasourcePlugin'
|
||||
type: object
|
||||
DashboardtypesDisplay:
|
||||
properties:
|
||||
description:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
DashboardtypesDynamicVariableSpec:
|
||||
properties:
|
||||
name:
|
||||
@@ -2822,7 +2827,7 @@ components:
|
||||
defaultValue:
|
||||
$ref: '#/components/schemas/VariableDefaultValue'
|
||||
display:
|
||||
$ref: '#/components/schemas/VariableDisplay'
|
||||
$ref: '#/components/schemas/DashboardtypesDisplay'
|
||||
name:
|
||||
type: string
|
||||
plugin:
|
||||
@@ -2830,6 +2835,8 @@ components:
|
||||
sort:
|
||||
nullable: true
|
||||
type: string
|
||||
required:
|
||||
- display
|
||||
type: object
|
||||
DashboardtypesListableDashboardForUserV2:
|
||||
properties:
|
||||
@@ -2957,7 +2964,7 @@ components:
|
||||
DashboardtypesListedDashboardV2Spec:
|
||||
properties:
|
||||
display:
|
||||
$ref: '#/components/schemas/CommonDisplay'
|
||||
$ref: '#/components/schemas/DashboardtypesDisplay'
|
||||
type: object
|
||||
DashboardtypesNumberPanelSpec:
|
||||
properties:
|
||||
@@ -2977,6 +2984,9 @@ components:
|
||||
$ref: '#/components/schemas/DashboardtypesPanelKind'
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesPanelSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelFormatting:
|
||||
properties:
|
||||
@@ -3106,7 +3116,7 @@ components:
|
||||
DashboardtypesPanelSpec:
|
||||
properties:
|
||||
display:
|
||||
$ref: '#/components/schemas/DashboardPanelDisplay'
|
||||
$ref: '#/components/schemas/DashboardtypesDisplay'
|
||||
links:
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardLink'
|
||||
@@ -3116,7 +3126,12 @@ components:
|
||||
queries:
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardtypesQuery'
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- display
|
||||
- plugin
|
||||
- queries
|
||||
type: object
|
||||
DashboardtypesPatchOp:
|
||||
enum:
|
||||
@@ -3185,6 +3200,9 @@ components:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5RequestType'
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesQuerySpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesQueryPlugin:
|
||||
discriminator:
|
||||
@@ -3291,6 +3309,8 @@ components:
|
||||
type: string
|
||||
plugin:
|
||||
$ref: '#/components/schemas/DashboardtypesQueryPlugin'
|
||||
required:
|
||||
- plugin
|
||||
type: object
|
||||
DashboardtypesQueryVariableSpec:
|
||||
properties:
|
||||
|
||||
@@ -254,12 +254,12 @@ func (module *module) PinV2(ctx context.Context, orgID valuer.UUID, userID value
|
||||
return module.pkgDashboardModule.PinV2(ctx, orgID, userID, id)
|
||||
}
|
||||
|
||||
func (module *module) UnpinV2(ctx context.Context, userID valuer.UUID, id valuer.UUID) error {
|
||||
return module.pkgDashboardModule.UnpinV2(ctx, userID, id)
|
||||
func (module *module) UnpinV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, id valuer.UUID) error {
|
||||
return module.pkgDashboardModule.UnpinV2(ctx, orgID, userID, id)
|
||||
}
|
||||
|
||||
func (module *module) DeletePreferencesForUser(ctx context.Context, userID valuer.UUID) error {
|
||||
return module.pkgDashboardModule.DeletePreferencesForUser(ctx, userID)
|
||||
func (module *module) DeletePreferencesForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error {
|
||||
return module.pkgDashboardModule.DeletePreferencesForUser(ctx, orgID, userID)
|
||||
}
|
||||
|
||||
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.Dashboard, error) {
|
||||
|
||||
@@ -185,6 +185,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
|
||||
s.config.APIServer.Timeout.Default,
|
||||
s.config.APIServer.Timeout.Max,
|
||||
).Wrap)
|
||||
r.Use(middleware.NewResource(s.signoz.Instrumentation.Logger()).Wrap)
|
||||
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, s.signoz.Auditor).Wrap)
|
||||
r.Use(middleware.NewComment().Wrap)
|
||||
|
||||
|
||||
@@ -3156,17 +3156,6 @@ export interface DashboardLinkDTO {
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface DashboardPanelDisplayDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface VariableDisplayDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -3892,6 +3881,17 @@ export type DashboardtypesDashboardSpecDTODatasources = {
|
||||
export enum DashboardtypesPanelKindDTO {
|
||||
Panel = 'Panel',
|
||||
}
|
||||
export interface DashboardtypesDisplayDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
}
|
||||
|
||||
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTOKind {
|
||||
'signoz/TimeSeriesPanel' = 'signoz/TimeSeriesPanel',
|
||||
}
|
||||
@@ -4440,42 +4440,36 @@ export interface DashboardtypesQuerySpecDTO {
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
plugin?: DashboardtypesQueryPluginDTO;
|
||||
plugin: DashboardtypesQueryPluginDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesQueryDTO {
|
||||
kind?: Querybuildertypesv5RequestTypeDTO;
|
||||
spec?: DashboardtypesQuerySpecDTO;
|
||||
kind: Querybuildertypesv5RequestTypeDTO;
|
||||
spec: DashboardtypesQuerySpecDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesPanelSpecDTO {
|
||||
display?: DashboardPanelDisplayDTO;
|
||||
display: DashboardtypesDisplayDTO;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
links?: DashboardLinkDTO[];
|
||||
plugin?: DashboardtypesPanelPluginDTO;
|
||||
plugin: DashboardtypesPanelPluginDTO;
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
queries?: DashboardtypesQueryDTO[];
|
||||
queries: DashboardtypesQueryDTO[] | null;
|
||||
}
|
||||
|
||||
export interface DashboardtypesPanelDTO {
|
||||
kind?: DashboardtypesPanelKindDTO;
|
||||
spec?: DashboardtypesPanelSpecDTO;
|
||||
kind: DashboardtypesPanelKindDTO;
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
export type DashboardtypesDashboardSpecDTOPanelsAnyOf = {
|
||||
export type DashboardtypesDashboardSpecDTOPanels = {
|
||||
[key: string]: DashboardtypesPanelDTO;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type DashboardtypesDashboardSpecDTOPanels =
|
||||
DashboardtypesDashboardSpecDTOPanelsAnyOf | null;
|
||||
|
||||
export enum DashboardtypesLayoutEnvelopeGithubComPersesSpecGoDashboardGridLayoutSpecDTOKind {
|
||||
Grid = 'Grid',
|
||||
}
|
||||
@@ -4572,7 +4566,7 @@ export interface DashboardtypesListVariableSpecDTO {
|
||||
*/
|
||||
customAllValue?: string;
|
||||
defaultValue?: VariableDefaultValueDTO;
|
||||
display?: VariableDisplayDTO;
|
||||
display: DashboardtypesDisplayDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -4614,23 +4608,23 @@ export interface DashboardtypesDashboardSpecDTO {
|
||||
* @type object
|
||||
*/
|
||||
datasources?: DashboardtypesDashboardSpecDTODatasources;
|
||||
display?: CommonDisplayDTO;
|
||||
display: DashboardtypesDisplayDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
duration?: string;
|
||||
/**
|
||||
* @type array,null
|
||||
* @type array
|
||||
*/
|
||||
layouts?: DashboardtypesLayoutDTO[] | null;
|
||||
layouts: DashboardtypesLayoutDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
links?: DashboardLinkDTO[];
|
||||
/**
|
||||
* @type object,null
|
||||
* @type object
|
||||
*/
|
||||
panels?: DashboardtypesDashboardSpecDTOPanels;
|
||||
panels: DashboardtypesDashboardSpecDTOPanels;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -4638,7 +4632,7 @@ export interface DashboardtypesDashboardSpecDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
variables?: DashboardtypesVariableDTO[];
|
||||
variables: DashboardtypesVariableDTO[];
|
||||
}
|
||||
|
||||
export enum DashboardtypesDatasourcePluginKindDTO {
|
||||
@@ -4762,7 +4756,7 @@ export enum DashboardtypesListSortDTO {
|
||||
name = 'name',
|
||||
}
|
||||
export interface DashboardtypesListedDashboardV2SpecDTO {
|
||||
display?: CommonDisplayDTO;
|
||||
display?: DashboardtypesDisplayDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesListedDashboardForUserV2DTO {
|
||||
|
||||
@@ -40,13 +40,31 @@ type SpeechRecognitionConstructor = new () => ISpeechRecognition;
|
||||
|
||||
// ── Vendor-prefix shim for Safari / older browsers ────────────────────────────
|
||||
|
||||
const SpeechRecognitionAPI: SpeechRecognitionConstructor | null =
|
||||
typeof window !== 'undefined'
|
||||
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
((window as any).SpeechRecognition ??
|
||||
// Some hardened/enterprise browsers install a getter
|
||||
// on window.SpeechRecognition that THROWS on access ("Web Speech API is disabled
|
||||
// due to your security policy") instead of leaving the property undefined.
|
||||
// Because this resolves at module-evaluation time, an uncaught throw here aborts
|
||||
// the entire bundle and the app renders a blank page. Read defensively so a
|
||||
// throwing getter degrades to "unsupported" rather than crashing the app.
|
||||
function resolveSpeechRecognitionAPI(): SpeechRecognitionConstructor | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(window as any).SpeechRecognition ??
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(window as any).webkitSpeechRecognition ??
|
||||
null)
|
||||
: null;
|
||||
null
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const SpeechRecognitionAPI: SpeechRecognitionConstructor | null =
|
||||
resolveSpeechRecognitionAPI();
|
||||
|
||||
export type SpeechRecognitionError =
|
||||
| 'not-supported'
|
||||
|
||||
@@ -142,6 +142,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
.reset-password-back-action {
|
||||
margin-top: var(--spacing-12);
|
||||
width: 100%;
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
width: 100%;
|
||||
padding: 0 16px;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { CircleAlert } from '@signozhq/icons';
|
||||
import { ArrowLeft, CircleAlert } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import AuthError from 'components/AuthError/AuthError';
|
||||
import AuthPageContainer from 'components/AuthPageContainer';
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import './ResetPassword.styles.scss';
|
||||
@@ -59,6 +62,16 @@ function TokenError({ error }: TokenErrorProps): JSX.Element {
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{error && <AuthError error={error} />}
|
||||
<div className="reset-password-back-action">
|
||||
<Button
|
||||
variant="solid"
|
||||
data-testid="back-to-login"
|
||||
prefix={<ArrowLeft size={12} />}
|
||||
onClick={(): void => history.push(ROUTES.LOGIN)}
|
||||
>
|
||||
Back to login
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AuthPageContainer>
|
||||
);
|
||||
|
||||
@@ -119,6 +119,10 @@
|
||||
|
||||
border-radius: 0px 4px 4px 0px;
|
||||
background: var(--l3-background);
|
||||
|
||||
&.version-container-standalone {
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.version {
|
||||
|
||||
@@ -1010,7 +1010,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
<img src={signozBrandLogoUrl} alt="SigNoz" />
|
||||
</div>
|
||||
|
||||
{licenseTag && (
|
||||
{(licenseTag || currentVersion) && (
|
||||
<div
|
||||
className={cx(
|
||||
'brand-title-section',
|
||||
@@ -1021,7 +1021,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
'version-update-notification',
|
||||
)}
|
||||
>
|
||||
<span className="license-type"> {licenseTag} </span>
|
||||
{licenseTag && <span className="license-type"> {licenseTag} </span>}
|
||||
|
||||
{currentVersion && (
|
||||
<Tooltip
|
||||
@@ -1043,7 +1043,12 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="version-container">
|
||||
<div
|
||||
className={cx(
|
||||
'version-container',
|
||||
!licenseTag && 'version-container-standalone',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cx('version', changelog && 'version-clickable')}
|
||||
onClick={onClickVersionHandler}
|
||||
|
||||
53
frontend/src/pages/DashboardPage/DashboardPage.tsx
Normal file
53
frontend/src/pages/DashboardPage/DashboardPage.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Modal } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { AxiosError } from 'axios';
|
||||
import NotFound from 'components/NotFound';
|
||||
import Spinner from 'components/Spinner';
|
||||
import DashboardContainer from 'container/DashboardContainer';
|
||||
import { useDashboardBootstrap } from 'hooks/dashboard/useDashboardBootstrap';
|
||||
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
|
||||
import { ErrorType } from 'types/common';
|
||||
|
||||
function DashboardPage(): JSX.Element {
|
||||
const { dashboardId } = useParams<{ dashboardId: string }>();
|
||||
|
||||
const [onModal, Content] = Modal.useModal();
|
||||
|
||||
const { isLoading, isError, isFetching, error } = useDashboardBootstrap(
|
||||
dashboardId,
|
||||
{ confirm: onModal.confirm },
|
||||
);
|
||||
|
||||
const dashboardTitle = useDashboardStore((s) => s.dashboardData?.data.title);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = dashboardTitle || document.title;
|
||||
}, [dashboardTitle]);
|
||||
|
||||
const errorMessage = isError
|
||||
? (error as AxiosError<{ errorType: string }>)?.response?.data?.errorType
|
||||
: 'Something went wrong';
|
||||
|
||||
if (isError && !isFetching && errorMessage === ErrorType.NotFound) {
|
||||
return <NotFound />;
|
||||
}
|
||||
|
||||
if (isError && errorMessage) {
|
||||
return <Typography>{errorMessage}</Typography>;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <Spinner tip="Loading.." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{Content}
|
||||
<DashboardContainer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardPage;
|
||||
@@ -1,53 +1,15 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Modal } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { AxiosError } from 'axios';
|
||||
import NotFound from 'components/NotFound';
|
||||
import Spinner from 'components/Spinner';
|
||||
import DashboardContainer from 'container/DashboardContainer';
|
||||
import { useDashboardBootstrap } from 'hooks/dashboard/useDashboardBootstrap';
|
||||
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
|
||||
import { ErrorType } from 'types/common';
|
||||
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
|
||||
import DashboardPageV2 from 'pages/DashboardPageV2';
|
||||
|
||||
function DashboardPage(): JSX.Element {
|
||||
const { dashboardId } = useParams<{ dashboardId: string }>();
|
||||
import DashboardPage from './DashboardPage';
|
||||
|
||||
const [onModal, Content] = Modal.useModal();
|
||||
// Serves the V2 dashboard detail page when the `use_dashboard_v2` flag is active;
|
||||
// otherwise the existing V1 page. Lets V2 dark-ship behind the flag without
|
||||
// changing route definitions.
|
||||
function DashboardPageEntry(): JSX.Element {
|
||||
const isDashboardV2 = useIsDashboardV2();
|
||||
|
||||
const { isLoading, isError, isFetching, error } = useDashboardBootstrap(
|
||||
dashboardId,
|
||||
{ confirm: onModal.confirm },
|
||||
);
|
||||
|
||||
const dashboardTitle = useDashboardStore((s) => s.dashboardData?.data.title);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = dashboardTitle || document.title;
|
||||
}, [dashboardTitle]);
|
||||
|
||||
const errorMessage = isError
|
||||
? (error as AxiosError<{ errorType: string }>)?.response?.data?.errorType
|
||||
: 'Something went wrong';
|
||||
|
||||
if (isError && !isFetching && errorMessage === ErrorType.NotFound) {
|
||||
return <NotFound />;
|
||||
}
|
||||
|
||||
if (isError && errorMessage) {
|
||||
return <Typography>{errorMessage}</Typography>;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <Spinner tip="Loading.." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{Content}
|
||||
<DashboardContainer />
|
||||
</>
|
||||
);
|
||||
return isDashboardV2 ? <DashboardPageV2 /> : <DashboardPage />;
|
||||
}
|
||||
|
||||
export default DashboardPage;
|
||||
export default DashboardPageEntry;
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- searchable async select: no @signozhq/ui equivalent
|
||||
import { Select } from 'antd';
|
||||
import { useGetFieldKeys } from 'hooks/dynamicVariables/useGetFieldKeys';
|
||||
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
|
||||
import { TELEMETRY_SIGNALS, type TelemetrySignal } from '../variableModel';
|
||||
import styles from './VariableForm.module.scss';
|
||||
|
||||
interface DynamicVariableFieldsProps {
|
||||
attribute: string;
|
||||
signal: TelemetrySignal;
|
||||
onChange: (patch: {
|
||||
dynamicAttribute?: string;
|
||||
dynamicSignal?: TelemetrySignal;
|
||||
}) => void;
|
||||
onPreview: (values: (string | number)[]) => void;
|
||||
}
|
||||
|
||||
/** Dynamic-variable body: telemetry signal + field, whose live values preview. */
|
||||
function DynamicVariableFields({
|
||||
attribute,
|
||||
signal,
|
||||
onChange,
|
||||
onPreview,
|
||||
}: DynamicVariableFieldsProps): JSX.Element {
|
||||
const [search, setSearch] = useState('');
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
|
||||
const { data: keyData, isLoading } = useGetFieldKeys({
|
||||
signal,
|
||||
name: debouncedSearch || undefined,
|
||||
});
|
||||
|
||||
// `keys` is a Record keyed BY field name; the field names are the map keys.
|
||||
// When the API reports the list is `complete`, search filters locally.
|
||||
const isComplete = keyData?.data?.complete === true;
|
||||
const options = useMemo(
|
||||
() =>
|
||||
Object.keys(keyData?.data?.keys ?? {}).map((name) => ({
|
||||
label: name,
|
||||
value: name,
|
||||
})),
|
||||
[keyData],
|
||||
);
|
||||
|
||||
const { data: valueData } = useGetFieldValues({
|
||||
signal,
|
||||
name: attribute,
|
||||
enabled: !!attribute,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const payload = valueData?.data;
|
||||
const values =
|
||||
payload?.normalizedValues ?? payload?.values?.StringValues ?? [];
|
||||
onPreview(values);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [valueData]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cx(styles.row, styles.sortSection)}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>Source</Typography.Text>
|
||||
</div>
|
||||
<SelectSimple
|
||||
className={styles.sortSelect}
|
||||
value={signal}
|
||||
items={TELEMETRY_SIGNALS.map((s) => ({ label: s, value: s }))}
|
||||
onChange={(value): void =>
|
||||
onChange({ dynamicSignal: value as TelemetrySignal })
|
||||
}
|
||||
testId="variable-signal-select"
|
||||
/>
|
||||
</div>
|
||||
<div className={cx(styles.row, styles.sortSection)}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>Attribute</Typography.Text>
|
||||
</div>
|
||||
<Select
|
||||
className={styles.searchSelect}
|
||||
showSearch
|
||||
value={attribute || undefined}
|
||||
placeholder="Select a telemetry field"
|
||||
loading={isLoading}
|
||||
filterOption={isComplete}
|
||||
onSearch={setSearch}
|
||||
onChange={(value): void => onChange({ dynamicAttribute: value as string })}
|
||||
options={options}
|
||||
notFoundContent={isLoading ? 'Loading…' : 'No fields found'}
|
||||
data-testid="variable-field-select"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default DynamicVariableFields;
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
|
||||
import Editor from 'components/Editor';
|
||||
import sortValues from 'lib/dashboardVariables/sortVariableValues';
|
||||
|
||||
import type { VariableSort } from '../variableModel';
|
||||
import styles from './VariableForm.module.scss';
|
||||
|
||||
interface QueryVariableFieldsProps {
|
||||
queryValue: string;
|
||||
sort: VariableSort;
|
||||
onChange: (queryValue: string) => void;
|
||||
onPreview: (values: (string | number)[]) => void;
|
||||
onError: (message: string | null) => void;
|
||||
}
|
||||
|
||||
/** Query-variable body: SQL editor + "Test Run Query" that previews the values. */
|
||||
function QueryVariableFields({
|
||||
queryValue,
|
||||
sort,
|
||||
onChange,
|
||||
onPreview,
|
||||
onError,
|
||||
}: QueryVariableFieldsProps): JSX.Element {
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
|
||||
const runTest = async (): Promise<void> => {
|
||||
setIsRunning(true);
|
||||
onError(null);
|
||||
try {
|
||||
const res = await dashboardVariablesQuery({
|
||||
query: queryValue,
|
||||
variables: {},
|
||||
});
|
||||
if (res.statusCode === 200 && res.payload) {
|
||||
onPreview(
|
||||
sortValues(res.payload.variableValues ?? [], sort) as (string | number)[],
|
||||
);
|
||||
} else {
|
||||
onError(res.error || 'Failed to run query');
|
||||
onPreview([]);
|
||||
}
|
||||
} catch (err) {
|
||||
onError((err as Error).message || 'Failed to run query');
|
||||
onPreview([]);
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.queryContainer}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>Query</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.editorWrap}>
|
||||
<Editor
|
||||
language="sql"
|
||||
value={queryValue}
|
||||
onChange={(value): void => onChange(value)}
|
||||
height="240px"
|
||||
options={{
|
||||
fontSize: 13,
|
||||
wordWrap: 'on',
|
||||
lineNumbers: 'off',
|
||||
glyphMargin: false,
|
||||
folding: false,
|
||||
lineDecorationsWidth: 0,
|
||||
lineNumbersMinChars: 0,
|
||||
minimap: { enabled: false },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.testRow}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
loading={isRunning}
|
||||
disabled={!queryValue}
|
||||
onClick={runTest}
|
||||
testId="variable-test-run"
|
||||
>
|
||||
Test Run Query
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default QueryVariableFields;
|
||||
@@ -0,0 +1,310 @@
|
||||
/* Faithful reproduction of the V1 VariableItem layout, scoped as a module and
|
||||
built on @signozhq components where possible. antd is retained only for the
|
||||
monaco Editor, multiline TextArea, Collapse, and searchable Selects. */
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.allVariables {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.allVariablesBtn {
|
||||
--button-height: 24px;
|
||||
--button-padding: 0;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 12px 16px 20px;
|
||||
}
|
||||
|
||||
/* VariableItemRow */
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* LabelContainer */
|
||||
.labelContainer {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.column {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input,
|
||||
.textarea,
|
||||
.defaultInput {
|
||||
padding: 6px 6px 6px 8px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 2px;
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.input,
|
||||
.textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.defaultInput {
|
||||
width: 342px;
|
||||
}
|
||||
|
||||
.errorText {
|
||||
font-size: 12px;
|
||||
color: var(--bg-amber-500);
|
||||
}
|
||||
|
||||
/* Variable type segmented group */
|
||||
.typeSection {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.typeLabelContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.typeBtnGroup {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, max-content);
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 2px;
|
||||
background: var(--l2-background);
|
||||
box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.typeBtn {
|
||||
--button-height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
min-width: 114px;
|
||||
border-radius: 0;
|
||||
color: var(--l2-foreground);
|
||||
|
||||
& + & {
|
||||
border-left: 1px solid var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
.typeBtnSelected {
|
||||
background: var(--l1-border);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.betaTag {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* Query */
|
||||
.queryContainer {
|
||||
display: flex;
|
||||
flex-flow: column wrap;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.editorWrap {
|
||||
height: 240px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.testRow {
|
||||
display: flex;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Custom — antd Collapse */
|
||||
.customSection {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.customSection :global(.custom-collapse) {
|
||||
width: 100%;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 3px 3px 0 0;
|
||||
|
||||
:global(.ant-collapse-item) {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-header) {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 38px;
|
||||
padding: 12px;
|
||||
background: var(--l3-background);
|
||||
border-radius: 3px 3px 0 0;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-header-text) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 1px 2px;
|
||||
color: var(--bg-robin-400);
|
||||
font-family: 'Space Mono';
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
border-radius: 2px;
|
||||
background: color-mix(in srgb, var(--bg-robin-400) 8%, transparent);
|
||||
}
|
||||
|
||||
:global(.ant-collapse-content-box) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:global(.comma-input) {
|
||||
height: 109px;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Textbox */
|
||||
.textboxSection {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Preview strip */
|
||||
.previewSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 88px;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 8px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.previewLabel {
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 8px;
|
||||
color: var(--bg-robin-400);
|
||||
font-family: 'Space Mono';
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
border-radius: 3px 0 2px;
|
||||
background: color-mix(in srgb, var(--bg-robin-400) 8%, transparent);
|
||||
}
|
||||
|
||||
.previewValues {
|
||||
display: flex;
|
||||
flex-flow: wrap;
|
||||
gap: 8px;
|
||||
padding: 4.5px 11px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.previewValues [data-slot='badge'] {
|
||||
height: 30px;
|
||||
align-items: center;
|
||||
color: var(--l1-foreground);
|
||||
font-family: 'Space Mono';
|
||||
font-size: 14px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.previewError {
|
||||
color: var(--bg-amber-500);
|
||||
}
|
||||
|
||||
/* Sort / multi / all / default rows */
|
||||
.sortSection,
|
||||
.multiSection,
|
||||
.allOptionSection,
|
||||
.dynamicSection {
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.sortSection {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rowLabel {
|
||||
width: 339px;
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.sortSelect {
|
||||
width: 192px;
|
||||
}
|
||||
|
||||
.defaultValueSection {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.defaultValueSection .label {
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.defaultValueDesc {
|
||||
display: block;
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.06px;
|
||||
}
|
||||
|
||||
.searchSelect {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
margin-top: 12px;
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ArrowLeft, Check, X } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- TextArea/Collapse/searchable Select: no @signozhq/ui equivalent
|
||||
import { Collapse, Input as AntdInput, Select } from 'antd';
|
||||
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
|
||||
import sortValues from 'lib/dashboardVariables/sortVariableValues';
|
||||
|
||||
import {
|
||||
VARIABLE_SORTS,
|
||||
type VariableFormModel,
|
||||
type VariableSort,
|
||||
type VariableType,
|
||||
} from '../variableModel';
|
||||
import DynamicVariableFields from './DynamicVariableFields';
|
||||
import QueryVariableFields from './QueryVariableFields';
|
||||
import VariableTypeSelector from './VariableTypeSelector';
|
||||
import styles from './VariableForm.module.scss';
|
||||
|
||||
const SORT_LABEL: Record<VariableSort, string> = {
|
||||
DISABLED: 'Disabled',
|
||||
ASC: 'Ascending',
|
||||
DESC: 'Descending',
|
||||
};
|
||||
|
||||
function getNameError(name: string, existingNames: string[]): string | null {
|
||||
if (name === '') {
|
||||
return 'Variable name is required';
|
||||
}
|
||||
if (/\s/.test(name)) {
|
||||
return 'Variable name cannot contain whitespaces';
|
||||
}
|
||||
if (existingNames.includes(name)) {
|
||||
return 'Variable name already exists';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface VariableFormProps {
|
||||
initial: VariableFormModel;
|
||||
/** Names of the other variables, for uniqueness validation. */
|
||||
existingNames: string[];
|
||||
isSaving: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (model: VariableFormModel) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-drawer variable editor reproducing the V1 VariableItem layout, built on
|
||||
* @signozhq components (antd kept only for the monaco editor, TextArea, Collapse
|
||||
* and searchable selects). Master→detail: renders in place of the list.
|
||||
*/
|
||||
function VariableForm({
|
||||
initial,
|
||||
existingNames,
|
||||
isSaving,
|
||||
onClose,
|
||||
onSave,
|
||||
}: VariableFormProps): JSX.Element {
|
||||
const [model, setModel] = useState<VariableFormModel>(initial);
|
||||
const [previewValues, setPreviewValues] = useState<(string | number)[]>([]);
|
||||
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||
const [defaultValue, setDefaultValue] = useState<string>(
|
||||
((initial.defaultValue as { value?: string })?.value ?? '') as string,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setModel(initial);
|
||||
setPreviewValues([]);
|
||||
setPreviewError(null);
|
||||
setDefaultValue(
|
||||
((initial.defaultValue as { value?: string })?.value ?? '') as string,
|
||||
);
|
||||
}, [initial]);
|
||||
|
||||
const set = (patch: Partial<VariableFormModel>): void =>
|
||||
setModel((prev) => ({ ...prev, ...patch }));
|
||||
|
||||
const selectType = (type: VariableType): void => {
|
||||
set({ type });
|
||||
setPreviewValues([]);
|
||||
setPreviewError(null);
|
||||
};
|
||||
|
||||
const onCustomChange = (value: string): void => {
|
||||
set({ customValue: value });
|
||||
setPreviewValues(
|
||||
sortValues(commaValuesParser(value), model.sort) as (string | number)[],
|
||||
);
|
||||
};
|
||||
|
||||
const trimmedName = model.name.trim();
|
||||
const nameError = getNameError(trimmedName, existingNames);
|
||||
|
||||
const isListType =
|
||||
model.type === 'QUERY' || model.type === 'CUSTOM' || model.type === 'DYNAMIC';
|
||||
const showAllOptionField = model.type === 'QUERY' || model.type === 'CUSTOM';
|
||||
|
||||
const handleSave = (): void => {
|
||||
onSave({
|
||||
...model,
|
||||
name: trimmedName,
|
||||
defaultValue: defaultValue ? { value: defaultValue } : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.allVariables}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className={styles.allVariablesBtn}
|
||||
prefix={<ArrowLeft size={14} />}
|
||||
onClick={onClose}
|
||||
testId="variable-form-back"
|
||||
>
|
||||
All variables
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
{/* Name */}
|
||||
<div className={cx(styles.row, styles.column)}>
|
||||
<Typography.Text className={styles.label}>Name</Typography.Text>
|
||||
<Input
|
||||
className={styles.input}
|
||||
value={model.name}
|
||||
placeholder="Unique name of the variable"
|
||||
onChange={(e): void => set({ name: e.target.value })}
|
||||
testId="variable-name-input"
|
||||
/>
|
||||
{nameError ? (
|
||||
<Typography.Text className={styles.errorText}>
|
||||
{nameError}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className={cx(styles.row, styles.column)}>
|
||||
<Typography.Text className={styles.label}>Description</Typography.Text>
|
||||
<AntdInput.TextArea
|
||||
className={styles.textarea}
|
||||
value={model.description}
|
||||
placeholder="Enter a description for the variable"
|
||||
rows={3}
|
||||
onChange={(e): void => set({ description: e.target.value })}
|
||||
data-testid="variable-description-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Variable Type */}
|
||||
<VariableTypeSelector value={model.type} onChange={selectType} />
|
||||
|
||||
{/* Type-specific body */}
|
||||
{model.type === 'DYNAMIC' ? (
|
||||
<DynamicVariableFields
|
||||
attribute={model.dynamicAttribute}
|
||||
signal={model.dynamicSignal}
|
||||
onChange={(patch): void => set(patch)}
|
||||
onPreview={setPreviewValues}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{model.type === 'QUERY' ? (
|
||||
<QueryVariableFields
|
||||
queryValue={model.queryValue}
|
||||
sort={model.sort}
|
||||
onChange={(queryValue): void => set({ queryValue })}
|
||||
onPreview={setPreviewValues}
|
||||
onError={setPreviewError}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{model.type === 'CUSTOM' ? (
|
||||
<div className={cx(styles.row, styles.customSection)}>
|
||||
<Collapse
|
||||
collapsible="header"
|
||||
rootClassName="custom-collapse"
|
||||
defaultActiveKey={['1']}
|
||||
items={[
|
||||
{
|
||||
key: '1',
|
||||
label: 'Options',
|
||||
children: (
|
||||
<AntdInput.TextArea
|
||||
value={model.customValue}
|
||||
placeholder="Enter options separated by commas."
|
||||
rootClassName="comma-input"
|
||||
onChange={(e): void => onCustomChange(e.target.value)}
|
||||
data-testid="variable-custom-input"
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{model.type === 'TEXT' ? (
|
||||
<div className={cx(styles.row, styles.textboxSection)}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>
|
||||
Default Value
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Input
|
||||
className={styles.defaultInput}
|
||||
value={model.textValue}
|
||||
placeholder="Enter a default value (if any)..."
|
||||
onChange={(e): void => set({ textValue: e.target.value })}
|
||||
testId="variable-text-input"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Shared rows for list-type variables */}
|
||||
{isListType ? (
|
||||
<>
|
||||
<div className={cx(styles.row, styles.previewSection)}>
|
||||
<Typography.Text className={styles.previewLabel}>
|
||||
Preview of Values
|
||||
</Typography.Text>
|
||||
<div className={styles.previewValues}>
|
||||
{previewError ? (
|
||||
<Typography.Text className={styles.previewError}>
|
||||
{previewError}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
previewValues.map((value, idx) => (
|
||||
<Badge
|
||||
// eslint-disable-next-line react/no-array-index-key -- preview values are display-only and may contain duplicates
|
||||
key={`${value}-${idx}`}
|
||||
color="vanilla"
|
||||
>
|
||||
{value.toString()}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cx(styles.row, styles.sortSection)}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>Sort Values</Typography.Text>
|
||||
</div>
|
||||
<SelectSimple
|
||||
className={styles.sortSelect}
|
||||
value={model.sort}
|
||||
items={VARIABLE_SORTS.map((sort) => ({
|
||||
label: SORT_LABEL[sort],
|
||||
value: sort,
|
||||
}))}
|
||||
onChange={(value): void => set({ sort: value as VariableSort })}
|
||||
testId="variable-sort-select"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={cx(styles.row, styles.multiSection)}>
|
||||
<Typography.Text className={styles.rowLabel}>
|
||||
Enable multiple values to be checked
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
value={model.multiSelect}
|
||||
onChange={(checked): void => {
|
||||
set({
|
||||
multiSelect: checked,
|
||||
showAllOption: checked ? model.showAllOption : false,
|
||||
});
|
||||
}}
|
||||
testId="variable-multi-switch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{model.multiSelect && showAllOptionField ? (
|
||||
<div className={cx(styles.row, styles.allOptionSection)}>
|
||||
<Typography.Text className={styles.rowLabel}>
|
||||
Include an option for ALL values
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
value={model.showAllOption}
|
||||
onChange={(checked): void => set({ showAllOption: checked })}
|
||||
testId="variable-all-switch"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={cx(styles.row, styles.defaultValueSection)}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>
|
||||
Default Value
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.defaultValueDesc}>
|
||||
{model.type === 'QUERY'
|
||||
? 'Click Test Run Query to see the values or add custom value'
|
||||
: 'Select a value from the preview values or add custom value'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Select
|
||||
className={styles.searchSelect}
|
||||
showSearch
|
||||
allowClear
|
||||
placeholder="Select a default value"
|
||||
value={defaultValue || undefined}
|
||||
onChange={(value): void => setDefaultValue(value ?? '')}
|
||||
options={previewValues.map((value) => ({
|
||||
label: value.toString(),
|
||||
value: value.toString(),
|
||||
}))}
|
||||
data-testid="variable-default-select"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<X size={14} />}
|
||||
onClick={onClose}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Check size={14} />}
|
||||
disabled={!!nameError}
|
||||
loading={isSaving}
|
||||
onClick={handleSave}
|
||||
testId="variable-save"
|
||||
>
|
||||
Save Variable
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariableForm;
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
ClipboardType,
|
||||
DatabaseZap,
|
||||
Info,
|
||||
LayoutList,
|
||||
Pyramid,
|
||||
} from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
|
||||
import type { VariableType } from '../variableModel';
|
||||
import styles from './VariableForm.module.scss';
|
||||
|
||||
interface VariableTypeSelectorProps {
|
||||
value: VariableType;
|
||||
onChange: (type: VariableType) => void;
|
||||
}
|
||||
|
||||
/** The segmented Dynamic / Textbox / Custom / Query type picker. */
|
||||
function VariableTypeSelector({
|
||||
value,
|
||||
onChange,
|
||||
}: VariableTypeSelectorProps): JSX.Element {
|
||||
return (
|
||||
<div className={cx(styles.row, styles.typeSection)}>
|
||||
<div className={styles.typeLabelContainer}>
|
||||
<Typography.Text className={styles.label}>Variable Type</Typography.Text>
|
||||
<TextToolTip
|
||||
text="Learn more about supported variable types"
|
||||
url="https://signoz.io/docs/userguide/manage-variables/#supported-variable-types"
|
||||
urlText="here"
|
||||
useFilledIcon={false}
|
||||
outlinedIcon={<Info size={14} />}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.typeBtnGroup}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
prefix={<Pyramid size={14} />}
|
||||
className={cx(styles.typeBtn, {
|
||||
[styles.typeBtnSelected]: value === 'DYNAMIC',
|
||||
})}
|
||||
onClick={(): void => onChange('DYNAMIC')}
|
||||
testId="variable-type-dynamic"
|
||||
>
|
||||
Dynamic
|
||||
<Badge color="robin" className={styles.betaTag}>
|
||||
Beta
|
||||
</Badge>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
prefix={<ClipboardType size={14} />}
|
||||
className={cx(styles.typeBtn, {
|
||||
[styles.typeBtnSelected]: value === 'TEXT',
|
||||
})}
|
||||
onClick={(): void => onChange('TEXT')}
|
||||
testId="variable-type-textbox"
|
||||
>
|
||||
Textbox
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
prefix={<LayoutList size={14} />}
|
||||
className={cx(styles.typeBtn, {
|
||||
[styles.typeBtnSelected]: value === 'CUSTOM',
|
||||
})}
|
||||
onClick={(): void => onChange('CUSTOM')}
|
||||
testId="variable-type-custom"
|
||||
>
|
||||
Custom
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
prefix={<DatabaseZap size={14} />}
|
||||
className={cx(styles.typeBtn, {
|
||||
[styles.typeBtnSelected]: value === 'QUERY',
|
||||
})}
|
||||
onClick={(): void => onChange('QUERY')}
|
||||
testId="variable-type-query"
|
||||
>
|
||||
Query
|
||||
<Badge color="amber" className={styles.betaTag}>
|
||||
Not Recommended
|
||||
</Badge>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariableTypeSelector;
|
||||
@@ -0,0 +1,101 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
border: 1px dashed var(--l1-border);
|
||||
border-radius: 4px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.rowMain {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.varName {
|
||||
font-weight: 500;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.varDesc {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.typeTag {
|
||||
flex-shrink: 0;
|
||||
padding: 1px 8px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--l2-foreground);
|
||||
text-transform: uppercase;
|
||||
background: var(--l2-background);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.confirmText {
|
||||
margin-right: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
PenLine,
|
||||
Trash2,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import type { VariableFormModel } from './variableModel';
|
||||
import styles from './Variables.module.scss';
|
||||
|
||||
const TYPE_LABEL: Record<VariableFormModel['type'], string> = {
|
||||
QUERY: 'Query',
|
||||
CUSTOM: 'Custom',
|
||||
TEXT: 'Text',
|
||||
DYNAMIC: 'Dynamic',
|
||||
};
|
||||
|
||||
interface VariablesListProps {
|
||||
variables: VariableFormModel[];
|
||||
canEdit: boolean;
|
||||
/** Index whose delete is awaiting inline confirmation, if any. */
|
||||
confirmingIndex: number | null;
|
||||
onEdit: (index: number) => void;
|
||||
onRequestDelete: (index: number) => void;
|
||||
onConfirmDelete: (index: number) => void;
|
||||
onCancelDelete: () => void;
|
||||
onMove: (from: number, to: number) => void;
|
||||
}
|
||||
|
||||
function VariablesList({
|
||||
variables,
|
||||
canEdit,
|
||||
confirmingIndex,
|
||||
onEdit,
|
||||
onRequestDelete,
|
||||
onConfirmDelete,
|
||||
onCancelDelete,
|
||||
onMove,
|
||||
}: VariablesListProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.list} data-testid="variables-list">
|
||||
{variables.map((variable, index) => (
|
||||
<div
|
||||
className={styles.row}
|
||||
key={variable.name || `variable-${index}`}
|
||||
data-testid={`variable-row-${variable.name}`}
|
||||
>
|
||||
<div className={styles.rowMain}>
|
||||
<Typography.Text className={styles.varName}>
|
||||
${variable.name}
|
||||
</Typography.Text>
|
||||
<span className={styles.typeTag}>{TYPE_LABEL[variable.type]}</span>
|
||||
{variable.description ? (
|
||||
<Typography.Text className={styles.varDesc}>
|
||||
{variable.description}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{canEdit && confirmingIndex === index ? (
|
||||
<div className={styles.rowActions}>
|
||||
<Typography.Text className={styles.confirmText}>Delete?</Typography.Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
size="icon"
|
||||
onClick={(): void => onConfirmDelete(index)}
|
||||
aria-label="Confirm delete"
|
||||
testId={`variable-delete-confirm-${variable.name}`}
|
||||
>
|
||||
<Check size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={onCancelDelete}
|
||||
aria-label="Cancel delete"
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canEdit && confirmingIndex !== index ? (
|
||||
<div className={styles.rowActions}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
disabled={index === 0}
|
||||
onClick={(): void => onMove(index, index - 1)}
|
||||
aria-label="Move up"
|
||||
>
|
||||
<ChevronUp size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
disabled={index === variables.length - 1}
|
||||
onClick={(): void => onMove(index, index + 1)}
|
||||
aria-label="Move down"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={(): void => onEdit(index)}
|
||||
aria-label="Edit variable"
|
||||
testId={`variable-edit-${variable.name}`}
|
||||
>
|
||||
<PenLine size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={(): void => onRequestDelete(index)}
|
||||
aria-label="Delete variable"
|
||||
testId={`variable-delete-${variable.name}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariablesList;
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { useSaveVariables } from './useSaveVariables';
|
||||
import { dtoToFormModel } from './variableAdapters';
|
||||
import {
|
||||
emptyVariableFormModel,
|
||||
type VariableFormModel,
|
||||
} from './variableModel';
|
||||
import VariableForm from './VariableForm/VariableForm';
|
||||
import VariablesList from './VariablesList';
|
||||
import styles from './Variables.module.scss';
|
||||
|
||||
interface VariablesSettingsProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
}
|
||||
|
||||
/** `null` index = adding a new variable; a number = editing that row. */
|
||||
type EditingState = { index: number | null } | null;
|
||||
|
||||
function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const { save, isSaving } = useSaveVariables();
|
||||
|
||||
const initialModels = useMemo(
|
||||
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
|
||||
[dashboard.spec?.variables],
|
||||
);
|
||||
const [variables, setVariables] = useState<VariableFormModel[]>(initialModels);
|
||||
|
||||
// Resync from the dashboard after a save round-trips (refetch bumps updatedAt).
|
||||
useEffect(() => {
|
||||
setVariables(initialModels);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dashboard.updatedAt]);
|
||||
|
||||
const [editing, setEditing] = useState<EditingState>(null);
|
||||
const [confirmDeleteIndex, setConfirmDeleteIndex] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const editingModel: VariableFormModel | null = useMemo(() => {
|
||||
if (!editing) {
|
||||
return null;
|
||||
}
|
||||
return editing.index === null
|
||||
? emptyVariableFormModel()
|
||||
: variables[editing.index];
|
||||
}, [editing, variables]);
|
||||
|
||||
const existingNames = useMemo(() => {
|
||||
const self = editing?.index ?? null;
|
||||
return variables.filter((_, i) => i !== self).map((v) => v.name);
|
||||
}, [variables, editing]);
|
||||
|
||||
const persist = (next: VariableFormModel[]): void => {
|
||||
setVariables(next);
|
||||
void save(next);
|
||||
};
|
||||
|
||||
const handleFormSave = (model: VariableFormModel): void => {
|
||||
const next = [...variables];
|
||||
if (editing?.index == null) {
|
||||
next.push(model);
|
||||
} else {
|
||||
next[editing.index] = model;
|
||||
}
|
||||
setEditing(null);
|
||||
persist(next);
|
||||
};
|
||||
|
||||
const handleMove = (from: number, to: number): void => {
|
||||
if (to < 0 || to >= variables.length) {
|
||||
return;
|
||||
}
|
||||
const next = [...variables];
|
||||
const [moved] = next.splice(from, 1);
|
||||
next.splice(to, 0, moved);
|
||||
persist(next);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = (index: number): void => {
|
||||
persist(variables.filter((_, i) => i !== index));
|
||||
setConfirmDeleteIndex(null);
|
||||
};
|
||||
|
||||
// Detail view — edit/new form replaces the list in place (no modal).
|
||||
if (editingModel) {
|
||||
return (
|
||||
<VariableForm
|
||||
initial={editingModel}
|
||||
existingNames={existingNames}
|
||||
isSaving={isSaving}
|
||||
onClose={(): void => setEditing(null)}
|
||||
onSave={handleFormSave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Master view — the variables list.
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.titleRow}>
|
||||
<Typography.Text className={styles.title}>Variables</Typography.Text>
|
||||
<Typography.Text className={styles.subtitle}>
|
||||
Define variables to parameterize panel queries.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{isEditable ? (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={(): void => setEditing({ index: null })}
|
||||
testId="add-variable"
|
||||
>
|
||||
New variable
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{variables.length === 0 ? (
|
||||
<div className={styles.empty}>
|
||||
<Typography.Text>No variables defined yet.</Typography.Text>
|
||||
</div>
|
||||
) : (
|
||||
<VariablesList
|
||||
variables={variables}
|
||||
canEdit={isEditable}
|
||||
confirmingIndex={confirmDeleteIndex}
|
||||
onEdit={(index): void => setEditing({ index })}
|
||||
onRequestDelete={(index): void => setConfirmDeleteIndex(index)}
|
||||
onConfirmDelete={handleConfirmDelete}
|
||||
onCancelDelete={(): void => setConfirmDeleteIndex(null)}
|
||||
onMove={handleMove}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariablesSettings;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { formModelToDto } from './variableAdapters';
|
||||
import type { VariableFormModel } from './variableModel';
|
||||
import { buildVariablesPatch } from './variablePatchOps';
|
||||
|
||||
interface UseSaveVariables {
|
||||
save: (variables: VariableFormModel[]) => Promise<boolean>;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the dashboard's variable list via a single `/spec/variables` patch,
|
||||
* then refetches. Mirrors the General-settings save flow (patch → toast →
|
||||
* refetch → surface errors).
|
||||
*/
|
||||
export function useSaveVariables(): UseSaveVariables {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const refetch = useDashboardStore((s) => s.refetch);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const save = useCallback(
|
||||
async (variables: VariableFormModel[]): Promise<boolean> => {
|
||||
if (!dashboardId) {
|
||||
return false;
|
||||
}
|
||||
const dtos = variables.map(formModelToDto);
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchDashboardV2({ id: dashboardId }, buildVariablesPatch(dtos));
|
||||
toast.success('Variables updated');
|
||||
refetch();
|
||||
return true;
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
return false;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[dashboardId, refetch, showErrorModal],
|
||||
);
|
||||
|
||||
return { save, isSaving };
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTOKind as TextEnvelopeKind,
|
||||
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTOKind as ListEnvelopeKind,
|
||||
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesCustomVariableSpecDTOKind as CustomPluginKind,
|
||||
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDynamicVariableSpecDTOKind as DynamicPluginKind,
|
||||
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesQueryVariableSpecDTOKind as QueryPluginKind,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardtypesListVariableSpecDTO,
|
||||
DashboardtypesVariableDTO,
|
||||
DashboardtypesVariablePluginDTO,
|
||||
DashboardTextVariableSpecDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
emptyVariableFormModel,
|
||||
PLUGIN_KIND,
|
||||
type TelemetrySignal,
|
||||
type VariableFormModel,
|
||||
type VariableSort,
|
||||
} from './variableModel';
|
||||
|
||||
/** DTO envelope → flat form model (for display / editing). */
|
||||
export function dtoToFormModel(
|
||||
dto: DashboardtypesVariableDTO,
|
||||
): VariableFormModel {
|
||||
const base = emptyVariableFormModel();
|
||||
const display = dto.spec?.display;
|
||||
const common: VariableFormModel = {
|
||||
...base,
|
||||
name: dto.spec?.name ?? display?.name ?? '',
|
||||
description: display?.description ?? '',
|
||||
hidden: display?.hidden ?? false,
|
||||
};
|
||||
|
||||
// Text variable — a distinct envelope (no list plugin).
|
||||
if (dto.kind === TextEnvelopeKind.TextVariable) {
|
||||
const spec = dto.spec as DashboardTextVariableSpecDTO;
|
||||
return {
|
||||
...common,
|
||||
type: 'TEXT',
|
||||
textValue: spec.value ?? '',
|
||||
textConstant: spec.constant ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
// List variable — Query / Custom / Dynamic, distinguished by plugin.kind.
|
||||
const spec = dto.spec as DashboardtypesListVariableSpecDTO;
|
||||
const listCommon: VariableFormModel = {
|
||||
...common,
|
||||
multiSelect: spec.allowMultiple ?? false,
|
||||
showAllOption: spec.allowAllValue ?? false,
|
||||
sort: (spec.sort as VariableSort) ?? 'DISABLED',
|
||||
defaultValue: spec.defaultValue,
|
||||
};
|
||||
const plugin = spec.plugin;
|
||||
|
||||
if (plugin?.kind === CustomPluginKind['signoz/CustomVariable']) {
|
||||
return {
|
||||
...listCommon,
|
||||
type: 'CUSTOM',
|
||||
customValue: plugin.spec.customValue ?? '',
|
||||
};
|
||||
}
|
||||
if (plugin?.kind === DynamicPluginKind['signoz/DynamicVariable']) {
|
||||
return {
|
||||
...listCommon,
|
||||
type: 'DYNAMIC',
|
||||
dynamicAttribute: plugin.spec.name ?? '',
|
||||
dynamicSignal: (plugin.spec.signal as TelemetrySignal) ?? 'traces',
|
||||
};
|
||||
}
|
||||
// Default to Query (also covers a query plugin or a missing/unknown plugin).
|
||||
return {
|
||||
...listCommon,
|
||||
type: 'QUERY',
|
||||
queryValue:
|
||||
plugin?.kind === QueryPluginKind['signoz/QueryVariable']
|
||||
? (plugin.spec.queryValue ?? '')
|
||||
: '',
|
||||
};
|
||||
}
|
||||
|
||||
function buildPlugin(
|
||||
model: VariableFormModel,
|
||||
): DashboardtypesVariablePluginDTO {
|
||||
switch (model.type) {
|
||||
case 'CUSTOM':
|
||||
return {
|
||||
kind: CustomPluginKind['signoz/CustomVariable'],
|
||||
spec: { customValue: model.customValue },
|
||||
};
|
||||
case 'DYNAMIC':
|
||||
return {
|
||||
kind: DynamicPluginKind['signoz/DynamicVariable'],
|
||||
spec: {
|
||||
name: model.dynamicAttribute,
|
||||
signal: model.dynamicSignal as TelemetrytypesSignalDTO,
|
||||
},
|
||||
};
|
||||
case 'QUERY':
|
||||
default:
|
||||
return {
|
||||
kind: QueryPluginKind['signoz/QueryVariable'],
|
||||
spec: { queryValue: model.queryValue },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Flat form model → DTO envelope (for persistence). */
|
||||
export function formModelToDto(
|
||||
model: VariableFormModel,
|
||||
): DashboardtypesVariableDTO {
|
||||
const display = {
|
||||
name: model.name,
|
||||
description: model.description,
|
||||
hidden: model.hidden,
|
||||
};
|
||||
|
||||
if (model.type === 'TEXT') {
|
||||
return {
|
||||
kind: TextEnvelopeKind.TextVariable,
|
||||
spec: {
|
||||
name: model.name,
|
||||
display,
|
||||
value: model.textValue,
|
||||
constant: model.textConstant,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: ListEnvelopeKind.ListVariable,
|
||||
spec: {
|
||||
name: model.name,
|
||||
display,
|
||||
allowMultiple: model.multiSelect,
|
||||
allowAllValue: model.showAllOption,
|
||||
sort: model.sort,
|
||||
defaultValue: model.defaultValue,
|
||||
plugin: buildPlugin(model),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Maps the V2 plugin/envelope to the four UI-facing variable types. */
|
||||
export function variableTypeOf(
|
||||
dto: DashboardtypesVariableDTO,
|
||||
): VariableFormModel['type'] {
|
||||
return dtoToFormModel(dto).type;
|
||||
}
|
||||
|
||||
export { PLUGIN_KIND };
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { VariableDefaultValueDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/**
|
||||
* Flat, UI-friendly representation of a V2 dashboard variable. The wire format
|
||||
* (`DashboardtypesVariableDTO`) is a nested envelope/plugin union that is awkward
|
||||
* to bind a form to; `variableAdapters` converts between this model and the DTO.
|
||||
*/
|
||||
|
||||
export type VariableType = 'QUERY' | 'CUSTOM' | 'TEXT' | 'DYNAMIC';
|
||||
|
||||
export type VariableSort = 'DISABLED' | 'ASC' | 'DESC';
|
||||
|
||||
export type TelemetrySignal = 'traces' | 'logs' | 'metrics';
|
||||
|
||||
/** Wire `kind` discriminators (string values of the generated enums). */
|
||||
export const ENVELOPE_KIND = {
|
||||
LIST: 'ListVariable',
|
||||
TEXT: 'TextVariable',
|
||||
} as const;
|
||||
|
||||
export const PLUGIN_KIND = {
|
||||
QUERY: 'signoz/QueryVariable',
|
||||
CUSTOM: 'signoz/CustomVariable',
|
||||
DYNAMIC: 'signoz/DynamicVariable',
|
||||
} as const;
|
||||
|
||||
export const VARIABLE_SORTS: VariableSort[] = ['DISABLED', 'ASC', 'DESC'];
|
||||
|
||||
export const TELEMETRY_SIGNALS: TelemetrySignal[] = [
|
||||
'traces',
|
||||
'logs',
|
||||
'metrics',
|
||||
];
|
||||
|
||||
export interface VariableFormModel {
|
||||
/** Stable identifier, referenced in queries (e.g. `$name`); must be unique. */
|
||||
name: string;
|
||||
description: string;
|
||||
hidden: boolean;
|
||||
type: VariableType;
|
||||
|
||||
// List-variable common fields (Query / Custom / Dynamic).
|
||||
multiSelect: boolean;
|
||||
showAllOption: boolean;
|
||||
sort: VariableSort;
|
||||
|
||||
// Type-specific.
|
||||
queryValue: string; // QUERY
|
||||
customValue: string; // CUSTOM
|
||||
textValue: string; // TEXT
|
||||
textConstant: boolean; // TEXT
|
||||
dynamicAttribute: string; // DYNAMIC — the telemetry field name
|
||||
dynamicSignal: TelemetrySignal; // DYNAMIC — the telemetry signal
|
||||
|
||||
/**
|
||||
* Runtime-selected default, not editable in the management tab yet; carried
|
||||
* through edits so saving a definition doesn't clobber it.
|
||||
*/
|
||||
defaultValue?: VariableDefaultValueDTO;
|
||||
}
|
||||
|
||||
export function emptyVariableFormModel(): VariableFormModel {
|
||||
return {
|
||||
name: '',
|
||||
description: '',
|
||||
hidden: false,
|
||||
type: 'QUERY',
|
||||
multiSelect: false,
|
||||
showAllOption: false,
|
||||
sort: 'DISABLED',
|
||||
queryValue: '',
|
||||
customValue: '',
|
||||
textValue: '',
|
||||
textConstant: false,
|
||||
dynamicAttribute: '',
|
||||
dynamicSignal: 'traces',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type {
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
DashboardtypesVariableDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/**
|
||||
* Builds the JSON-Patch to persist the dashboard's variable list. Add/edit/
|
||||
* delete/reorder all replace the whole `/spec/variables` array in one atomic op
|
||||
* — simpler and race-free vs per-index patches. RFC-6902 `add` on an object
|
||||
* member sets-or-replaces, so it works whether or not `variables` already exists.
|
||||
*/
|
||||
export function buildVariablesPatch(
|
||||
variables: DashboardtypesVariableDTO[],
|
||||
): DashboardtypesJSONPatchOperationDTO[] {
|
||||
return [
|
||||
{
|
||||
op: 'add' as DashboardtypesJSONPatchOperationDTO['op'],
|
||||
path: '/spec/variables',
|
||||
value: variables,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,48 +1,39 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Braces, Globe, Table } from '@signozhq/icons';
|
||||
import { Tabs } from '@signozhq/ui/tabs';
|
||||
import { TabItemProps, Tabs } from '@signozhq/ui/tabs';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import GeneralSettings from './General';
|
||||
import { SettingsTabPlaceholder } from './utils';
|
||||
|
||||
import styles from './DashboardSettings.module.scss';
|
||||
import VariablesSettings from './Variables';
|
||||
|
||||
interface DashboardSettingsProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
}
|
||||
|
||||
function tabLabel(icon: JSX.Element, text: string): JSX.Element {
|
||||
return (
|
||||
<span className={styles.tabLabel}>
|
||||
{icon}
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardSettings({ dashboard }: DashboardSettingsProps): JSX.Element {
|
||||
const items = useMemo(
|
||||
const items: TabItemProps[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'general',
|
||||
label: tabLabel(<Table size={14} />, 'General'),
|
||||
label: 'General',
|
||||
children: <GeneralSettings dashboard={dashboard} />,
|
||||
prefixIcon: <Table size={14} />,
|
||||
},
|
||||
{
|
||||
key: 'variables',
|
||||
label: tabLabel(<Braces size={14} />, 'Variables'),
|
||||
children: (
|
||||
<SettingsTabPlaceholder message="V2 dashboard variables coming next." />
|
||||
),
|
||||
label: 'Variables',
|
||||
children: <VariablesSettings dashboard={dashboard} />,
|
||||
prefixIcon: <Braces size={14} />,
|
||||
},
|
||||
{
|
||||
key: 'public-dashboard',
|
||||
label: tabLabel(<Globe size={14} />, 'Publish'),
|
||||
label: 'Publish',
|
||||
children: (
|
||||
<SettingsTabPlaceholder message="V2 public dashboard publishing coming next." />
|
||||
),
|
||||
prefixIcon: <Globe size={14} />,
|
||||
},
|
||||
],
|
||||
[dashboard],
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
|
||||
import sortValues from 'lib/dashboardVariables/sortVariableValues';
|
||||
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
|
||||
import type { VariableSelection, VariableSelectionMap } from './selectionTypes';
|
||||
import DynamicSelector from './selectors/DynamicSelector';
|
||||
import QuerySelector from './selectors/QuerySelector';
|
||||
import TextSelector from './selectors/TextSelector';
|
||||
import ValueSelector from './selectors/ValueSelector';
|
||||
import styles from './VariablesBar.module.scss';
|
||||
|
||||
interface VariableSelectorProps {
|
||||
variable: VariableFormModel;
|
||||
/** All variables (Dynamic uses them to scope options by sibling selections). */
|
||||
variables: VariableFormModel[];
|
||||
/** Names this variable depends on (for Query gating). */
|
||||
parents: string[];
|
||||
/** All current selections (Query passes them as the request payload). */
|
||||
selections: VariableSelectionMap;
|
||||
selection: VariableSelection;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
}
|
||||
|
||||
/** One labelled variable control; dispatches on the variable type. */
|
||||
function VariableSelector({
|
||||
variable,
|
||||
variables,
|
||||
parents,
|
||||
selections,
|
||||
selection,
|
||||
onChange,
|
||||
}: VariableSelectorProps): JSX.Element {
|
||||
const customOptions = useMemo(
|
||||
() =>
|
||||
variable.type === 'CUSTOM'
|
||||
? sortValues(commaValuesParser(variable.customValue), variable.sort).map(
|
||||
String,
|
||||
)
|
||||
: [],
|
||||
[variable],
|
||||
);
|
||||
|
||||
const renderControl = (): JSX.Element => {
|
||||
switch (variable.type) {
|
||||
case 'TEXT':
|
||||
return (
|
||||
<TextSelector
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
testId={`variable-input-${variable.name}`}
|
||||
/>
|
||||
);
|
||||
case 'QUERY':
|
||||
return (
|
||||
<QuerySelector
|
||||
variable={variable}
|
||||
parents={parents}
|
||||
selections={selections}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
case 'DYNAMIC':
|
||||
return (
|
||||
<DynamicSelector
|
||||
variable={variable}
|
||||
variables={variables}
|
||||
selections={selections}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
case 'CUSTOM':
|
||||
default:
|
||||
return (
|
||||
<ValueSelector
|
||||
options={customOptions}
|
||||
multiSelect={variable.multiSelect}
|
||||
showAllOption={variable.showAllOption}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
testId={`variable-select-${variable.name}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.variable} data-testid={`variable-${variable.name}`}>
|
||||
<Typography.Text className={styles.label}>${variable.name}</Typography.Text>
|
||||
{renderControl()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariableSelector;
|
||||
@@ -0,0 +1,29 @@
|
||||
.bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 12px 16px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.variable {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.select {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.input {
|
||||
min-width: 160px;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { useVariableSelection } from './useVariableSelection';
|
||||
import VariableSelector from './VariableSelector';
|
||||
import styles from './VariablesBar.module.scss';
|
||||
|
||||
interface VariablesBarProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime variable selector bar shown above the panels. Renders one control per
|
||||
* dashboard variable; selections live in the store + URL (never the spec).
|
||||
*/
|
||||
function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
const { variables, dependencyData, selection, setSelection } =
|
||||
useVariableSelection(dashboard);
|
||||
|
||||
if (variables.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.bar} data-testid="dashboard-variables-bar">
|
||||
{variables.map((variable) => (
|
||||
<VariableSelector
|
||||
key={variable.name}
|
||||
variable={variable}
|
||||
variables={variables}
|
||||
parents={dependencyData.parentGraph[variable.name] ?? []}
|
||||
selections={selection}
|
||||
selection={
|
||||
selection[variable.name] ?? {
|
||||
value: variable.multiSelect ? [] : '',
|
||||
allSelected: false,
|
||||
}
|
||||
}
|
||||
onChange={(next): void => setSelection(variable.name, next)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariablesBar;
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
|
||||
import type { VariableSelectionMap } from './selectionTypes';
|
||||
|
||||
function formatQueryValue(val: string): string {
|
||||
const num = Number(val);
|
||||
if (!Number.isNaN(num) && Number.isFinite(num)) {
|
||||
return val;
|
||||
}
|
||||
return `'${val.replace(/'/g, "\\'")}'`;
|
||||
}
|
||||
|
||||
function buildQueryPart(attribute: string, values: string[]): string {
|
||||
const formatted = values.map(formatQueryValue);
|
||||
if (formatted.length === 1) {
|
||||
return `${attribute} = ${formatted[0]}`;
|
||||
}
|
||||
return `${attribute} IN [${formatted.join(', ')}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a filter expression from the OTHER dynamic variables' current
|
||||
* selections (e.g. `k8s.namespace.name IN ['prod'] AND service = 'api'`), so a
|
||||
* dynamic variable's option list is scoped by its sibling selections. Variables
|
||||
* in the ALL state, with no selection, or non-dynamic are skipped. Ported from
|
||||
* the V1 dynamic-variable runtime.
|
||||
*/
|
||||
export function buildExistingDynamicVariableQuery(
|
||||
variables: VariableFormModel[],
|
||||
selections: VariableSelectionMap,
|
||||
currentName: string,
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
variables.forEach((variable) => {
|
||||
if (
|
||||
variable.name === currentName ||
|
||||
variable.type !== 'DYNAMIC' ||
|
||||
!variable.dynamicAttribute
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const selection = selections[variable.name];
|
||||
if (!selection || selection.allSelected) {
|
||||
return;
|
||||
}
|
||||
const raw = Array.isArray(selection.value)
|
||||
? selection.value
|
||||
: [selection.value];
|
||||
const valid = raw
|
||||
.filter((v) => v !== null && v !== undefined && v !== '')
|
||||
.map((v) => String(v));
|
||||
if (valid.length > 0) {
|
||||
parts.push(buildQueryPart(variable.dynamicAttribute, valid));
|
||||
}
|
||||
});
|
||||
return parts.join(' AND ');
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/** A user-selected variable value at runtime (not persisted to the spec). */
|
||||
export type SelectedVariableValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| (string | number | boolean)[]
|
||||
| null;
|
||||
|
||||
export interface VariableSelection {
|
||||
value: SelectedVariableValue;
|
||||
/** True when every option is selected ("ALL"); for dynamic vars value may be null. */
|
||||
allSelected: boolean;
|
||||
}
|
||||
|
||||
/** Selected values for a dashboard's variables, keyed by variable name. */
|
||||
export type VariableSelectionMap = Record<string, VariableSelection>;
|
||||
@@ -0,0 +1,31 @@
|
||||
import type {
|
||||
SelectedVariableValue,
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from './selectionTypes';
|
||||
|
||||
/** A selection counts as resolved (usable as a parent value) when it's non-empty. */
|
||||
export function isResolved(selection?: VariableSelection): boolean {
|
||||
if (!selection) {
|
||||
return false;
|
||||
}
|
||||
if (selection.allSelected) {
|
||||
return true;
|
||||
}
|
||||
const { value } = selection;
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0;
|
||||
}
|
||||
return value !== '' && value !== null && value !== undefined;
|
||||
}
|
||||
|
||||
/** Flatten the selection map into the `{ name: value }` payload a query expects. */
|
||||
export function selectionToPayload(
|
||||
selection: VariableSelectionMap,
|
||||
): Record<string, SelectedVariableValue> {
|
||||
const payload: Record<string, SelectedVariableValue> = {};
|
||||
Object.entries(selection).forEach(([name, sel]) => {
|
||||
payload[name] = sel.value;
|
||||
});
|
||||
return payload;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useMemo } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
|
||||
import sortValues from 'lib/dashboardVariables/sortVariableValues';
|
||||
import type { AppState } from 'store/reducers';
|
||||
import type { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableModel';
|
||||
import { buildExistingDynamicVariableQuery } from '../dynamicFilter';
|
||||
import type {
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from '../selectionTypes';
|
||||
import { useAutoSelect } from '../useAutoSelect';
|
||||
import ValueSelector from './ValueSelector';
|
||||
|
||||
interface DynamicSelectorProps {
|
||||
variable: VariableFormModel;
|
||||
/** All variables + current selections, to scope options by sibling dynamics. */
|
||||
variables: VariableFormModel[];
|
||||
selections: VariableSelectionMap;
|
||||
selection: VariableSelection;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic-variable options sourced from live telemetry field values for the
|
||||
* chosen signal + attribute, scoped by the other dynamic variables' selections
|
||||
* (so e.g. `pod` narrows to the chosen `namespace`).
|
||||
*/
|
||||
function DynamicSelector({
|
||||
variable,
|
||||
variables,
|
||||
selections,
|
||||
selection,
|
||||
onChange,
|
||||
}: DynamicSelectorProps): JSX.Element {
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const existingQuery = useMemo(
|
||||
() => buildExistingDynamicVariableQuery(variables, selections, variable.name),
|
||||
[variables, selections, variable.name],
|
||||
);
|
||||
|
||||
const { data, isFetching } = useGetFieldValues({
|
||||
signal: variable.dynamicSignal,
|
||||
name: variable.dynamicAttribute,
|
||||
startUnixMilli: minTime,
|
||||
endUnixMilli: maxTime,
|
||||
existingQuery: existingQuery || undefined,
|
||||
enabled: !!variable.dynamicAttribute,
|
||||
});
|
||||
|
||||
const options = useMemo(() => {
|
||||
const payload = data?.data;
|
||||
const values =
|
||||
payload?.normalizedValues ?? payload?.values?.StringValues ?? [];
|
||||
return sortValues(values, variable.sort).map(String);
|
||||
}, [data, variable.sort]);
|
||||
|
||||
useAutoSelect(variable, options, selection, onChange);
|
||||
|
||||
return (
|
||||
<ValueSelector
|
||||
options={options}
|
||||
multiSelect={variable.multiSelect}
|
||||
showAllOption={variable.showAllOption}
|
||||
loading={isFetching}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
testId={`variable-select-${variable.name}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default DynamicSelector;
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
|
||||
import sortValues from 'lib/dashboardVariables/sortVariableValues';
|
||||
import type { AppState } from 'store/reducers';
|
||||
import type { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableModel';
|
||||
import type {
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from '../selectionTypes';
|
||||
import { isResolved, selectionToPayload } from '../selectionUtils';
|
||||
import { useAutoSelect } from '../useAutoSelect';
|
||||
import ValueSelector from './ValueSelector';
|
||||
|
||||
interface QuerySelectorProps {
|
||||
variable: VariableFormModel;
|
||||
/** Names this variable's query references; it waits until they're resolved. */
|
||||
parents: string[];
|
||||
/** All current selections, fed to the query as `{ name: value }`. */
|
||||
selections: VariableSelectionMap;
|
||||
selection: VariableSelection;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query-driven options. Dependency orchestration is declarative: the query is
|
||||
* `enabled` only once every parent is resolved, and the parent values are in the
|
||||
* query key — so it refetches automatically when a parent changes (and a cyclic
|
||||
* dependency is simply never enabled).
|
||||
*/
|
||||
function QuerySelector({
|
||||
variable,
|
||||
parents,
|
||||
selections,
|
||||
selection,
|
||||
onChange,
|
||||
}: QuerySelectorProps): JSX.Element {
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
const payload = useMemo(() => selectionToPayload(selections), [selections]);
|
||||
const enabled = parents.every((parent) => isResolved(selections[parent]));
|
||||
|
||||
const { data, isFetching } = useQuery(
|
||||
[
|
||||
'dashboard-variable',
|
||||
variable.name,
|
||||
variable.queryValue,
|
||||
payload,
|
||||
minTime,
|
||||
maxTime,
|
||||
],
|
||||
() =>
|
||||
dashboardVariablesQuery({
|
||||
query: variable.queryValue,
|
||||
variables: payload,
|
||||
}),
|
||||
{ enabled, refetchOnWindowFocus: false },
|
||||
);
|
||||
|
||||
const options = useMemo(() => {
|
||||
if (!data || data.statusCode !== 200 || !data.payload) {
|
||||
return [] as string[];
|
||||
}
|
||||
return sortValues(data.payload.variableValues ?? [], variable.sort).map(
|
||||
String,
|
||||
);
|
||||
}, [data, variable.sort]);
|
||||
|
||||
useAutoSelect(variable, options, selection, onChange);
|
||||
|
||||
return (
|
||||
<ValueSelector
|
||||
options={options}
|
||||
multiSelect={variable.multiSelect}
|
||||
showAllOption={variable.showAllOption}
|
||||
loading={isFetching}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
testId={`variable-select-${variable.name}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default QuerySelector;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
|
||||
import type { VariableSelection } from '../selectionTypes';
|
||||
import styles from '../VariablesBar.module.scss';
|
||||
|
||||
interface TextSelectorProps {
|
||||
selection: VariableSelection;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
/** Free-text variable input. */
|
||||
function TextSelector({
|
||||
selection,
|
||||
onChange,
|
||||
testId,
|
||||
}: TextSelectorProps): JSX.Element {
|
||||
return (
|
||||
<Input
|
||||
className={styles.input}
|
||||
value={typeof selection.value === 'string' ? selection.value : ''}
|
||||
placeholder="Enter a value"
|
||||
onChange={(e): void =>
|
||||
onChange({ value: e.target.value, allSelected: false })
|
||||
}
|
||||
testId={testId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default TextSelector;
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useMemo } from 'react';
|
||||
import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
|
||||
import type { OptionData } from 'components/NewSelect/types';
|
||||
import { ALL_SELECT_VALUE } from 'container/DashboardContainer/utils';
|
||||
|
||||
import type { VariableSelection } from '../selectionTypes';
|
||||
import styles from '../VariablesBar.module.scss';
|
||||
|
||||
interface ValueSelectorProps {
|
||||
options: string[];
|
||||
multiSelect: boolean;
|
||||
showAllOption: boolean;
|
||||
loading?: boolean;
|
||||
selection: VariableSelection;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single/multi value picker for Custom/Query/Dynamic variables. Reuses the
|
||||
* shared NewSelect components, which provide search, the "ALL" option and
|
||||
* apply-on-close batching (so multi-select edits don't cascade per toggle).
|
||||
*/
|
||||
function ValueSelector({
|
||||
options,
|
||||
multiSelect,
|
||||
showAllOption,
|
||||
loading,
|
||||
selection,
|
||||
onChange,
|
||||
testId,
|
||||
}: ValueSelectorProps): JSX.Element {
|
||||
const optionData = useMemo<OptionData[]>(
|
||||
() => options.map((option) => ({ label: option, value: option })),
|
||||
[options],
|
||||
);
|
||||
|
||||
if (multiSelect) {
|
||||
const value = selection.allSelected
|
||||
? ALL_SELECT_VALUE
|
||||
: (Array.isArray(selection.value) ? selection.value : []).map(String);
|
||||
return (
|
||||
<CustomMultiSelect
|
||||
className={styles.select}
|
||||
data-testid={testId}
|
||||
options={optionData}
|
||||
value={value}
|
||||
loading={loading}
|
||||
showSearch
|
||||
placeholder="Select value"
|
||||
enableAllSelection={showAllOption}
|
||||
onChange={(next): void => {
|
||||
const values = Array.isArray(next)
|
||||
? next.map(String)
|
||||
: next
|
||||
? [String(next)]
|
||||
: [];
|
||||
if (values.length === 0) {
|
||||
onChange({ value: [], allSelected: false });
|
||||
return;
|
||||
}
|
||||
// CustomMultiSelect emits the full value set when ALL is picked.
|
||||
const isAll =
|
||||
showAllOption &&
|
||||
options.length > 0 &&
|
||||
options.every((option) => values.includes(option));
|
||||
onChange({ value: values, allSelected: isAll });
|
||||
}}
|
||||
onClear={(): void => onChange({ value: [], allSelected: false })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomSelect
|
||||
className={styles.select}
|
||||
data-testid={testId}
|
||||
options={optionData}
|
||||
value={
|
||||
selection.value == null || Array.isArray(selection.value)
|
||||
? undefined
|
||||
: String(selection.value)
|
||||
}
|
||||
loading={loading}
|
||||
showSearch
|
||||
placeholder="Select value"
|
||||
onChange={(next): void =>
|
||||
onChange({ value: next == null ? '' : String(next), allSelected: false })
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ValueSelector;
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
|
||||
import type { VariableSelection } from './selectionTypes';
|
||||
|
||||
/**
|
||||
* When fetched options arrive and the current selection isn't one of them,
|
||||
* auto-pick the variable's default (if present in the options) or the first
|
||||
* option — so dependent children always have a usable parent value.
|
||||
*/
|
||||
export function useAutoSelect(
|
||||
variable: VariableFormModel,
|
||||
options: string[],
|
||||
selection: VariableSelection,
|
||||
onChange: (selection: VariableSelection) => void,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (options.length === 0 || selection.allSelected) {
|
||||
return;
|
||||
}
|
||||
const current = selection.value;
|
||||
const isValid = Array.isArray(current)
|
||||
? current.length > 0 && current.every((c) => options.includes(String(c)))
|
||||
: current !== '' &&
|
||||
current !== null &&
|
||||
current !== undefined &&
|
||||
options.includes(String(current));
|
||||
if (isValid) {
|
||||
return;
|
||||
}
|
||||
const fallback = (variable.defaultValue as { value?: string } | undefined)
|
||||
?.value;
|
||||
const initial =
|
||||
fallback && options.includes(fallback) ? fallback : options[0];
|
||||
onChange({
|
||||
value: variable.multiSelect ? [initial] : initial,
|
||||
allSelected: false,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [options]);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { parseAsJson, useQueryState } from 'nuqs';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { dtoToFormModel } from '../DashboardSettings/Variables/variableAdapters';
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
|
||||
import { selectVariableValues } from '../store/slices/variableSelectionSlice';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import type {
|
||||
SelectedVariableValue,
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from './selectionTypes';
|
||||
import {
|
||||
computeVariableDependencies,
|
||||
type VariableDependencyData,
|
||||
} from './variableDependencies';
|
||||
|
||||
/** URL sentinel for an "ALL values selected" state (matches V1). */
|
||||
export const ALL_SELECTED = '__ALL__';
|
||||
|
||||
/** `?variables=` holds `{ [name]: value }` (ALL encoded as the sentinel). */
|
||||
const variablesUrlParser = parseAsJson<Record<string, SelectedVariableValue>>(
|
||||
(v) =>
|
||||
typeof v === 'object' && v !== null
|
||||
? (v as Record<string, SelectedVariableValue>)
|
||||
: null,
|
||||
);
|
||||
|
||||
function defaultSelection(model: VariableFormModel): VariableSelection {
|
||||
const def = (
|
||||
model.defaultValue as { value?: SelectedVariableValue } | undefined
|
||||
)?.value;
|
||||
if (def !== undefined && def !== null && def !== '') {
|
||||
return { value: def, allSelected: false };
|
||||
}
|
||||
return { value: model.multiSelect ? [] : '', allSelected: false };
|
||||
}
|
||||
|
||||
function fromUrlValue(raw: SelectedVariableValue): VariableSelection {
|
||||
return raw === ALL_SELECTED
|
||||
? { value: null, allSelected: true }
|
||||
: { value: raw, allSelected: false };
|
||||
}
|
||||
|
||||
interface UseVariableSelection {
|
||||
variables: VariableFormModel[];
|
||||
dependencyData: VariableDependencyData;
|
||||
selection: VariableSelectionMap;
|
||||
setSelection: (name: string, selection: VariableSelection) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime variable selection: derives the variable list from the spec, seeds
|
||||
* each value from URL → localStorage(store) → default, and persists changes to
|
||||
* both the store and the URL. Never writes to the dashboard spec.
|
||||
*/
|
||||
export function useVariableSelection(
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO,
|
||||
): UseVariableSelection {
|
||||
const dashboardId = dashboard.id ?? '';
|
||||
|
||||
const variables = useMemo(
|
||||
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
|
||||
[dashboard.spec?.variables],
|
||||
);
|
||||
const dependencyData = useMemo(
|
||||
() => computeVariableDependencies(variables),
|
||||
[variables],
|
||||
);
|
||||
|
||||
const selection = useDashboardStore(selectVariableValues(dashboardId));
|
||||
const setVariableValue = useDashboardStore((s) => s.setVariableValue);
|
||||
const setVariableValues = useDashboardStore((s) => s.setVariableValues);
|
||||
|
||||
const [urlValues, setUrlValues] = useQueryState(
|
||||
'variables',
|
||||
variablesUrlParser.withOptions({ history: 'replace' }),
|
||||
);
|
||||
|
||||
// Seed selections for this dashboard: URL wins, then persisted store, then default.
|
||||
useEffect(() => {
|
||||
if (!dashboardId || variables.length === 0) {
|
||||
return;
|
||||
}
|
||||
// `selection` here is the persisted (localStorage) map on mount — the
|
||||
// effect deliberately doesn't depend on it, so seeding runs once per set.
|
||||
const stored = selection;
|
||||
const seeded: VariableSelectionMap = {};
|
||||
variables.forEach((variable) => {
|
||||
const urlValue = urlValues?.[variable.name];
|
||||
if (urlValue !== undefined) {
|
||||
seeded[variable.name] = fromUrlValue(urlValue);
|
||||
} else if (stored[variable.name]) {
|
||||
seeded[variable.name] = stored[variable.name];
|
||||
} else {
|
||||
seeded[variable.name] = defaultSelection(variable);
|
||||
}
|
||||
});
|
||||
setVariableValues(dashboardId, seeded);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dashboardId, variables]);
|
||||
|
||||
const setSelection = useCallback(
|
||||
(name: string, next: VariableSelection): void => {
|
||||
setVariableValue(dashboardId, name, next);
|
||||
void setUrlValues((prev) => ({
|
||||
...(prev ?? {}),
|
||||
[name]: next.allSelected ? ALL_SELECTED : next.value,
|
||||
}));
|
||||
},
|
||||
[dashboardId, setVariableValue, setUrlValues],
|
||||
);
|
||||
|
||||
return { variables, dependencyData, selection, setSelection };
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
|
||||
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
|
||||
|
||||
/**
|
||||
* Inter-variable dependency graph for runtime selection. A QUERY variable
|
||||
* "depends on" another variable when its query text references that variable
|
||||
* (`{{.name}}`, `{{name}}`, `$name`, `[[name]]`). When a variable's value
|
||||
* changes, its dependent QUERY variables must refetch. Ported from the V1
|
||||
* dashboard-variables runtime; operates on the V2 flat variable model.
|
||||
*/
|
||||
|
||||
export type VariableGraph = Record<string, string[]>;
|
||||
|
||||
export interface VariableDependencyData {
|
||||
/** Topological order of variables (parents before children). */
|
||||
order: string[];
|
||||
/** Direct children (dependents) of each variable. */
|
||||
graph: VariableGraph;
|
||||
/** Direct parents of each variable. */
|
||||
parentGraph: VariableGraph;
|
||||
/** All transitive descendants of each variable (precomputed). */
|
||||
transitiveDescendants: VariableGraph;
|
||||
hasCycle: boolean;
|
||||
cycleNodes?: string[];
|
||||
}
|
||||
|
||||
/** Names of QUERY variables whose query references `variableName`. */
|
||||
function getDependents(
|
||||
variableName: string,
|
||||
variables: VariableFormModel[],
|
||||
): string[] {
|
||||
return variables
|
||||
.filter(
|
||||
(v) =>
|
||||
v.type === 'QUERY' &&
|
||||
!!v.name &&
|
||||
textContainsVariableReference(v.queryValue || '', variableName),
|
||||
)
|
||||
.map((v) => v.name);
|
||||
}
|
||||
|
||||
/** variable name → its direct dependents (children). */
|
||||
export function buildDependencies(
|
||||
variables: VariableFormModel[],
|
||||
): VariableGraph {
|
||||
const graph: VariableGraph = {};
|
||||
variables.forEach((v) => {
|
||||
if (v.name) {
|
||||
graph[v.name] = getDependents(v.name, variables);
|
||||
}
|
||||
});
|
||||
return graph;
|
||||
}
|
||||
|
||||
/** Invert a child graph into a parent graph. */
|
||||
export function buildParentGraph(graph: VariableGraph): VariableGraph {
|
||||
const parents: VariableGraph = {};
|
||||
Object.keys(graph).forEach((node) => {
|
||||
parents[node] = parents[node] ?? [];
|
||||
});
|
||||
Object.entries(graph).forEach(([node, children]) => {
|
||||
children.forEach((child) => {
|
||||
parents[child] = parents[child] ?? [];
|
||||
parents[child].push(node);
|
||||
});
|
||||
});
|
||||
return parents;
|
||||
}
|
||||
|
||||
function collectCyclePath(
|
||||
graph: VariableGraph,
|
||||
start: string,
|
||||
end: string,
|
||||
): string[] {
|
||||
const path: string[] = [];
|
||||
let current = start;
|
||||
const findParent = (node: string): string | undefined =>
|
||||
Object.keys(graph).find((key) => graph[key]?.includes(node));
|
||||
while (current !== end) {
|
||||
const parent = findParent(current);
|
||||
if (!parent) {
|
||||
break;
|
||||
}
|
||||
path.push(parent);
|
||||
current = parent;
|
||||
}
|
||||
return [start, ...path];
|
||||
}
|
||||
|
||||
function detectCycle(
|
||||
graph: VariableGraph,
|
||||
node: string,
|
||||
visited: Set<string>,
|
||||
recStack: Set<string>,
|
||||
): string[] | null {
|
||||
if (!visited.has(node)) {
|
||||
visited.add(node);
|
||||
recStack.add(node);
|
||||
let cycleNodes: string[] | null = null;
|
||||
(graph[node] || []).some((neighbor) => {
|
||||
if (!visited.has(neighbor)) {
|
||||
const found = detectCycle(graph, neighbor, visited, recStack);
|
||||
if (found) {
|
||||
cycleNodes = found;
|
||||
return true;
|
||||
}
|
||||
} else if (recStack.has(neighbor)) {
|
||||
cycleNodes = collectCyclePath(graph, node, neighbor);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (cycleNodes) {
|
||||
return cycleNodes;
|
||||
}
|
||||
}
|
||||
recStack.delete(node);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Build the full dependency data (topo order, parents, transitive descendants, cycle info). */
|
||||
export function buildDependencyData(
|
||||
dependencies: VariableGraph,
|
||||
): VariableDependencyData {
|
||||
const inDegree: Record<string, number> = {};
|
||||
const adjList: VariableGraph = {};
|
||||
|
||||
Object.keys(dependencies).forEach((node) => {
|
||||
inDegree[node] = inDegree[node] ?? 0;
|
||||
adjList[node] = adjList[node] ?? [];
|
||||
(dependencies[node] || []).forEach((child) => {
|
||||
inDegree[child] = inDegree[child] ?? 0;
|
||||
inDegree[child] += 1;
|
||||
adjList[node].push(child);
|
||||
});
|
||||
});
|
||||
|
||||
const visited = new Set<string>();
|
||||
const recStack = new Set<string>();
|
||||
let cycleNodes: string[] | undefined;
|
||||
Object.keys(dependencies).some((node) => {
|
||||
if (!visited.has(node)) {
|
||||
const found = detectCycle(dependencies, node, visited, recStack);
|
||||
if (found) {
|
||||
cycleNodes = found;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Topological sort (Kahn's algorithm).
|
||||
const queue = Object.keys(inDegree).filter((n) => inDegree[n] === 0);
|
||||
const order: string[] = [];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
if (current === undefined) {
|
||||
break;
|
||||
}
|
||||
order.push(current);
|
||||
(adjList[current] || []).forEach((neighbor) => {
|
||||
inDegree[neighbor] -= 1;
|
||||
if (inDegree[neighbor] === 0) {
|
||||
queue.push(neighbor);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const hasCycle = order.length !== Object.keys(dependencies).length;
|
||||
|
||||
// Transitive descendants: walk topo order in reverse.
|
||||
const transitiveDescendants: VariableGraph = {};
|
||||
for (let i = order.length - 1; i >= 0; i--) {
|
||||
const node = order[i];
|
||||
const desc = new Set<string>();
|
||||
(adjList[node] || []).forEach((child) => {
|
||||
desc.add(child);
|
||||
(transitiveDescendants[child] || []).forEach((d) => desc.add(d));
|
||||
});
|
||||
transitiveDescendants[node] = Array.from(desc);
|
||||
}
|
||||
|
||||
return {
|
||||
order,
|
||||
graph: adjList,
|
||||
parentGraph: buildParentGraph(adjList),
|
||||
transitiveDescendants,
|
||||
hasCycle,
|
||||
cycleNodes,
|
||||
};
|
||||
}
|
||||
|
||||
/** Compute the full dependency data straight from the variable list. */
|
||||
export function computeVariableDependencies(
|
||||
variables: VariableFormModel[],
|
||||
): VariableDependencyData {
|
||||
return buildDependencyData(buildDependencies(variables));
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { useAppContext } from 'providers/App/App';
|
||||
import DashboardDescription from './DashboardDescription';
|
||||
import PanelsAndSectionsLayout from './PanelsAndSectionsLayout';
|
||||
import { useDashboardStore } from './store/useDashboardStore';
|
||||
import VariablesBar from './VariablesBar/VariablesBar';
|
||||
import styles from './DashboardContainer.module.scss';
|
||||
|
||||
interface DashboardContainerProps {
|
||||
@@ -45,6 +46,7 @@ function DashboardContainer({
|
||||
handle={fullScreenHandle}
|
||||
refetch={refetch}
|
||||
/>
|
||||
<VariablesBar dashboard={dashboard} />
|
||||
<PanelsAndSectionsLayout layouts={layouts} panels={panels} />
|
||||
</div>
|
||||
{/* Shared panel-type picker (V1 component): opened from any "New Panel"
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
DashboardtypesLayoutDTO,
|
||||
DashboardtypesPanelDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { DashboardtypesJSONPatchOperationDTOOp } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DashboardtypesPatchOpDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { GridItem } from './utils';
|
||||
|
||||
@@ -16,7 +16,7 @@ import type { GridItem } from './utils';
|
||||
* patches in DashboardSettings/General and DashboardDescription).
|
||||
*/
|
||||
|
||||
const { add, replace, remove } = DashboardtypesJSONPatchOperationDTOOp;
|
||||
const { add, replace, remove } = DashboardtypesPatchOpDTO;
|
||||
|
||||
const PANEL_REF_PREFIX = '#/spec/panels/';
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { StateCreator } from 'zustand';
|
||||
|
||||
import type {
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from '../../VariablesBar/selectionTypes';
|
||||
import type { DashboardStore } from '../useDashboardStore';
|
||||
|
||||
/**
|
||||
* Runtime variable selection — the values the user picks in the variable bar.
|
||||
* Keyed by dashboardId → variable name. Frontend-only and persisted to
|
||||
* localStorage (mirrored to the URL by the bar for shareable links); it is
|
||||
* deliberately NOT part of the dashboard spec, so selecting a value never
|
||||
* patches the dashboard.
|
||||
*/
|
||||
export interface VariableSelectionSlice {
|
||||
variableValues: Record<string, VariableSelectionMap>;
|
||||
setVariableValue: (
|
||||
dashboardId: string,
|
||||
name: string,
|
||||
selection: VariableSelection,
|
||||
) => void;
|
||||
/** Bulk set (used to seed from URL/localStorage/defaults on load). */
|
||||
setVariableValues: (dashboardId: string, values: VariableSelectionMap) => void;
|
||||
}
|
||||
|
||||
export const createVariableSelectionSlice: StateCreator<
|
||||
DashboardStore,
|
||||
[['zustand/persist', unknown]],
|
||||
[],
|
||||
VariableSelectionSlice
|
||||
> = (set, get) => ({
|
||||
variableValues: {},
|
||||
setVariableValue: (dashboardId, name, selection): void => {
|
||||
const { variableValues } = get();
|
||||
set({
|
||||
variableValues: {
|
||||
...variableValues,
|
||||
[dashboardId]: { ...variableValues[dashboardId], [name]: selection },
|
||||
},
|
||||
});
|
||||
},
|
||||
setVariableValues: (dashboardId, values): void => {
|
||||
const { variableValues } = get();
|
||||
set({
|
||||
variableValues: { ...variableValues, [dashboardId]: values },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/** Selector: the selection map for a dashboard (empty if none). */
|
||||
export const selectVariableValues =
|
||||
(dashboardId: string) =>
|
||||
(state: DashboardStore): VariableSelectionMap =>
|
||||
state.variableValues[dashboardId] ?? {};
|
||||
@@ -9,25 +9,36 @@ import {
|
||||
createCollapseSlice,
|
||||
type CollapseSlice,
|
||||
} from './slices/collapseSlice';
|
||||
import {
|
||||
createVariableSelectionSlice,
|
||||
type VariableSelectionSlice,
|
||||
} from './slices/variableSelectionSlice';
|
||||
|
||||
export type DashboardStore = EditContextSlice & CollapseSlice;
|
||||
export type DashboardStore = EditContextSlice &
|
||||
CollapseSlice &
|
||||
VariableSelectionSlice;
|
||||
|
||||
/**
|
||||
* V2 dashboard session store. Holds cross-cutting client state only — never the
|
||||
* dashboard spec (that stays in react-query via useGetDashboardV2). Two slices:
|
||||
* dashboard spec (that stays in react-query via useGetDashboardV2). Slices:
|
||||
* - edit-context: dashboardId / isEditable / refetch (set once, not persisted).
|
||||
* - collapse: per-section open state (frontend-only, persisted to localStorage).
|
||||
* - variable-selection: runtime variable values (frontend-only, persisted).
|
||||
*/
|
||||
export const useDashboardStore = create<DashboardStore>()(
|
||||
persist(
|
||||
(...a) => ({
|
||||
...createEditContextSlice(...a),
|
||||
...createCollapseSlice(...a),
|
||||
...createVariableSelectionSlice(...a),
|
||||
}),
|
||||
{
|
||||
name: '@signoz/dashboard-v2',
|
||||
// Persist only the collapse map — context (incl. the refetch fn) is transient.
|
||||
partialize: (state) => ({ collapsed: state.collapsed }),
|
||||
// Persist UI-only state (context incl. the refetch fn is transient).
|
||||
partialize: (state) => ({
|
||||
collapsed: state.collapsed,
|
||||
variableValues: state.variableValues,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
|
||||
import DashboardsListPageV2 from 'pages/DashboardsListPageV2';
|
||||
|
||||
import DashboardsListPage from './DashboardsListPage';
|
||||
|
||||
export default DashboardsListPage;
|
||||
// Serves the V2 dashboards list when the `use_dashboard_v2` flag is active;
|
||||
// otherwise the existing V1 list. Lets V2 dark-ship behind the flag without
|
||||
// changing route definitions.
|
||||
function DashboardsListPageEntry(): JSX.Element {
|
||||
const isDashboardV2 = useIsDashboardV2();
|
||||
|
||||
return isDashboardV2 ? <DashboardsListPageV2 /> : <DashboardsListPage />;
|
||||
}
|
||||
|
||||
export default DashboardsListPageEntry;
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
createDashboardV2,
|
||||
useListDashboardsV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import {
|
||||
DashboardtypesListOrderDTO,
|
||||
DashboardtypesListSortDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { RequestDashboardBtn } from 'container/ListOfDashboard/RequestDashboardBtn';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
@@ -24,8 +28,6 @@ import {
|
||||
useSearch,
|
||||
useSortColumn,
|
||||
useSortOrder,
|
||||
type SortColumn,
|
||||
type SortOrder,
|
||||
} from '../../hooks/useDashboardsListQueryParams';
|
||||
import type { DashboardListItem } from '../../utils';
|
||||
import ConfigureMetadataModal from '../ConfigureMetadataModal/ConfigureMetadataModal';
|
||||
@@ -131,6 +133,10 @@ function DashboardsList(): JSX.Element {
|
||||
tags: null,
|
||||
spec: {
|
||||
display: { name: t('new_dashboard_title', { ns: 'dashboard' }) },
|
||||
layouts: [],
|
||||
panels: {},
|
||||
variables: [],
|
||||
// TODO(@AshwinBhatkal): duration and refresh interval need to be integrated
|
||||
},
|
||||
});
|
||||
safeNavigate(
|
||||
@@ -150,7 +156,7 @@ function DashboardsList(): JSX.Element {
|
||||
}, []);
|
||||
|
||||
const onSortChange = useCallback(
|
||||
(column: SortColumn): void => {
|
||||
(column: DashboardtypesListSortDTO): void => {
|
||||
void setSortColumn(column);
|
||||
void setPage(1);
|
||||
},
|
||||
@@ -158,7 +164,7 @@ function DashboardsList(): JSX.Element {
|
||||
);
|
||||
|
||||
const onOrderChange = useCallback(
|
||||
(order: SortOrder): void => {
|
||||
(order: DashboardtypesListOrderDTO): void => {
|
||||
void setSortOrder(order);
|
||||
void setPage(1);
|
||||
},
|
||||
|
||||
@@ -7,18 +7,18 @@ import {
|
||||
HdmiPort,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import type {
|
||||
SortColumn,
|
||||
SortOrder,
|
||||
} from '../../hooks/useDashboardsListQueryParams';
|
||||
import {
|
||||
DashboardtypesListOrderDTO,
|
||||
DashboardtypesListSortDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import styles from './ListHeader.module.scss';
|
||||
|
||||
interface Props {
|
||||
sortColumn: SortColumn;
|
||||
onSortChange: (column: SortColumn) => void;
|
||||
sortOrder: SortOrder;
|
||||
onOrderChange: (order: SortOrder) => void;
|
||||
sortColumn: DashboardtypesListSortDTO;
|
||||
onSortChange: (column: DashboardtypesListSortDTO) => void;
|
||||
sortOrder: DashboardtypesListOrderDTO;
|
||||
onOrderChange: (order: DashboardtypesListOrderDTO) => void;
|
||||
onConfigureMetadata: () => void;
|
||||
}
|
||||
|
||||
@@ -44,49 +44,57 @@ function ListHeader({
|
||||
<Button
|
||||
type="text"
|
||||
className={styles.sortButton}
|
||||
onClick={(): void => onSortChange('name')}
|
||||
onClick={(): void => onSortChange(DashboardtypesListSortDTO.name)}
|
||||
data-testid="sort-by-name"
|
||||
>
|
||||
Name
|
||||
{sortColumn === 'name' && <Check size={14} />}
|
||||
{sortColumn === DashboardtypesListSortDTO.name && <Check size={14} />}
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
className={styles.sortButton}
|
||||
onClick={(): void => onSortChange('created_at')}
|
||||
onClick={(): void =>
|
||||
onSortChange(DashboardtypesListSortDTO.created_at)
|
||||
}
|
||||
data-testid="sort-by-last-created"
|
||||
>
|
||||
Last created
|
||||
{sortColumn === 'created_at' && <Check size={14} />}
|
||||
{sortColumn === DashboardtypesListSortDTO.created_at && (
|
||||
<Check size={14} />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
className={styles.sortButton}
|
||||
onClick={(): void => onSortChange('updated_at')}
|
||||
onClick={(): void =>
|
||||
onSortChange(DashboardtypesListSortDTO.updated_at)
|
||||
}
|
||||
data-testid="sort-by-last-updated"
|
||||
>
|
||||
Last updated
|
||||
{sortColumn === 'updated_at' && <Check size={14} />}
|
||||
{sortColumn === DashboardtypesListSortDTO.updated_at && (
|
||||
<Check size={14} />
|
||||
)}
|
||||
</Button>
|
||||
<div className={styles.sortDivider} />
|
||||
<Typography.Text className={styles.sortHeading}>Order</Typography.Text>
|
||||
<Button
|
||||
type="text"
|
||||
className={styles.sortButton}
|
||||
onClick={(): void => onOrderChange('asc')}
|
||||
onClick={(): void => onOrderChange(DashboardtypesListOrderDTO.asc)}
|
||||
data-testid="sort-order-asc"
|
||||
>
|
||||
Ascending
|
||||
{sortOrder === 'asc' && <Check size={14} />}
|
||||
{sortOrder === DashboardtypesListOrderDTO.asc && <Check size={14} />}
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
className={styles.sortButton}
|
||||
onClick={(): void => onOrderChange('desc')}
|
||||
onClick={(): void => onOrderChange(DashboardtypesListOrderDTO.desc)}
|
||||
data-testid="sort-order-desc"
|
||||
>
|
||||
Descending
|
||||
{sortOrder === 'desc' && <Check size={14} />}
|
||||
{sortOrder === DashboardtypesListOrderDTO.desc && <Check size={14} />}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Shared building blocks for the dashboards-list view states.
|
||||
// Composed via CSS-modules `composes:` from each state's own SCSS.
|
||||
/* Shared building blocks for the dashboards-list view states. */
|
||||
/* Composed via CSS-modules `composes:` from each state's own SCSS. */
|
||||
|
||||
.cardWrapper {
|
||||
display: flex;
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
DashboardtypesListOrderDTO,
|
||||
DashboardtypesListSortDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
parseAsInteger,
|
||||
parseAsString,
|
||||
@@ -7,26 +11,31 @@ import {
|
||||
type UseQueryStateReturn,
|
||||
} from 'nuqs';
|
||||
|
||||
export const SORT_COLUMNS = ['updated_at', 'created_at', 'name'] as const;
|
||||
export type SortColumn = (typeof SORT_COLUMNS)[number];
|
||||
|
||||
export const SORT_ORDERS = ['asc', 'desc'] as const;
|
||||
export type SortOrder = (typeof SORT_ORDERS)[number];
|
||||
export const SORT_COLUMNS = Object.values(DashboardtypesListSortDTO);
|
||||
export const SORT_ORDERS = Object.values(DashboardtypesListOrderDTO);
|
||||
|
||||
const opts: Options = { history: 'push' };
|
||||
|
||||
export const useSortColumn = (): UseQueryStateReturn<SortColumn, SortColumn> =>
|
||||
export const useSortColumn = (): UseQueryStateReturn<
|
||||
DashboardtypesListSortDTO,
|
||||
DashboardtypesListSortDTO
|
||||
> =>
|
||||
useQueryState(
|
||||
'sort',
|
||||
parseAsStringLiteral(SORT_COLUMNS)
|
||||
.withDefault('updated_at')
|
||||
.withDefault(DashboardtypesListSortDTO.updated_at)
|
||||
.withOptions(opts),
|
||||
);
|
||||
|
||||
export const useSortOrder = (): UseQueryStateReturn<SortOrder, SortOrder> =>
|
||||
export const useSortOrder = (): UseQueryStateReturn<
|
||||
DashboardtypesListOrderDTO,
|
||||
DashboardtypesListOrderDTO
|
||||
> =>
|
||||
useQueryState(
|
||||
'order',
|
||||
parseAsStringLiteral(SORT_ORDERS).withDefault('desc').withOptions(opts),
|
||||
parseAsStringLiteral(SORT_ORDERS)
|
||||
.withDefault(DashboardtypesListOrderDTO.desc)
|
||||
.withOptions(opts),
|
||||
);
|
||||
|
||||
export const usePage = (): UseQueryStateReturn<number, number> =>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import type { DashboardtypesGettableDashboardWithPinDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { DashboardtypesListedDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export type DashboardListItem = DashboardtypesGettableDashboardWithPinDTO;
|
||||
export type DashboardListItem = DashboardtypesListedDashboardV2DTO;
|
||||
|
||||
export const tagsToStrings = (
|
||||
tags: { key: string; value: string }[] | null | undefined,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Logout } from 'api/utils';
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { createErrorResponse, rest, server } from 'mocks-server/server';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
import { render, screen, waitFor, fireEvent } from 'tests/test-utils';
|
||||
|
||||
import ResetPassword from '../index';
|
||||
|
||||
@@ -103,6 +103,7 @@ describe('ResetPassword Page', () => {
|
||||
expect(
|
||||
screen.getByText(/reset password token does not exist/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('back-to-login')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "token is expired" when token is expired (401) without redirecting to login', async () => {
|
||||
@@ -137,6 +138,32 @@ describe('ResetPassword Page', () => {
|
||||
// 401 from this endpoint must NOT trigger logout/redirect
|
||||
expect(mockHistoryPush).not.toHaveBeenCalledWith(ROUTES.LOGIN);
|
||||
expect(Logout).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId('back-to-login')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates to login when "Back to login" is clicked on error screen', async () => {
|
||||
server.use(
|
||||
rest.post(
|
||||
VERIFY_TOKEN_ENDPOINT,
|
||||
createErrorResponse(
|
||||
404,
|
||||
'reset_password_token_not_found',
|
||||
'reset password token does not exist',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
window.history.pushState({}, '', '/password-reset?token=invalid-token');
|
||||
render(<ResetPassword />, undefined, {
|
||||
initialRoute: '/password-reset?token=invalid-token',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('back-to-login')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId('back-to-login'));
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith(ROUTES.LOGIN);
|
||||
});
|
||||
|
||||
it('redirects to login when no token is in the URL', async () => {
|
||||
|
||||
@@ -48,9 +48,7 @@
|
||||
"node_modules",
|
||||
"src/parser/*.ts",
|
||||
"src/parser/TraceOperatorParser/*.ts",
|
||||
"orval.config.ts",
|
||||
"src/pages/DashboardsListPageV2/**/*",
|
||||
"src/pages/DashboardPageV2/**/*"
|
||||
"orval.config.ts"
|
||||
],
|
||||
"include": [
|
||||
"./src",
|
||||
|
||||
@@ -50,8 +50,8 @@ func (handler *healthOpenAPIHandler) ServeOpenAPI(opCtx openapi.OperationContext
|
||||
)
|
||||
}
|
||||
|
||||
func (handler *healthOpenAPIHandler) AuditDef() *pkghandler.AuditDef {
|
||||
// Health endpoints are not audited since they don't represent user actions and are called frequently by monitoring systems, which would create noise in the audit logs.
|
||||
func (handler *healthOpenAPIHandler) ResourceDefs() []pkghandler.ResourceDef {
|
||||
// Health endpoints don't act on resources.
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -7,166 +7,197 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func (provider *provider) addRoleRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v1/roles", handler.New(provider.authzMiddleware.Check(provider.authzHandler.Create, authtypes.Relation{Verb: coretypes.VerbCreate}, coretypes.ResourceRole, roleCollectionSelectorCallback, []string{
|
||||
authtypes.SigNozAdminRoleName,
|
||||
}), handler.OpenAPIDef{
|
||||
ID: "CreateRole",
|
||||
Tags: []string{"role"},
|
||||
Summary: "Create role",
|
||||
Description: "This endpoint creates a role",
|
||||
Request: new(authtypes.PostableRole),
|
||||
RequestContentType: "",
|
||||
Response: new(types.Identifiable),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbCreate)}),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/roles", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.authzHandler.Create, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CreateRole",
|
||||
Tags: []string{"role"},
|
||||
Summary: "Create role",
|
||||
Description: "This endpoint creates a role",
|
||||
Request: new(authtypes.PostableRole),
|
||||
RequestContentType: "",
|
||||
Response: new(types.Identifiable),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbCreate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceRole,
|
||||
Verb: coretypes.VerbCreate,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: coretypes.ResponseJSONPath("data.id"),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/roles", handler.New(provider.authzMiddleware.Check(provider.authzHandler.List, authtypes.Relation{Verb: coretypes.VerbList}, coretypes.ResourceRole, roleCollectionSelectorCallback, []string{
|
||||
authtypes.SigNozAdminRoleName,
|
||||
}), handler.OpenAPIDef{
|
||||
ID: "ListRoles",
|
||||
Tags: []string{"role"},
|
||||
Summary: "List roles",
|
||||
Description: "This endpoint lists all roles",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: make([]*authtypes.Role, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbList)}),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/roles", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.authzHandler.List, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "ListRoles",
|
||||
Tags: []string{"role"},
|
||||
Summary: "List roles",
|
||||
Description: "This endpoint lists all roles",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: make([]*authtypes.Role, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceRole,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/roles/{id}", handler.New(provider.authzMiddleware.Check(provider.authzHandler.Get, authtypes.Relation{Verb: coretypes.VerbRead}, coretypes.ResourceRole, provider.roleInstanceSelectorCallback, []string{
|
||||
authtypes.SigNozAdminRoleName,
|
||||
}), handler.OpenAPIDef{
|
||||
ID: "GetRole",
|
||||
Tags: []string{"role"},
|
||||
Summary: "Get role",
|
||||
Description: "This endpoint gets a role",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(authtypes.Role),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbRead)}),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/roles/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.authzHandler.Get, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetRole",
|
||||
Tags: []string{"role"},
|
||||
Summary: "Get role",
|
||||
Description: "This endpoint gets a role",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(authtypes.Role),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceRole,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: provider.roleSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/roles/{id}/relations/{relation}/objects", handler.New(provider.authzMiddleware.Check(provider.authzHandler.GetObjects, authtypes.Relation{Verb: coretypes.VerbRead}, coretypes.ResourceRole, provider.roleInstanceSelectorCallback, []string{
|
||||
authtypes.SigNozAdminRoleName,
|
||||
}), handler.OpenAPIDef{
|
||||
ID: "GetObjects",
|
||||
Tags: []string{"role"},
|
||||
Summary: "Get objects for a role by relation",
|
||||
Description: "Gets all objects connected to the specified role via a given relation type",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: make([]*coretypes.ObjectGroup, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbRead)}),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/roles/{id}/relations/{relation}/objects", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.authzHandler.GetObjects, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetObjects",
|
||||
Tags: []string{"role"},
|
||||
Summary: "Get objects for a role by relation",
|
||||
Description: "Gets all objects connected to the specified role via a given relation type",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: make([]*coretypes.ObjectGroup, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceRole,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: provider.roleSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/roles/{id}", handler.New(provider.authzMiddleware.Check(provider.authzHandler.Patch, authtypes.Relation{Verb: coretypes.VerbUpdate}, coretypes.ResourceRole, provider.roleInstanceSelectorCallback, []string{
|
||||
authtypes.SigNozAdminRoleName,
|
||||
}), handler.OpenAPIDef{
|
||||
ID: "PatchRole",
|
||||
Tags: []string{"role"},
|
||||
Summary: "Patch role",
|
||||
Description: "This endpoint patches a role",
|
||||
Request: new(authtypes.PatchableRole),
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbUpdate)}),
|
||||
})).Methods(http.MethodPatch).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/roles/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.authzHandler.Patch, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "PatchRole",
|
||||
Tags: []string{"role"},
|
||||
Summary: "Patch role",
|
||||
Description: "This endpoint patches a role",
|
||||
Request: new(authtypes.PatchableRole),
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbUpdate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceRole,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: provider.roleSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPatch).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/roles/{id}/relations/{relation}/objects", handler.New(provider.authzMiddleware.Check(provider.authzHandler.PatchObjects, authtypes.Relation{Verb: coretypes.VerbUpdate}, coretypes.ResourceRole, provider.roleInstanceSelectorCallback, []string{
|
||||
authtypes.SigNozAdminRoleName,
|
||||
}), handler.OpenAPIDef{
|
||||
ID: "PatchObjects",
|
||||
Tags: []string{"role"},
|
||||
Summary: "Patch objects for a role by relation",
|
||||
Description: "Patches the objects connected to the specified role via a given relation type",
|
||||
Request: new(coretypes.PatchableObjects),
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusBadRequest, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbUpdate)}),
|
||||
})).Methods(http.MethodPatch).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/roles/{id}/relations/{relation}/objects", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.authzHandler.PatchObjects, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "PatchObjects",
|
||||
Tags: []string{"role"},
|
||||
Summary: "Patch objects for a role by relation",
|
||||
Description: "Patches the objects connected to the specified role via a given relation type",
|
||||
Request: new(coretypes.PatchableObjects),
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusBadRequest, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbUpdate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceRole,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: provider.roleSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPatch).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/roles/{id}", handler.New(provider.authzMiddleware.Check(provider.authzHandler.Delete, authtypes.Relation{Verb: coretypes.VerbDelete}, coretypes.ResourceRole, provider.roleInstanceSelectorCallback, []string{
|
||||
authtypes.SigNozAdminRoleName,
|
||||
}), handler.OpenAPIDef{
|
||||
ID: "DeleteRole",
|
||||
Tags: []string{"role"},
|
||||
Summary: "Delete role",
|
||||
Description: "This endpoint deletes a role",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbDelete)}),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/roles/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.authzHandler.Delete, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "DeleteRole",
|
||||
Tags: []string{"role"},
|
||||
Summary: "Delete role",
|
||||
Description: "This endpoint deletes a role",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbDelete)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceRole,
|
||||
Verb: coretypes.VerbDelete,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: provider.roleSelector,
|
||||
}),
|
||||
)).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func roleCollectionSelectorCallback(_ *http.Request, _ authtypes.Claims) ([]coretypes.Selector, error) {
|
||||
return []coretypes.Selector{
|
||||
coretypes.TypeRole.MustSelector(coretypes.WildCardSelectorString),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (provider *provider) roleInstanceSelectorCallback(req *http.Request, claims authtypes.Claims) ([]coretypes.Selector, error) {
|
||||
roleID, err := valuer.NewUUID(mux.Vars(req)["id"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
role, err := provider.authzService.Get(req.Context(), valuer.MustNewUUID(claims.OrgID), roleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []coretypes.Selector{
|
||||
coretypes.TypeRole.MustSelector(role.Name),
|
||||
coretypes.TypeRole.MustSelector(coretypes.WildCardSelectorString),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
package signozapiserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/http/middleware"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
@@ -17,41 +14,56 @@ import (
|
||||
)
|
||||
|
||||
func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v1/service_accounts", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.Create, authtypes.Relation{Verb: coretypes.VerbCreate}, coretypes.ResourceServiceAccount, serviceAccountCollectionSelectorCallback, []string{
|
||||
authtypes.SigNozAdminRoleName,
|
||||
}), handler.OpenAPIDef{
|
||||
ID: "CreateServiceAccount",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Create service account",
|
||||
Description: "This endpoint creates a service account",
|
||||
Request: new(serviceaccounttypes.PostableServiceAccount),
|
||||
RequestContentType: "",
|
||||
Response: new(types.Identifiable),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbCreate)}),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/service_accounts", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.Create, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CreateServiceAccount",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Create service account",
|
||||
Description: "This endpoint creates a service account",
|
||||
Request: new(serviceaccounttypes.PostableServiceAccount),
|
||||
RequestContentType: "",
|
||||
Response: new(types.Identifiable),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbCreate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceServiceAccount,
|
||||
Verb: coretypes.VerbCreate,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: coretypes.ResponseJSONPath("data.id"),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.List, authtypes.Relation{Verb: coretypes.VerbList}, coretypes.ResourceServiceAccount, serviceAccountCollectionSelectorCallback, []string{
|
||||
authtypes.SigNozAdminRoleName,
|
||||
}), handler.OpenAPIDef{
|
||||
ID: "ListServiceAccounts",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "List service accounts",
|
||||
Description: "This endpoint lists the service accounts for an organisation",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: make([]*serviceaccounttypes.ServiceAccount, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbList)}),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/service_accounts", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.List, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "ListServiceAccounts",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "List service accounts",
|
||||
Description: "This endpoint lists the service accounts for an organisation",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: make([]*serviceaccounttypes.ServiceAccount, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceServiceAccount,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -72,89 +84,117 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.Get, authtypes.Relation{Verb: coretypes.VerbRead}, coretypes.ResourceServiceAccount, serviceAccountInstanceSelectorCallback, []string{
|
||||
authtypes.SigNozAdminRoleName,
|
||||
}), handler.OpenAPIDef{
|
||||
ID: "GetServiceAccount",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Gets a service account",
|
||||
Description: "This endpoint gets an existing service account",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(serviceaccounttypes.ServiceAccountWithRoles),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbRead)}),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.Get, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetServiceAccount",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Gets a service account",
|
||||
Description: "This endpoint gets an existing service account",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(serviceaccounttypes.ServiceAccountWithRoles),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceServiceAccount,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.GetRoles, authtypes.Relation{Verb: coretypes.VerbRead}, coretypes.ResourceServiceAccount, serviceAccountInstanceSelectorCallback, []string{
|
||||
authtypes.SigNozAdminRoleName,
|
||||
}), handler.OpenAPIDef{
|
||||
ID: "GetServiceAccountRoles",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Gets service account roles",
|
||||
Description: "This endpoint gets all the roles for the existing service account",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new([]*authtypes.Role),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbRead)}),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.GetRoles, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetServiceAccountRoles",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Gets service account roles",
|
||||
Description: "This endpoint gets all the roles for the existing service account",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new([]*authtypes.Role),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceServiceAccount,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(provider.authzMiddleware.CheckAll(provider.serviceAccountHandler.SetRole, []middleware.AuthZCheckGroup{
|
||||
{{Relation: authtypes.Relation{Verb: coretypes.VerbAttach}, Resource: coretypes.ResourceServiceAccount, SelectorCallback: serviceAccountInstanceSelectorCallback, Roles: []string{
|
||||
authtypes.SigNozAdminRoleName,
|
||||
}}},
|
||||
{{Relation: authtypes.Relation{Verb: coretypes.VerbAttach}, Resource: coretypes.ResourceRole, SelectorCallback: provider.roleAttachSelectorFromBody, Roles: []string{
|
||||
authtypes.SigNozAdminRoleName,
|
||||
}}},
|
||||
}), handler.OpenAPIDef{
|
||||
ID: "CreateServiceAccountRole",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Create service account role",
|
||||
Description: "This endpoint assigns a role to a service account",
|
||||
Request: new(serviceaccounttypes.PostableServiceAccountRole),
|
||||
RequestContentType: "",
|
||||
Response: new(types.Identifiable),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbAttach), coretypes.ResourceRole.Scope(coretypes.VerbAttach)}),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.SetRole, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CreateServiceAccountRole",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Create service account role",
|
||||
Description: "This endpoint assigns a role to a service account",
|
||||
Request: new(serviceaccounttypes.PostableServiceAccountRole),
|
||||
RequestContentType: "",
|
||||
Response: new(types.Identifiable),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbAttach), coretypes.ResourceRole.Scope(coretypes.VerbAttach)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
SourceResource: coretypes.ResourceServiceAccount,
|
||||
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceRole,
|
||||
TargetIDs: coretypes.OneID(coretypes.BodyJSONPath("id")),
|
||||
TargetSelector: provider.roleSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/roles/{rid}", handler.New(provider.authzMiddleware.CheckAll(provider.serviceAccountHandler.DeleteRole, []middleware.AuthZCheckGroup{
|
||||
{{Relation: authtypes.Relation{Verb: coretypes.VerbDetach}, Resource: coretypes.ResourceServiceAccount, SelectorCallback: serviceAccountInstanceSelectorCallback, Roles: []string{
|
||||
authtypes.SigNozAdminRoleName,
|
||||
}}},
|
||||
{{Relation: authtypes.Relation{Verb: coretypes.VerbDetach}, Resource: coretypes.ResourceRole, SelectorCallback: provider.roleDetachSelectorFromPath, Roles: []string{
|
||||
authtypes.SigNozAdminRoleName,
|
||||
}}},
|
||||
}), handler.OpenAPIDef{
|
||||
ID: "DeleteServiceAccountRole",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Delete service account role",
|
||||
Description: "This endpoint revokes a role from service account",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbDetach), coretypes.ResourceRole.Scope(coretypes.VerbDetach)}),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/roles/{rid}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.DeleteRole, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "DeleteServiceAccountRole",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Delete service account role",
|
||||
Description: "This endpoint revokes a role from service account",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbDetach), coretypes.ResourceRole.Scope(coretypes.VerbDetach)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbDetach,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
SourceResource: coretypes.ResourceServiceAccount,
|
||||
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceRole,
|
||||
TargetIDs: coretypes.OneID(coretypes.PathParam("rid")),
|
||||
TargetSelector: provider.roleSelector,
|
||||
}),
|
||||
)).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -175,208 +215,209 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.Update, authtypes.Relation{Verb: coretypes.VerbUpdate}, coretypes.ResourceServiceAccount, serviceAccountInstanceSelectorCallback, []string{
|
||||
authtypes.SigNozAdminRoleName,
|
||||
}), handler.OpenAPIDef{
|
||||
ID: "UpdateServiceAccount",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Updates a service account",
|
||||
Description: "This endpoint updates an existing service account",
|
||||
Request: new(serviceaccounttypes.UpdatableServiceAccount),
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbUpdate)}),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.Update, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "UpdateServiceAccount",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Updates a service account",
|
||||
Description: "This endpoint updates an existing service account",
|
||||
Request: new(serviceaccounttypes.UpdatableServiceAccount),
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbUpdate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceServiceAccount,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.Delete, authtypes.Relation{Verb: coretypes.VerbDelete}, coretypes.ResourceServiceAccount, serviceAccountInstanceSelectorCallback, []string{
|
||||
authtypes.SigNozAdminRoleName,
|
||||
}), handler.OpenAPIDef{
|
||||
ID: "DeleteServiceAccount",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Deletes a service account",
|
||||
Description: "This endpoint deletes an existing service account",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbDelete)}),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.Delete, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "DeleteServiceAccount",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Deletes a service account",
|
||||
Description: "This endpoint deletes an existing service account",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbDelete)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceServiceAccount,
|
||||
Verb: coretypes.VerbDelete,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(provider.authzMiddleware.CheckAll(provider.serviceAccountHandler.CreateFactorAPIKey, []middleware.AuthZCheckGroup{
|
||||
{{Relation: authtypes.Relation{Verb: coretypes.VerbCreate}, Resource: coretypes.ResourceMetaResourceFactorAPIKey, SelectorCallback: factorAPIKeyCollectionSelectorCallback, Roles: []string{
|
||||
authtypes.SigNozAdminRoleName,
|
||||
}}},
|
||||
{{Relation: authtypes.Relation{Verb: coretypes.VerbAttach}, Resource: coretypes.ResourceServiceAccount, SelectorCallback: serviceAccountInstanceSelectorCallback, Roles: []string{
|
||||
authtypes.SigNozAdminRoleName,
|
||||
}}},
|
||||
}), handler.OpenAPIDef{
|
||||
ID: "CreateServiceAccountKey",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Create a service account key",
|
||||
Description: "This endpoint creates a service account key",
|
||||
Request: new(serviceaccounttypes.PostableFactorAPIKey),
|
||||
RequestContentType: "",
|
||||
Response: new(serviceaccounttypes.GettableFactorAPIKeyWithKey),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbCreate), coretypes.ResourceServiceAccount.Scope(coretypes.VerbAttach)}),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.CreateFactorAPIKey, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CreateServiceAccountKey",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Create a service account key",
|
||||
Description: "This endpoint creates a service account key",
|
||||
Request: new(serviceaccounttypes.PostableFactorAPIKey),
|
||||
RequestContentType: "",
|
||||
Response: new(serviceaccounttypes.GettableFactorAPIKeyWithKey),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbCreate), coretypes.ResourceServiceAccount.Scope(coretypes.VerbAttach)}),
|
||||
},
|
||||
handler.WithResourceDefs(
|
||||
handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceFactorAPIKey,
|
||||
Verb: coretypes.VerbCreate,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: coretypes.ResponseJSONPath("data.id"),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
},
|
||||
handler.AttachDetachParentChildResourceDef{
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ParentResource: coretypes.ResourceServiceAccount,
|
||||
ParentID: coretypes.PathParam("id"),
|
||||
ParentSelector: coretypes.IDSelector,
|
||||
ChildResource: coretypes.ResourceMetaResourceFactorAPIKey,
|
||||
ChildIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
|
||||
},
|
||||
),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.ListFactorAPIKey, authtypes.Relation{Verb: coretypes.VerbList}, coretypes.ResourceMetaResourceFactorAPIKey, factorAPIKeyCollectionSelectorCallback, []string{
|
||||
authtypes.SigNozAdminRoleName,
|
||||
}), handler.OpenAPIDef{
|
||||
ID: "ListServiceAccountKeys",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "List service account keys",
|
||||
Description: "This endpoint lists the service account keys",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: make([]*serviceaccounttypes.GettableFactorAPIKey, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbList)}),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.ListFactorAPIKey, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "ListServiceAccountKeys",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "List service account keys",
|
||||
Description: "This endpoint lists the service account keys",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: make([]*serviceaccounttypes.GettableFactorAPIKey, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceFactorAPIKey,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(provider.authzMiddleware.Check(provider.serviceAccountHandler.UpdateFactorAPIKey, authtypes.Relation{Verb: coretypes.VerbUpdate}, coretypes.ResourceMetaResourceFactorAPIKey, factorAPIKeyInstanceSelectorCallback, []string{
|
||||
authtypes.SigNozAdminRoleName,
|
||||
}), handler.OpenAPIDef{
|
||||
ID: "UpdateServiceAccountKey",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Updates a service account key",
|
||||
Description: "This endpoint updates an existing service account key",
|
||||
Request: new(serviceaccounttypes.UpdatableFactorAPIKey),
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbUpdate)}),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.UpdateFactorAPIKey, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "UpdateServiceAccountKey",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Updates a service account key",
|
||||
Description: "This endpoint updates an existing service account key",
|
||||
Request: new(serviceaccounttypes.UpdatableFactorAPIKey),
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbUpdate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceFactorAPIKey,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: coretypes.PathParam("fid"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(provider.authzMiddleware.CheckAll(provider.serviceAccountHandler.RevokeFactorAPIKey, []middleware.AuthZCheckGroup{
|
||||
{{Relation: authtypes.Relation{Verb: coretypes.VerbDelete}, Resource: coretypes.ResourceMetaResourceFactorAPIKey, SelectorCallback: factorAPIKeyInstanceSelectorCallback, Roles: []string{
|
||||
authtypes.SigNozAdminRoleName,
|
||||
}}},
|
||||
{{Relation: authtypes.Relation{Verb: coretypes.VerbDetach}, Resource: coretypes.ResourceServiceAccount, SelectorCallback: serviceAccountInstanceSelectorCallback, Roles: []string{
|
||||
authtypes.SigNozAdminRoleName,
|
||||
}}},
|
||||
}), handler.OpenAPIDef{
|
||||
ID: "RevokeServiceAccountKey",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Revoke a service account key",
|
||||
Description: "This endpoint revokes an existing service account key",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbDelete), coretypes.ResourceServiceAccount.Scope(coretypes.VerbDetach)}),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.RevokeFactorAPIKey, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "RevokeServiceAccountKey",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Revoke a service account key",
|
||||
Description: "This endpoint revokes an existing service account key",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorAPIKey.Scope(coretypes.VerbDelete), coretypes.ResourceServiceAccount.Scope(coretypes.VerbDetach)}),
|
||||
},
|
||||
handler.WithResourceDefs(
|
||||
handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceFactorAPIKey,
|
||||
Verb: coretypes.VerbDelete,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: coretypes.PathParam("fid"),
|
||||
Selector: coretypes.IDSelector,
|
||||
},
|
||||
handler.AttachDetachParentChildResourceDef{
|
||||
Verb: coretypes.VerbDetach,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ParentResource: coretypes.ResourceServiceAccount,
|
||||
ParentID: coretypes.PathParam("id"),
|
||||
ParentSelector: coretypes.IDSelector,
|
||||
ChildResource: coretypes.ResourceMetaResourceFactorAPIKey,
|
||||
ChildIDs: coretypes.OneID(coretypes.PathParam("fid")),
|
||||
},
|
||||
),
|
||||
)).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) roleDetachSelectorFromPath(req *http.Request, claims authtypes.Claims) ([]coretypes.Selector, error) {
|
||||
roleID, err := valuer.NewUUID(mux.Vars(req)["rid"])
|
||||
// roleSelector resolves the FGA selectors for a role from its UUID. The id is
|
||||
// already extracted by the ResourceDef (path or body); this only does the
|
||||
// UUID -> name lookup the FGA object string requires. Shared by service account
|
||||
// and role routes.
|
||||
func (provider *provider) roleSelector(ctx context.Context, resource coretypes.Resource, id string, orgID valuer.UUID) ([]coretypes.Selector, error) {
|
||||
roleID, err := valuer.NewUUID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
role, err := provider.authzService.Get(req.Context(), valuer.MustNewUUID(claims.OrgID), roleID)
|
||||
role, err := provider.authzService.Get(ctx, orgID, roleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []coretypes.Selector{
|
||||
coretypes.TypeRole.MustSelector(role.Name),
|
||||
coretypes.TypeRole.MustSelector(coretypes.WildCardSelectorString),
|
||||
}, nil
|
||||
|
||||
}
|
||||
|
||||
func (provider *provider) roleAttachSelectorFromBody(req *http.Request, claims authtypes.Claims) ([]coretypes.Selector, error) {
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Body = io.NopCloser(bytes.NewReader(body))
|
||||
|
||||
postableRole := new(serviceaccounttypes.PostableServiceAccountRole)
|
||||
if err := json.Unmarshal(body, postableRole); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
role, err := provider.authzService.Get(req.Context(), valuer.MustNewUUID(claims.OrgID), postableRole.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []coretypes.Selector{
|
||||
coretypes.TypeRole.MustSelector(role.Name),
|
||||
coretypes.TypeRole.MustSelector(coretypes.WildCardSelectorString),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func factorAPIKeyCollectionSelectorCallback(_ *http.Request, _ authtypes.Claims) ([]coretypes.Selector, error) {
|
||||
return []coretypes.Selector{
|
||||
coretypes.TypeMetaResource.MustSelector(coretypes.WildCardSelectorString),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func factorAPIKeyInstanceSelectorCallback(req *http.Request, _ authtypes.Claims) ([]coretypes.Selector, error) {
|
||||
fid := mux.Vars(req)["fid"]
|
||||
fidSelector, err := coretypes.TypeMetaResource.Selector(fid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []coretypes.Selector{
|
||||
fidSelector,
|
||||
coretypes.TypeMetaResource.MustSelector(coretypes.WildCardSelectorString),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func serviceAccountCollectionSelectorCallback(_ *http.Request, _ authtypes.Claims) ([]coretypes.Selector, error) {
|
||||
return []coretypes.Selector{
|
||||
coretypes.TypeServiceAccount.MustSelector(coretypes.WildCardSelectorString),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func serviceAccountInstanceSelectorCallback(req *http.Request, _ authtypes.Claims) ([]coretypes.Selector, error) {
|
||||
id := mux.Vars(req)["id"]
|
||||
idSelector, err := coretypes.TypeServiceAccount.Selector(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []coretypes.Selector{
|
||||
idSelector,
|
||||
coretypes.TypeServiceAccount.MustSelector(coretypes.WildCardSelectorString),
|
||||
resource.Type().MustSelector(role.Name),
|
||||
resource.Type().MustSelector(coretypes.WildCardSelectorString),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -20,16 +20,16 @@ func newTestSettings() factory.ScopedProviderSettings {
|
||||
return factory.NewScopedProviderSettings(instrumentationtest.New().ToProviderSettings(), "auditorserver_test")
|
||||
}
|
||||
|
||||
func newTestEvent(resource string, action coretypes.Verb) audittypes.AuditEvent {
|
||||
func newTestEvent(resource coretypes.Resource, action coretypes.Verb) audittypes.AuditEvent {
|
||||
return audittypes.AuditEvent{
|
||||
Timestamp: time.Now(),
|
||||
EventName: audittypes.NewEventName(coretypes.MustNewKind(resource), action),
|
||||
EventName: audittypes.NewEventName(resource.Kind(), action),
|
||||
AuditAttributes: audittypes.AuditAttributes{
|
||||
Action: action,
|
||||
Outcome: audittypes.OutcomeSuccess,
|
||||
},
|
||||
ResourceAttributes: audittypes.ResourceAttributes{
|
||||
ResourceKind: coretypes.MustNewKind(resource),
|
||||
Resource: resource,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -84,7 +84,7 @@ func TestAdd_FlushesOnBatchSize(t *testing.T) {
|
||||
go func() { _ = server.Start(ctx) }()
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
server.Add(ctx, newTestEvent("dashboard", coretypes.VerbCreate))
|
||||
server.Add(ctx, newTestEvent(coretypes.ResourceMetaResourceDashboard, coretypes.VerbCreate))
|
||||
}
|
||||
|
||||
assert.Eventually(t, func() bool {
|
||||
@@ -113,7 +113,7 @@ func TestAdd_FlushesOnInterval(t *testing.T) {
|
||||
|
||||
go func() { _ = server.Start(ctx) }()
|
||||
|
||||
server.Add(ctx, newTestEvent("user", coretypes.VerbUpdate))
|
||||
server.Add(ctx, newTestEvent(coretypes.ResourceUser, coretypes.VerbUpdate))
|
||||
|
||||
assert.Eventually(t, func() bool {
|
||||
return exported.Load() == 1
|
||||
@@ -131,9 +131,9 @@ func TestAdd_DropsWhenBufferFull(t *testing.T) {
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
server.Add(ctx, newTestEvent("dashboard", coretypes.VerbCreate))
|
||||
server.Add(ctx, newTestEvent("dashboard", coretypes.VerbUpdate))
|
||||
server.Add(ctx, newTestEvent("dashboard", coretypes.VerbDelete))
|
||||
server.Add(ctx, newTestEvent(coretypes.ResourceMetaResourceDashboard, coretypes.VerbCreate))
|
||||
server.Add(ctx, newTestEvent(coretypes.ResourceMetaResourceDashboard, coretypes.VerbUpdate))
|
||||
server.Add(ctx, newTestEvent(coretypes.ResourceMetaResourceDashboard, coretypes.VerbDelete))
|
||||
|
||||
assert.Equal(t, 2, server.queueLen())
|
||||
}
|
||||
@@ -156,7 +156,7 @@ func TestStop_DrainsRemainingEvents(t *testing.T) {
|
||||
go func() { _ = server.Start(ctx) }()
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
server.Add(ctx, newTestEvent("alert-rule", coretypes.VerbCreate))
|
||||
server.Add(ctx, newTestEvent(coretypes.ResourceMetaResourceRule, coretypes.VerbCreate))
|
||||
}
|
||||
|
||||
require.NoError(t, server.Stop(ctx))
|
||||
@@ -181,8 +181,8 @@ func TestAdd_ContinuesAfterExportFailure(t *testing.T) {
|
||||
|
||||
go func() { _ = server.Start(ctx) }()
|
||||
|
||||
server.Add(ctx, newTestEvent("user", coretypes.VerbDelete))
|
||||
server.Add(ctx, newTestEvent("user", coretypes.VerbDelete))
|
||||
server.Add(ctx, newTestEvent(coretypes.ResourceUser, coretypes.VerbDelete))
|
||||
server.Add(ctx, newTestEvent(coretypes.ResourceUser, coretypes.VerbDelete))
|
||||
|
||||
assert.Eventually(t, func() bool {
|
||||
return calls.Load() >= 1
|
||||
@@ -213,7 +213,7 @@ func TestAdd_ConcurrentSafety(t *testing.T) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
server.Add(ctx, newTestEvent("dashboard", coretypes.VerbCreate))
|
||||
server.Add(ctx, newTestEvent(coretypes.ResourceMetaResourceDashboard, coretypes.VerbCreate))
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
@@ -15,13 +15,13 @@ type ServeOpenAPIFunc func(openapi.OperationContext)
|
||||
type Handler interface {
|
||||
http.Handler
|
||||
ServeOpenAPI(openapi.OperationContext)
|
||||
AuditDef() *AuditDef
|
||||
ResourceDefs() []ResourceDef
|
||||
}
|
||||
|
||||
type handler struct {
|
||||
handlerFunc http.HandlerFunc
|
||||
openAPIDef OpenAPIDef
|
||||
auditDef *AuditDef
|
||||
handlerFunc http.HandlerFunc
|
||||
openAPIDef OpenAPIDef
|
||||
resourceDefs []ResourceDef
|
||||
}
|
||||
|
||||
func New(handlerFunc http.HandlerFunc, openAPIDef OpenAPIDef, opts ...Option) Handler {
|
||||
@@ -130,6 +130,6 @@ func (handler *handler) ServeOpenAPI(opCtx openapi.OperationContext) {
|
||||
}
|
||||
}
|
||||
|
||||
func (handler *handler) AuditDef() *AuditDef {
|
||||
return handler.auditDef
|
||||
func (handler *handler) ResourceDefs() []ResourceDef {
|
||||
return handler.resourceDefs
|
||||
}
|
||||
|
||||
@@ -1,25 +1,9 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/types/audittypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
)
|
||||
|
||||
// Option configures optional behaviour on a handler created by New.
|
||||
type Option func(*handler)
|
||||
|
||||
type AuditDef struct {
|
||||
ResourceKind coretypes.Kind // Typeable.Kind() value, e.g. "dashboard", "user".
|
||||
Action coretypes.Verb // create, update, delete, etc.
|
||||
Category audittypes.ActionCategory // access_control, configuration_change, etc.
|
||||
ResourceIDParam string // Gorilla mux path param name for the resource ID.
|
||||
}
|
||||
|
||||
// WithAudit attaches an AuditDef to the handler. The actual audit event
|
||||
// emission is handled by the middleware layer, which reads the AuditDef
|
||||
// from the matched route's handler.
|
||||
func WithAuditDef(def AuditDef) Option {
|
||||
func WithResourceDefs(defs ...ResourceDef) Option {
|
||||
return func(h *handler) {
|
||||
h.auditDef = &def
|
||||
h.resourceDefs = append(h.resourceDefs, defs...)
|
||||
}
|
||||
}
|
||||
|
||||
99
pkg/http/handler/resourcedef.go
Normal file
99
pkg/http/handler/resourcedef.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package handler
|
||||
|
||||
import "github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
|
||||
type ResourceDef interface {
|
||||
// resolveRequest is unexported to seal the interface. It returns a slice so a
|
||||
// single def can fan out (e.g. a telemetry query touching multiple signals).
|
||||
resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource
|
||||
}
|
||||
|
||||
func ResolveRequest(defs []ResourceDef, ec coretypes.ExtractorContext) []coretypes.ResolvedResource {
|
||||
resolved := make([]coretypes.ResolvedResource, 0, len(defs))
|
||||
for _, def := range defs {
|
||||
resolved = append(resolved, def.resolveRequest(ec)...)
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
// BasicResourceDef checks a single resource for one verb.
|
||||
type BasicResourceDef struct {
|
||||
Resource coretypes.Resource
|
||||
Verb coretypes.Verb
|
||||
Category coretypes.ActionCategory
|
||||
ID coretypes.ResourceIDExtractor
|
||||
Selector coretypes.SelectorFunc
|
||||
}
|
||||
|
||||
func (def BasicResourceDef) resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource {
|
||||
return []coretypes.ResolvedResource{
|
||||
coretypes.NewResolvedResource(
|
||||
def.Verb,
|
||||
def.Category,
|
||||
def.Resource,
|
||||
def.ID,
|
||||
def.Selector,
|
||||
ec,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// AttachDetachSiblingResourceDef checks an attach/detach between peer resources;
|
||||
// both source and target are authz-checked.
|
||||
type AttachDetachSiblingResourceDef struct {
|
||||
Verb coretypes.Verb
|
||||
Category coretypes.ActionCategory
|
||||
SourceResource coretypes.Resource
|
||||
SourceIDs coretypes.ResourceIDsExtractor
|
||||
SourceSelector coretypes.SelectorFunc
|
||||
TargetResource coretypes.Resource
|
||||
TargetIDs coretypes.ResourceIDsExtractor
|
||||
TargetSelector coretypes.SelectorFunc
|
||||
}
|
||||
|
||||
func (def AttachDetachSiblingResourceDef) resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource {
|
||||
return []coretypes.ResolvedResource{
|
||||
coretypes.NewResolvedResourceWithTarget(
|
||||
def.Verb,
|
||||
def.Category,
|
||||
def.SourceResource,
|
||||
def.SourceIDs,
|
||||
def.SourceSelector,
|
||||
def.TargetResource,
|
||||
def.TargetIDs,
|
||||
def.TargetSelector,
|
||||
false,
|
||||
ec,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// AttachDetachParentChildResourceDef authz-checks only the parent; the child
|
||||
// rides along for audit context.
|
||||
type AttachDetachParentChildResourceDef struct {
|
||||
Verb coretypes.Verb
|
||||
Category coretypes.ActionCategory
|
||||
ParentResource coretypes.Resource
|
||||
ParentID coretypes.ResourceIDExtractor
|
||||
ParentSelector coretypes.SelectorFunc
|
||||
ChildResource coretypes.Resource
|
||||
ChildIDs coretypes.ResourceIDsExtractor
|
||||
}
|
||||
|
||||
func (def AttachDetachParentChildResourceDef) resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource {
|
||||
return []coretypes.ResolvedResource{
|
||||
coretypes.NewResolvedResourceWithTarget(
|
||||
def.Verb,
|
||||
def.Category,
|
||||
def.ParentResource,
|
||||
coretypes.OneID(def.ParentID),
|
||||
def.ParentSelector,
|
||||
def.ChildResource,
|
||||
def.ChildIDs,
|
||||
nil,
|
||||
true,
|
||||
ec,
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -12,10 +12,10 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/auditor"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/types/audittypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -61,6 +61,12 @@ func (middleware *Audit) Wrap(next http.Handler) http.Handler {
|
||||
|
||||
responseBuffer := &byteBuffer{}
|
||||
writer := newResponseCapture(rw, responseBuffer)
|
||||
|
||||
// Capture the body only when a resolved resource derives an id from it (e.g. a create).
|
||||
if coretypes.ShouldCaptureResponseBody(req.Context()) {
|
||||
writer.EnableBodyCapture()
|
||||
}
|
||||
|
||||
next.ServeHTTP(writer, req)
|
||||
|
||||
statusCode, writeErr := writer.StatusCode(), writer.WriteError()
|
||||
@@ -80,7 +86,7 @@ func (middleware *Audit) Wrap(next http.Handler) http.Handler {
|
||||
fields = append(fields, errors.Attr(writeErr))
|
||||
middleware.logger.ErrorContext(req.Context(), logMessage, fields...)
|
||||
} else {
|
||||
if responseBuffer.Len() != 0 {
|
||||
if statusCode >= 400 && responseBuffer.Len() != 0 {
|
||||
fields = append(fields, "response.body", responseBuffer.String())
|
||||
}
|
||||
|
||||
@@ -94,76 +100,85 @@ func (middleware *Audit) emitAuditEvent(req *http.Request, writer responseCaptur
|
||||
return
|
||||
}
|
||||
|
||||
def := auditDefFromRequest(req)
|
||||
if def == nil {
|
||||
resolved, err := coretypes.ResolvedResourcesFromContext(req.Context())
|
||||
if err != nil || len(resolved) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// extract claims
|
||||
claims, _ := authtypes.ClaimsFromContext(req.Context())
|
||||
|
||||
// extract status code
|
||||
statusCode := writer.StatusCode()
|
||||
|
||||
// extract traces.
|
||||
span := trace.SpanFromContext(req.Context())
|
||||
|
||||
// extract error details.
|
||||
var errorType, errorCode string
|
||||
if statusCode >= 400 {
|
||||
errorType = render.ErrorTypeFromStatusCode(statusCode)
|
||||
errorCode = render.ErrorCodeFromBody(writer.BodyBytes())
|
||||
}
|
||||
|
||||
event := audittypes.NewAuditEventFromHTTPRequest(
|
||||
req,
|
||||
routeTemplate,
|
||||
statusCode,
|
||||
span.SpanContext().TraceID(),
|
||||
span.SpanContext().SpanID(),
|
||||
def.Action,
|
||||
def.Category,
|
||||
claims,
|
||||
resourceIDFromRequest(req, def.ResourceIDParam),
|
||||
def.ResourceKind,
|
||||
errorType,
|
||||
errorCode,
|
||||
)
|
||||
extractorCtx := coretypes.ExtractorContext{Request: req, ResponseBody: writer.BodyBytes()}
|
||||
|
||||
middleware.auditor.Audit(req.Context(), event)
|
||||
}
|
||||
|
||||
func auditDefFromRequest(req *http.Request) *handler.AuditDef {
|
||||
route := mux.CurrentRoute(req)
|
||||
if route == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
actualHandler := route.GetHandler()
|
||||
if actualHandler == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// The type assertion is necessary because route.GetHandler() returns
|
||||
// http.Handler, and not every http.Handler on the mux is a handler.Handler
|
||||
// (e.g. middleware wrappers, raw http.HandlerFunc registrations).
|
||||
provider, ok := actualHandler.(handler.Handler)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return provider.AuditDef()
|
||||
}
|
||||
|
||||
func resourceIDFromRequest(req *http.Request, param string) string {
|
||||
if param == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
vars := mux.Vars(req)
|
||||
if vars == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return vars[param]
|
||||
for _, resource := range resolved {
|
||||
resource.ResolveResponse(extractorCtx)
|
||||
verb, category := resource.Verb(), resource.Category()
|
||||
|
||||
switch typed := resource.(type) {
|
||||
case coretypes.ResolvedResourceWithTargetResource:
|
||||
for _, sourceID := range typed.SourceIDs() {
|
||||
for _, targetID := range typed.TargetIDs() {
|
||||
attributesList := []audittypes.ResourceAttributes{
|
||||
audittypes.NewRelatedResourceAttributes(
|
||||
typed.SourceResource(),
|
||||
sourceID,
|
||||
typed.TargetResource(),
|
||||
targetID,
|
||||
),
|
||||
}
|
||||
|
||||
// Sibling peers are symmetric, so mirror the event from the target's side too.
|
||||
if !typed.IsParentChild() {
|
||||
attributesList = append(attributesList, audittypes.NewRelatedResourceAttributes(
|
||||
typed.TargetResource(),
|
||||
targetID,
|
||||
typed.SourceResource(),
|
||||
sourceID,
|
||||
))
|
||||
}
|
||||
|
||||
for _, attributes := range attributesList {
|
||||
middleware.auditor.Audit(req.Context(), audittypes.NewAuditEventFromHTTPRequest(
|
||||
req,
|
||||
routeTemplate,
|
||||
statusCode,
|
||||
span.SpanContext().TraceID(),
|
||||
span.SpanContext().SpanID(),
|
||||
verb,
|
||||
category,
|
||||
claims,
|
||||
attributes,
|
||||
errorType,
|
||||
errorCode,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
for _, id := range resource.SourceIDs() {
|
||||
attributes := audittypes.NewResourceAttributes(resource.SourceResource(), id)
|
||||
|
||||
middleware.auditor.Audit(req.Context(), audittypes.NewAuditEventFromHTTPRequest(
|
||||
req,
|
||||
routeTemplate,
|
||||
statusCode,
|
||||
span.SpanContext().TraceID(),
|
||||
span.SpanContext().SpanID(),
|
||||
verb,
|
||||
category,
|
||||
claims,
|
||||
attributes,
|
||||
errorType,
|
||||
errorCode,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
@@ -19,18 +21,6 @@ const (
|
||||
authzDeniedMessage string = "::AUTHZ-DENIED::"
|
||||
)
|
||||
|
||||
type AuthZCheckDef struct {
|
||||
Relation authtypes.Relation
|
||||
Resource coretypes.Resource
|
||||
SelectorCallback selectorCallbackWithClaimsFn
|
||||
Roles []string
|
||||
}
|
||||
|
||||
// AuthZCheckGroup is a set of checks OR'd together.
|
||||
// At least one check in the group must pass for the group to pass.
|
||||
type AuthZCheckGroup []AuthZCheckDef
|
||||
|
||||
type selectorCallbackWithClaimsFn func(*http.Request, authtypes.Claims) ([]coretypes.Selector, error)
|
||||
type selectorCallbackWithoutClaimsFn func(*http.Request, []*types.Organization) ([]coretypes.Selector, valuer.UUID, error)
|
||||
|
||||
type AuthZ struct {
|
||||
@@ -201,7 +191,9 @@ func (middleware *AuthZ) OpenAccess(next http.HandlerFunc) http.HandlerFunc {
|
||||
})
|
||||
}
|
||||
|
||||
func (middleware *AuthZ) Check(next http.HandlerFunc, relation authtypes.Relation, typeable coretypes.Resource, cb selectorCallbackWithClaimsFn, roles []string) http.HandlerFunc {
|
||||
// CheckResources authorizes every resolved resource for the route. roles are the
|
||||
// allowed role names (the OSS role-gate); the resource selectors drive the EE check.
|
||||
func (middleware *AuthZ) CheckResources(next http.HandlerFunc, roles ...string) http.HandlerFunc {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
@@ -210,40 +202,7 @@ func (middleware *AuthZ) Check(next http.HandlerFunc, relation authtypes.Relatio
|
||||
return
|
||||
}
|
||||
|
||||
selectors, err := cb(req, claims)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
roleSelectors := []coretypes.Selector{}
|
||||
for _, role := range roles {
|
||||
roleSelectors = append(roleSelectors, coretypes.TypeRole.MustSelector(role))
|
||||
}
|
||||
|
||||
err = middleware.authzService.CheckWithTupleCreation(ctx, claims, valuer.MustNewUUID(claims.OrgID), relation, typeable, selectors, roleSelectors)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
next(rw, req)
|
||||
})
|
||||
}
|
||||
|
||||
// CheckAll verifies groups of permission checks.
|
||||
// Within each group, checks are OR'd (any check passing = group passes).
|
||||
// Across groups, results are AND'd (all groups must pass).
|
||||
//
|
||||
// This model expresses any combination:
|
||||
// - Single check: []AuthZCheckGroup{{checkA}}
|
||||
// - Pure AND: []AuthZCheckGroup{{checkA}, {checkB}}
|
||||
// - Cross-resource OR: []AuthZCheckGroup{{checkA, checkB}}
|
||||
// - Mixed (A OR B) AND C: []AuthZCheckGroup{{checkA, checkB}, {checkC}}
|
||||
func (middleware *AuthZ) CheckAll(next http.HandlerFunc, groups []AuthZCheckGroup) http.HandlerFunc {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
resolved, err := coretypes.ResolvedResourcesFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
@@ -251,33 +210,23 @@ func (middleware *AuthZ) CheckAll(next http.HandlerFunc, groups []AuthZCheckGrou
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
for _, group := range groups {
|
||||
groupPassed := false
|
||||
var lastErr error
|
||||
roleSelectors := make([]coretypes.Selector, len(roles))
|
||||
for idx, role := range roles {
|
||||
roleSelectors[idx] = coretypes.TypeRole.MustSelector(role)
|
||||
}
|
||||
|
||||
for _, check := range group {
|
||||
selectors, err := check.SelectorCallback(req, claims)
|
||||
if err != nil {
|
||||
for _, resource := range resolved {
|
||||
if err := middleware.checkResource(ctx, claims, orgID, resource.Verb(), resource.SourceResource(), resource.SourceIDs(), resource.SourceSelector(), roleSelectors); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
target, ok := resource.(coretypes.ResolvedResourceWithTargetResource)
|
||||
if ok && !target.IsParentChild() {
|
||||
if err := middleware.checkResource(ctx, claims, orgID, target.Verb(), target.TargetResource(), target.TargetIDs(), target.TargetSelector(), roleSelectors); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
roleSelectors := make([]coretypes.Selector, len(check.Roles))
|
||||
for idx, role := range check.Roles {
|
||||
roleSelectors[idx] = coretypes.TypeRole.MustSelector(role)
|
||||
}
|
||||
|
||||
err = middleware.authzService.CheckWithTupleCreation(ctx, claims, orgID, check.Relation, check.Resource, selectors, roleSelectors)
|
||||
if err == nil {
|
||||
groupPassed = true
|
||||
break
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
if !groupPassed {
|
||||
render.Error(rw, lastErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,6 +234,68 @@ func (middleware *AuthZ) CheckAll(next http.HandlerFunc, groups []AuthZCheckGrou
|
||||
})
|
||||
}
|
||||
|
||||
func (middleware *AuthZ) checkResource(
|
||||
ctx context.Context,
|
||||
claims authtypes.Claims,
|
||||
orgID valuer.UUID,
|
||||
verb coretypes.Verb,
|
||||
resource coretypes.Resource,
|
||||
ids []string,
|
||||
selector coretypes.SelectorFunc,
|
||||
roleSelectors []coretypes.Selector,
|
||||
) error {
|
||||
if selector == nil {
|
||||
return errors.New(errors.TypeInternal, errors.CodeInternal, "resolved resource is missing a selector")
|
||||
}
|
||||
|
||||
for _, id := range ids {
|
||||
selectors, err := selector(ctx, resource, id, orgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = middleware.authzService.CheckWithTupleCreation(
|
||||
ctx,
|
||||
claims,
|
||||
orgID,
|
||||
authtypes.Relation{Verb: verb},
|
||||
resource,
|
||||
selectors,
|
||||
roleSelectors,
|
||||
)
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if !errors.Asc(err, authtypes.ErrCodeAuthZForbidden) {
|
||||
return err
|
||||
}
|
||||
|
||||
middleware.logger.WarnContext(ctx, authzDeniedMessage, slog.Any("claims", claims))
|
||||
principal := fmt.Sprintf("%s/%s", claims.Principal.StringValue(), claims.IdentityID())
|
||||
if id != "" {
|
||||
return errors.Newf(
|
||||
errors.TypeForbidden,
|
||||
authtypes.ErrCodeAuthZForbidden,
|
||||
"%s is not authorized to perform %s on resource %q",
|
||||
principal,
|
||||
resource.Scope(verb),
|
||||
id,
|
||||
)
|
||||
}
|
||||
|
||||
return errors.Newf(
|
||||
errors.TypeForbidden,
|
||||
authtypes.ErrCodeAuthZForbidden,
|
||||
"%s is not authorized to perform %s",
|
||||
principal,
|
||||
resource.Scope(verb),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (middleware *AuthZ) CheckWithoutClaims(next http.HandlerFunc, relation authtypes.Relation, typeable coretypes.Resource, cb selectorCallbackWithoutClaimsFn, roles []string) http.HandlerFunc {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
|
||||
67
pkg/http/middleware/resource.go
Normal file
67
pkg/http/middleware/resource.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// Resource resolves a route's declared ResourceDefs and stashes the result in
|
||||
// the request context for authz and audit to read.
|
||||
type Resource struct {
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewResource(logger *slog.Logger) *Resource {
|
||||
return &Resource{logger: logger.With(slog.String("pkg", pkgname))}
|
||||
}
|
||||
|
||||
func (middleware *Resource) Wrap(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||
defs := resourceDefsFromRequest(req)
|
||||
if len(defs) == 0 {
|
||||
next.ServeHTTP(rw, req)
|
||||
return
|
||||
}
|
||||
|
||||
// Buffer the body once so extractors can read it and the handler still sees a fresh reader.
|
||||
var body []byte
|
||||
if req.Body != nil {
|
||||
body, _ = io.ReadAll(req.Body)
|
||||
req.Body = io.NopCloser(bytes.NewReader(body))
|
||||
}
|
||||
|
||||
extractorCtx := coretypes.ExtractorContext{
|
||||
Request: req,
|
||||
RequestBody: body,
|
||||
}
|
||||
resolved := handler.ResolveRequest(defs, extractorCtx)
|
||||
|
||||
ctx := coretypes.NewContextWithResolvedResources(req.Context(), resolved)
|
||||
next.ServeHTTP(rw, req.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func resourceDefsFromRequest(req *http.Request) []handler.ResourceDef {
|
||||
route := mux.CurrentRoute(req)
|
||||
if route == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
actualHandler := route.GetHandler()
|
||||
if actualHandler == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
provider, ok := actualHandler.(handler.Handler)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return provider.ResourceDefs()
|
||||
}
|
||||
@@ -23,9 +23,14 @@ type responseCapture interface {
|
||||
// WriteError returns the error (if any) from the downstream Write call.
|
||||
WriteError() error
|
||||
|
||||
// BodyBytes returns the captured response body bytes. Only populated
|
||||
// for error responses (status >= 400).
|
||||
// BodyBytes returns the captured response body bytes. Populated for error
|
||||
// responses (status >= 400), or for any response once EnableBodyCapture is called.
|
||||
BodyBytes() []byte
|
||||
|
||||
// EnableBodyCapture forces capture of the response body regardless of status
|
||||
// code (still bounded by maxResponseBodyCapture). Must be called before the
|
||||
// handler writes the response.
|
||||
EnableBodyCapture()
|
||||
}
|
||||
|
||||
func newResponseCapture(rw http.ResponseWriter, buffer *byteBuffer) responseCapture {
|
||||
@@ -72,12 +77,13 @@ func (b *byteBuffer) String() string {
|
||||
}
|
||||
|
||||
type nonFlushingResponseCapture struct {
|
||||
rw http.ResponseWriter
|
||||
buffer *byteBuffer
|
||||
captureBody bool
|
||||
bodyBytesLeft int
|
||||
statusCode int
|
||||
writeError error
|
||||
rw http.ResponseWriter
|
||||
buffer *byteBuffer
|
||||
captureBody bool
|
||||
forceCaptureBody bool
|
||||
bodyBytesLeft int
|
||||
statusCode int
|
||||
writeError error
|
||||
}
|
||||
|
||||
type flushingResponseCapture struct {
|
||||
@@ -98,13 +104,17 @@ func (writer *nonFlushingResponseCapture) Header() http.Header {
|
||||
// WriteHeader writes the HTTP response header.
|
||||
func (writer *nonFlushingResponseCapture) WriteHeader(statusCode int) {
|
||||
writer.statusCode = statusCode
|
||||
if statusCode >= 400 {
|
||||
if statusCode >= 400 || writer.forceCaptureBody {
|
||||
writer.captureBody = true
|
||||
}
|
||||
|
||||
writer.rw.WriteHeader(statusCode)
|
||||
}
|
||||
|
||||
func (writer *nonFlushingResponseCapture) EnableBodyCapture() {
|
||||
writer.forceCaptureBody = true
|
||||
}
|
||||
|
||||
// Write writes HTTP response data.
|
||||
func (writer *nonFlushingResponseCapture) Write(data []byte) (int, error) {
|
||||
if writer.statusCode == 0 {
|
||||
|
||||
@@ -73,11 +73,11 @@ type Module interface {
|
||||
|
||||
PinV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, id valuer.UUID) error
|
||||
|
||||
UnpinV2(ctx context.Context, userID valuer.UUID, id valuer.UUID) error
|
||||
UnpinV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, id valuer.UUID) error
|
||||
|
||||
DeleteV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error
|
||||
|
||||
DeletePreferencesForUser(ctx context.Context, userID valuer.UUID) error
|
||||
DeletePreferencesForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error
|
||||
}
|
||||
|
||||
type Handler interface {
|
||||
|
||||
@@ -13,9 +13,16 @@ type Compiled struct {
|
||||
Args []any
|
||||
}
|
||||
|
||||
func (c Compiled) IsEmpty() bool {
|
||||
return c.SQL == ""
|
||||
}
|
||||
|
||||
// Compile always returns a non-nil *Compiled. An empty query (or one that
|
||||
// produces no SQL) yields a Compiled with an empty SQL — callers gate on
|
||||
// SQL != "" rather than a nil check.
|
||||
func Compile(query string, formatter sqlstore.SQLFormatter) (*Compiled, error) {
|
||||
if len(query) == 0 {
|
||||
return nil, nil //nolint:nilnil
|
||||
return &Compiled{}, nil
|
||||
}
|
||||
|
||||
queryVisitor := newVisitor(formatter)
|
||||
@@ -29,9 +36,6 @@ func Compile(query string, formatter sqlstore.SQLFormatter) (*Compiled, error) {
|
||||
return nil, errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardListFilterInvalid,
|
||||
"invalid filter query: %s", strings.Join(queryVisitor.errors, "; "))
|
||||
}
|
||||
if sql == "" {
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
|
||||
return &Compiled{
|
||||
SQL: sql,
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
type compileCase struct {
|
||||
subtestName string
|
||||
dslQueryToCompile string
|
||||
nilExpected bool
|
||||
emptyQueryExpected bool
|
||||
expectedSQL string
|
||||
expectedArgs []any
|
||||
expectedErrShouldContain string
|
||||
@@ -41,8 +41,8 @@ func runCompileCases(t *testing.T, cases []compileCase) {
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
if c.nilExpected {
|
||||
assert.Nil(t, out)
|
||||
if c.emptyQueryExpected {
|
||||
assert.True(t, out.IsEmpty())
|
||||
return
|
||||
}
|
||||
require.NotNil(t, out)
|
||||
@@ -71,7 +71,7 @@ func runCompileCases(t *testing.T, cases []compileCase) {
|
||||
|
||||
func TestCompile_Empty(t *testing.T) {
|
||||
runCompileCases(t, []compileCase{
|
||||
{subtestName: "empty query yields nil", dslQueryToCompile: "", nilExpected: true},
|
||||
{subtestName: "empty query yields nil", dslQueryToCompile: "", emptyQueryExpected: true},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ func (store *store) ListForUser(
|
||||
Where("dashboard.org_id = ?", orgID).
|
||||
Where("dashboard.source != ?", dashboardtypes.SourceSystem)
|
||||
|
||||
if compiled != nil {
|
||||
if !compiled.IsEmpty() {
|
||||
q = q.Where(compiled.SQL, compiled.Args...)
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ func (store *store) ListV2(
|
||||
Where("dashboard.org_id = ?", orgID).
|
||||
Where("dashboard.source != ?", dashboardtypes.SourceSystem)
|
||||
|
||||
if compiled != nil {
|
||||
if !compiled.IsEmpty() {
|
||||
q = q.Where(compiled.SQL, compiled.Args...)
|
||||
}
|
||||
|
||||
@@ -383,15 +383,16 @@ func (store *store) RunInTx(ctx context.Context, cb func(ctx context.Context) er
|
||||
// rows = 0 is the only signal of a real limit hit.
|
||||
func (store *store) PinForUser(ctx context.Context, preference *dashboardtypes.UserDashboardPreference) error {
|
||||
res, err := store.sqlstore.BunDBCtx(ctx).NewRaw(`
|
||||
INSERT INTO user_dashboard_preference (user_id, dashboard_id, is_pinned)
|
||||
SELECT ?, ?, true
|
||||
INSERT INTO user_dashboard_preference (id, user_id, dashboard_id, is_pinned, created_at, updated_at)
|
||||
SELECT ?, ?, ?, true, ?, ?
|
||||
WHERE (SELECT COUNT(*) FROM user_dashboard_preference WHERE user_id = ? AND is_pinned = true) < ?
|
||||
OR EXISTS (SELECT 1 FROM user_dashboard_preference WHERE user_id = ? AND dashboard_id = ? AND is_pinned = true)
|
||||
ON CONFLICT (user_id, dashboard_id) DO UPDATE SET is_pinned = true
|
||||
ON CONFLICT (user_id, dashboard_id) DO UPDATE SET is_pinned = true, updated_at = ?
|
||||
`,
|
||||
preference.UserID, preference.DashboardID,
|
||||
preference.ID, preference.UserID, preference.DashboardID, preference.CreatedAt, preference.UpdatedAt,
|
||||
preference.UserID, dashboardtypes.MaxPinnedDashboardsPerUser,
|
||||
preference.UserID, preference.DashboardID,
|
||||
preference.UpdatedAt,
|
||||
).Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "couldn't pin dashboard for user")
|
||||
@@ -410,12 +411,21 @@ func (store *store) PinForUser(ctx context.Context, preference *dashboardtypes.U
|
||||
// UnpinForUser deletes the user's preference row. This is fine while is_pinned
|
||||
// is the only preference stored; once the row carries other preferences this
|
||||
// must become an UPDATE that clears is_pinned instead of dropping the row.
|
||||
func (store *store) UnpinForUser(ctx context.Context, userID valuer.UUID, dashboardID valuer.UUID) error {
|
||||
func (store *store) UnpinForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, dashboardID valuer.UUID) error {
|
||||
// No org_id on the preference table, so scope by org via a subquery on the
|
||||
// parent (DELETE-with-JOIN isn't portable across Postgres/SQLite).
|
||||
dashboardIDsInOrgSubQuery := store.sqlstore.BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
TableExpr("dashboard").
|
||||
Column("id").
|
||||
Where("org_id = ?", orgID)
|
||||
|
||||
_, err := store.sqlstore.BunDBCtx(ctx).
|
||||
NewDelete().
|
||||
Model((*dashboardtypes.UserDashboardPreference)(nil)).
|
||||
Where("user_id = ?", userID).
|
||||
Where("dashboard_id = ?", dashboardID).
|
||||
Where("dashboard_id IN (?)", dashboardIDsInOrgSubQuery).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "couldn't unpin dashboard for user")
|
||||
@@ -423,11 +433,19 @@ func (store *store) UnpinForUser(ctx context.Context, userID valuer.UUID, dashbo
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) DeletePreferencesForDashboard(ctx context.Context, dashboardID valuer.UUID) error {
|
||||
func (store *store) DeletePreferencesForDashboard(ctx context.Context, orgID valuer.UUID, dashboardID valuer.UUID) error {
|
||||
// No org_id on the preference table, so scope by org via a subquery on the
|
||||
// parent (DELETE-with-JOIN isn't portable across Postgres/SQLite).
|
||||
dashboardIDsInOrgSubQuery := store.sqlstore.BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
TableExpr("dashboard").
|
||||
Column("id").
|
||||
Where("org_id = ?", orgID)
|
||||
_, err := store.sqlstore.BunDBCtx(ctx).
|
||||
NewDelete().
|
||||
Model((*dashboardtypes.UserDashboardPreference)(nil)).
|
||||
Where("dashboard_id = ?", dashboardID).
|
||||
Where("dashboard_id IN (?)", dashboardIDsInOrgSubQuery).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "couldn't delete dashboard preferences")
|
||||
@@ -435,11 +453,19 @@ func (store *store) DeletePreferencesForDashboard(ctx context.Context, dashboard
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) DeletePreferencesForUser(ctx context.Context, userID valuer.UUID) error {
|
||||
func (store *store) DeletePreferencesForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error {
|
||||
// No org_id on the preference table, so scope by org via a subquery on the
|
||||
// parent (DELETE-with-JOIN isn't portable across Postgres/SQLite).
|
||||
userIDsInOrgSubQuery := store.sqlstore.BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
TableExpr("users").
|
||||
Column("id").
|
||||
Where("org_id = ?", orgID)
|
||||
_, err := store.sqlstore.BunDBCtx(ctx).
|
||||
NewDelete().
|
||||
Model((*dashboardtypes.UserDashboardPreference)(nil)).
|
||||
Where("user_id = ?", userID).
|
||||
Where("user_id IN (?)", userIDsInOrgSubQuery).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "couldn't delete dashboard preferences")
|
||||
|
||||
@@ -304,7 +304,7 @@ func (handler *handler) pinUnpinV2(rw http.ResponseWriter, r *http.Request, pin
|
||||
if pin {
|
||||
err = handler.module.PinV2(ctx, orgID, userID, dashboardID)
|
||||
} else {
|
||||
err = handler.module.UnpinV2(ctx, userID, dashboardID)
|
||||
err = handler.module.UnpinV2(ctx, orgID, userID, dashboardID)
|
||||
}
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
|
||||
@@ -119,7 +119,7 @@ func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer
|
||||
return nil, err
|
||||
}
|
||||
// Locked-dashboard / state gate — independent of tags, so run it before the tx.
|
||||
if err := existing.CanUpdate(); err != nil {
|
||||
if err := existing.ErrIfNotUpdatable(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ func (module *module) PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.
|
||||
return nil, err
|
||||
}
|
||||
// Locked-dashboard / state gate — independent of tags, so run it before the tx.
|
||||
if err := existing.CanUpdate(); err != nil {
|
||||
if err := existing.ErrIfNotUpdatable(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ func (module *module) DeleteV2(ctx context.Context, orgID valuer.UUID, id valuer
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := existing.CanDelete(); err != nil {
|
||||
if err := existing.ErrIfNotDeletable(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -202,7 +202,7 @@ func (module *module) DeleteV2(ctx context.Context, orgID valuer.UUID, id valuer
|
||||
if _, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, id, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := module.store.DeletePreferencesForDashboard(ctx, id); err != nil {
|
||||
if err := module.store.DeletePreferencesForDashboard(ctx, orgID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return module.store.Delete(ctx, orgID, id)
|
||||
@@ -231,10 +231,10 @@ func (module *module) PinV2(ctx context.Context, orgID valuer.UUID, userID value
|
||||
return module.store.PinForUser(ctx, dashboardtypes.NewUserDashboardPreference(userID, id))
|
||||
}
|
||||
|
||||
func (module *module) UnpinV2(ctx context.Context, userID valuer.UUID, id valuer.UUID) error {
|
||||
return module.store.UnpinForUser(ctx, userID, id)
|
||||
func (module *module) UnpinV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, id valuer.UUID) error {
|
||||
return module.store.UnpinForUser(ctx, orgID, userID, id)
|
||||
}
|
||||
|
||||
func (module *module) DeletePreferencesForUser(ctx context.Context, userID valuer.UUID) error {
|
||||
return module.store.DeletePreferencesForUser(ctx, userID)
|
||||
func (module *module) DeletePreferencesForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error {
|
||||
return module.store.DeletePreferencesForUser(ctx, orgID, userID)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/emailing"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
root "github.com/SigNoz/signoz/pkg/modules/user"
|
||||
"github.com/SigNoz/signoz/pkg/tokenizer"
|
||||
@@ -35,11 +34,11 @@ type setter struct {
|
||||
analytics analytics.Analytics
|
||||
config root.Config
|
||||
getter root.Getter
|
||||
dashboard dashboard.Module
|
||||
onDeleteUser []root.OnDeleteUser
|
||||
}
|
||||
|
||||
// This module is a WIP, don't take inspiration from this.
|
||||
func NewSetter(store types.UserStore, tokenizer tokenizer.Tokenizer, emailing emailing.Emailing, providerSettings factory.ProviderSettings, orgSetter organization.Setter, authz authz.AuthZ, analytics analytics.Analytics, config root.Config, userRoleStore authtypes.UserRoleStore, getter root.Getter, dashboard dashboard.Module) root.Setter {
|
||||
func NewSetter(store types.UserStore, tokenizer tokenizer.Tokenizer, emailing emailing.Emailing, providerSettings factory.ProviderSettings, orgSetter organization.Setter, authz authz.AuthZ, analytics analytics.Analytics, config root.Config, userRoleStore authtypes.UserRoleStore, getter root.Getter, onDeleteUser []root.OnDeleteUser) root.Setter {
|
||||
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/user/impluser")
|
||||
return &setter{
|
||||
store: store,
|
||||
@@ -52,7 +51,7 @@ func NewSetter(store types.UserStore, tokenizer tokenizer.Tokenizer, emailing em
|
||||
authz: authz,
|
||||
config: config,
|
||||
getter: getter,
|
||||
dashboard: dashboard,
|
||||
onDeleteUser: onDeleteUser,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,8 +408,10 @@ func (module *setter) DeleteUser(ctx context.Context, orgID valuer.UUID, id stri
|
||||
return err
|
||||
}
|
||||
|
||||
if err := module.dashboard.DeletePreferencesForUser(ctx, user.ID); err != nil {
|
||||
return err
|
||||
for _, onDeleteUser := range module.onDeleteUser {
|
||||
if err := onDeleteUser(ctx, orgID, user.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
traitsOrProperties := types.NewTraitsFromUser(user)
|
||||
|
||||
@@ -129,3 +129,6 @@ type Handler interface {
|
||||
ChangePassword(http.ResponseWriter, *http.Request)
|
||||
ForgotPassword(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
// OnDeleteUser lets other modules clean up data tied to a deleted user.
|
||||
type OnDeleteUser func(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error
|
||||
|
||||
@@ -168,6 +168,7 @@ func (s *Server) createPublicServer(api *APIHandler, web web.Web) (*http.Server,
|
||||
s.config.APIServer.Timeout.Default,
|
||||
s.config.APIServer.Timeout.Max,
|
||||
).Wrap)
|
||||
r.Use(middleware.NewResource(s.signoz.Instrumentation.Logger()).Wrap)
|
||||
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, s.signoz.Auditor).Wrap)
|
||||
r.Use(middleware.NewComment().Wrap)
|
||||
|
||||
|
||||
@@ -122,7 +122,11 @@ func NewModules(
|
||||
) Modules {
|
||||
quickfilter := implquickfilter.NewModule(implquickfilter.NewStore(sqlstore))
|
||||
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter)
|
||||
userSetter := impluser.NewSetter(impluser.NewStore(sqlstore, providerSettings), tokenizer, emailing, providerSettings, orgSetter, authz, analytics, config.User, userRoleStore, userGetter, dashboard)
|
||||
// Cleanup callbacks from other modules, invoked when a user is deleted.
|
||||
onDeleteUser := []user.OnDeleteUser{
|
||||
dashboard.DeletePreferencesForUser,
|
||||
}
|
||||
userSetter := impluser.NewSetter(impluser.NewStore(sqlstore, providerSettings), tokenizer, emailing, providerSettings, orgSetter, authz, analytics, config.User, userRoleStore, userGetter, onDeleteUser)
|
||||
ruleStore := sqlrulestore.NewRuleStore(sqlstore, queryParser, providerSettings)
|
||||
|
||||
return Modules{
|
||||
|
||||
@@ -212,6 +212,7 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewFixChangelogOperationTypeFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewCloudIntegrationRemoveCascadeDeleteFactory(sqlschema),
|
||||
sqlmigration.NewAddUserDashboardPreferenceFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewRecreateUserDashboardPreferenceFactory(sqlstore, sqlschema),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
84
pkg/sqlmigration/093_recreate_user_dashboard_preference.go
Normal file
84
pkg/sqlmigration/093_recreate_user_dashboard_preference.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlschema"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type recreateUserDashboardPreference struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
sqlschema sqlschema.SQLSchema
|
||||
}
|
||||
|
||||
func NewRecreateUserDashboardPreferenceFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("recreate_user_dashboard_pref"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &recreateUserDashboardPreference{
|
||||
sqlstore: sqlstore,
|
||||
sqlschema: sqlschema,
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *recreateUserDashboardPreference) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
// Up replaces the composite (user_id, dashboard_id) primary key with a surrogate
|
||||
// id primary key, demotes the pair to a unique index, and adds created_at /
|
||||
// updated_at. The table is dropped and recreated since it carries no data yet.
|
||||
func (migration *recreateUserDashboardPreference) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
sqls := migration.sqlschema.Operator().DropTable(&sqlschema.Table{Name: "user_dashboard_preference"})
|
||||
|
||||
sqls = append(sqls, migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
|
||||
Name: "user_dashboard_preference",
|
||||
Columns: []*sqlschema.Column{
|
||||
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "user_id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "dashboard_id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "is_pinned", DataType: sqlschema.DataTypeBoolean, Nullable: false, Default: "false"},
|
||||
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
|
||||
{Name: "updated_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
|
||||
},
|
||||
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{ColumnNames: []sqlschema.ColumnName{"id"}},
|
||||
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
|
||||
{
|
||||
ReferencingColumnName: sqlschema.ColumnName("user_id"),
|
||||
ReferencedTableName: sqlschema.TableName("users"),
|
||||
ReferencedColumnName: sqlschema.ColumnName("id"),
|
||||
},
|
||||
{
|
||||
ReferencingColumnName: sqlschema.ColumnName("dashboard_id"),
|
||||
ReferencedTableName: sqlschema.TableName("dashboard"),
|
||||
ReferencedColumnName: sqlschema.ColumnName("id"),
|
||||
},
|
||||
},
|
||||
})...)
|
||||
|
||||
sqls = append(sqls, migration.sqlschema.Operator().CreateIndex(&sqlschema.UniqueIndex{
|
||||
TableName: "user_dashboard_preference",
|
||||
ColumnNames: []sqlschema.ColumnName{"user_id", "dashboard_id"},
|
||||
})...)
|
||||
|
||||
for _, sql := range sqls {
|
||||
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *recreateUserDashboardPreference) Down(_ context.Context, _ *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
@@ -13,13 +13,13 @@ import (
|
||||
|
||||
// Audit attributes — Action (What).
|
||||
type AuditAttributes struct {
|
||||
Action coretypes.Verb // guaranteed to be present
|
||||
ActionCategory ActionCategory // guaranteed to be present
|
||||
Outcome Outcome // guaranteed to be present
|
||||
Action coretypes.Verb // guaranteed to be present
|
||||
ActionCategory coretypes.ActionCategory // guaranteed to be present
|
||||
Outcome Outcome // guaranteed to be present
|
||||
IdentNProvider authtypes.IdentNProvider
|
||||
}
|
||||
|
||||
func NewAuditAttributesFromHTTP(statusCode int, action coretypes.Verb, category ActionCategory, claims authtypes.Claims) AuditAttributes {
|
||||
func NewAuditAttributesFromHTTP(statusCode int, action coretypes.Verb, category coretypes.ActionCategory, claims authtypes.Claims) AuditAttributes {
|
||||
outcome := OutcomeFailure
|
||||
if statusCode >= 200 && statusCode < 400 {
|
||||
outcome = OutcomeSuccess
|
||||
@@ -71,23 +71,50 @@ func (attributes PrincipalAttributes) Put(dest pcommon.Map) {
|
||||
// Audit attributes — Resource (On What).
|
||||
// These are OTel resource attributes (placed on the Resource, not event attributes).
|
||||
type ResourceAttributes struct {
|
||||
ResourceID string
|
||||
ResourceKind coretypes.Kind // guaranteed to be present
|
||||
Resource coretypes.Resource // guaranteed to be present
|
||||
ResourceID string
|
||||
|
||||
// TargetResource names the counterpart of an attach/detach event (audit
|
||||
// context only). nil when there is no relationship.
|
||||
TargetResource coretypes.Resource
|
||||
TargetResourceID string
|
||||
}
|
||||
|
||||
func NewResourceAttributes(resourceID string, resourceKind coretypes.Kind) ResourceAttributes {
|
||||
func NewResourceAttributes(resource coretypes.Resource, resourceID string) ResourceAttributes {
|
||||
return ResourceAttributes{
|
||||
ResourceID: resourceID,
|
||||
ResourceKind: resourceKind,
|
||||
Resource: resource,
|
||||
ResourceID: resourceID,
|
||||
}
|
||||
}
|
||||
|
||||
// NewAttachResourceAttributes builds resource attributes that additionally name
|
||||
// the target counterpart (used for attach/detach audit events).
|
||||
func NewRelatedResourceAttributes(resource coretypes.Resource, resourceID string, targetResource coretypes.Resource, targetResourceID string) ResourceAttributes {
|
||||
return ResourceAttributes{
|
||||
Resource: resource,
|
||||
ResourceID: resourceID,
|
||||
TargetResource: targetResource,
|
||||
TargetResourceID: targetResourceID,
|
||||
}
|
||||
}
|
||||
|
||||
// PutResource writes the resource attributes to an OTel Resource's attribute map.
|
||||
// These are resource-level attributes (stored in the resource JSON column),
|
||||
// not event-level attributes (stored in attributes_string).
|
||||
func (attributes ResourceAttributes) PutResource(dest pcommon.Map) {
|
||||
putStrIfNotEmpty(dest, "signoz.audit.resource.kind", attributes.ResourceKind.String())
|
||||
func (attributes ResourceAttributes) PutResource(orgID valuer.UUID, dest pcommon.Map) {
|
||||
putStrIfNotEmpty(dest, "signoz.audit.resource.kind", attributes.Resource.Kind().String())
|
||||
putStrIfNotEmpty(dest, "signoz.audit.resource.id", attributes.ResourceID)
|
||||
if attributes.ResourceID != "" {
|
||||
putStrIfNotEmpty(dest, "signoz.audit.resource.object", attributes.Resource.Object(orgID, attributes.ResourceID))
|
||||
}
|
||||
|
||||
if attributes.TargetResource != nil {
|
||||
putStrIfNotEmpty(dest, "signoz.audit.resource.target.kind", attributes.TargetResource.Kind().String())
|
||||
putStrIfNotEmpty(dest, "signoz.audit.resource.target.id", attributes.TargetResourceID)
|
||||
if attributes.TargetResourceID != "" {
|
||||
putStrIfNotEmpty(dest, "signoz.audit.resource.target.object", attributes.TargetResource.Object(orgID, attributes.TargetResourceID))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Audit attributes — Error (When outcome is failure)
|
||||
@@ -193,13 +220,24 @@ func newBody(auditAttributes AuditAttributes, principalAttributes PrincipalAttri
|
||||
|
||||
// Resource: " kind (id)" or " kind".
|
||||
b.WriteString(" ")
|
||||
b.WriteString(resourceAttributes.ResourceKind.String())
|
||||
b.WriteString(resourceAttributes.Resource.Kind().String())
|
||||
if resourceAttributes.ResourceID != "" {
|
||||
b.WriteString(" (")
|
||||
b.WriteString(resourceAttributes.ResourceID)
|
||||
b.WriteString(")")
|
||||
}
|
||||
|
||||
// Target (attach/detach context): " · target kind (id)" or " · target kind".
|
||||
if resourceAttributes.TargetResource != nil {
|
||||
b.WriteString(" to ")
|
||||
b.WriteString(resourceAttributes.TargetResource.Kind().String())
|
||||
if resourceAttributes.TargetResourceID != "" {
|
||||
b.WriteString(" (")
|
||||
b.WriteString(resourceAttributes.TargetResourceID)
|
||||
b.WriteString(")")
|
||||
}
|
||||
}
|
||||
|
||||
// Error suffix (failure only): ": type (code)" or ": type" or ": (code)" or omitted.
|
||||
if auditAttributes.Outcome == OutcomeFailure {
|
||||
errorType := errorAttributes.ErrorType
|
||||
|
||||
@@ -36,7 +36,7 @@ func TestNewAuditAttributesFromHTTP_OutcomeBoundary(t *testing.T) {
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
attrs := NewAuditAttributesFromHTTP(testCase.statusCode, coretypes.VerbUpdate, ActionCategoryConfigurationChange, claims)
|
||||
attrs := NewAuditAttributesFromHTTP(testCase.statusCode, coretypes.VerbUpdate, coretypes.ActionCategoryConfigurationChange, claims)
|
||||
assert.Equal(t, testCase.expectedOutcome, attrs.Outcome)
|
||||
})
|
||||
}
|
||||
@@ -55,7 +55,7 @@ func TestNewBody(t *testing.T) {
|
||||
name: "Success_EmptyResourceID",
|
||||
auditAttributes: AuditAttributes{
|
||||
Action: coretypes.VerbDelete,
|
||||
ActionCategory: ActionCategoryConfigurationChange,
|
||||
ActionCategory: coretypes.ActionCategoryConfigurationChange,
|
||||
Outcome: OutcomeSuccess,
|
||||
},
|
||||
principalAttributes: PrincipalAttributes{
|
||||
@@ -63,8 +63,8 @@ func TestNewBody(t *testing.T) {
|
||||
PrincipalEmail: valuer.MustNewEmail("test@acme.com"),
|
||||
},
|
||||
resourceAttributes: ResourceAttributes{
|
||||
ResourceID: "",
|
||||
ResourceKind: coretypes.MustNewKind("dashboard"),
|
||||
ResourceID: "",
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
},
|
||||
errorAttributes: ErrorAttributes{},
|
||||
expectedBody: "test@acme.com (019a1234-abcd-7000-8000-567800000001) deleted dashboard",
|
||||
@@ -73,7 +73,7 @@ func TestNewBody(t *testing.T) {
|
||||
name: "Success_EmptyPrincipalEmail",
|
||||
auditAttributes: AuditAttributes{
|
||||
Action: coretypes.VerbDelete,
|
||||
ActionCategory: ActionCategoryConfigurationChange,
|
||||
ActionCategory: coretypes.ActionCategoryConfigurationChange,
|
||||
Outcome: OutcomeSuccess,
|
||||
},
|
||||
principalAttributes: PrincipalAttributes{
|
||||
@@ -81,8 +81,8 @@ func TestNewBody(t *testing.T) {
|
||||
PrincipalEmail: valuer.Email{},
|
||||
},
|
||||
resourceAttributes: ResourceAttributes{
|
||||
ResourceID: "abd",
|
||||
ResourceKind: coretypes.MustNewKind("dashboard"),
|
||||
ResourceID: "abd",
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
},
|
||||
errorAttributes: ErrorAttributes{},
|
||||
expectedBody: "019a1234-abcd-7000-8000-567800000001 deleted dashboard (abd)",
|
||||
@@ -91,7 +91,7 @@ func TestNewBody(t *testing.T) {
|
||||
name: "Success_EmptyPrincipalIDandEmail",
|
||||
auditAttributes: AuditAttributes{
|
||||
Action: coretypes.VerbDelete,
|
||||
ActionCategory: ActionCategoryConfigurationChange,
|
||||
ActionCategory: coretypes.ActionCategoryConfigurationChange,
|
||||
Outcome: OutcomeSuccess,
|
||||
},
|
||||
principalAttributes: PrincipalAttributes{
|
||||
@@ -99,8 +99,8 @@ func TestNewBody(t *testing.T) {
|
||||
PrincipalEmail: valuer.Email{},
|
||||
},
|
||||
resourceAttributes: ResourceAttributes{
|
||||
ResourceID: "abd",
|
||||
ResourceKind: coretypes.MustNewKind("dashboard"),
|
||||
ResourceID: "abd",
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
},
|
||||
errorAttributes: ErrorAttributes{},
|
||||
expectedBody: "deleted dashboard (abd)",
|
||||
@@ -109,7 +109,7 @@ func TestNewBody(t *testing.T) {
|
||||
name: "Success_AllPresent",
|
||||
auditAttributes: AuditAttributes{
|
||||
Action: coretypes.VerbCreate,
|
||||
ActionCategory: ActionCategoryConfigurationChange,
|
||||
ActionCategory: coretypes.ActionCategoryConfigurationChange,
|
||||
Outcome: OutcomeSuccess,
|
||||
},
|
||||
principalAttributes: PrincipalAttributes{
|
||||
@@ -117,8 +117,8 @@ func TestNewBody(t *testing.T) {
|
||||
PrincipalEmail: valuer.MustNewEmail("alice@acme.com"),
|
||||
},
|
||||
resourceAttributes: ResourceAttributes{
|
||||
ResourceID: "019b-5678",
|
||||
ResourceKind: coretypes.MustNewKind("dashboard"),
|
||||
ResourceID: "019b-5678",
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
},
|
||||
errorAttributes: ErrorAttributes{},
|
||||
expectedBody: "alice@acme.com (019a1234-abcd-7000-8000-567800000001) created dashboard (019b-5678)",
|
||||
@@ -127,21 +127,21 @@ func TestNewBody(t *testing.T) {
|
||||
name: "Success_EmptyEverythingOptional",
|
||||
auditAttributes: AuditAttributes{
|
||||
Action: coretypes.VerbUpdate,
|
||||
ActionCategory: ActionCategoryConfigurationChange,
|
||||
ActionCategory: coretypes.ActionCategoryConfigurationChange,
|
||||
Outcome: OutcomeSuccess,
|
||||
},
|
||||
principalAttributes: PrincipalAttributes{},
|
||||
resourceAttributes: ResourceAttributes{
|
||||
ResourceKind: coretypes.MustNewKind("alert-rule"),
|
||||
Resource: coretypes.ResourceMetaResourceRule,
|
||||
},
|
||||
errorAttributes: ErrorAttributes{},
|
||||
expectedBody: "updated alert-rule",
|
||||
expectedBody: "updated rule",
|
||||
},
|
||||
{
|
||||
name: "Failure_AllPresent",
|
||||
auditAttributes: AuditAttributes{
|
||||
Action: coretypes.VerbUpdate,
|
||||
ActionCategory: ActionCategoryConfigurationChange,
|
||||
ActionCategory: coretypes.ActionCategoryConfigurationChange,
|
||||
Outcome: OutcomeFailure,
|
||||
},
|
||||
principalAttributes: PrincipalAttributes{
|
||||
@@ -149,8 +149,8 @@ func TestNewBody(t *testing.T) {
|
||||
PrincipalEmail: valuer.MustNewEmail("viewer@acme.com"),
|
||||
},
|
||||
resourceAttributes: ResourceAttributes{
|
||||
ResourceID: "019b-5678",
|
||||
ResourceKind: coretypes.MustNewKind("dashboard"),
|
||||
ResourceID: "019b-5678",
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
},
|
||||
errorAttributes: ErrorAttributes{
|
||||
ErrorType: "forbidden",
|
||||
@@ -169,7 +169,7 @@ func TestNewBody(t *testing.T) {
|
||||
PrincipalEmail: valuer.MustNewEmail("test@acme.com"),
|
||||
},
|
||||
resourceAttributes: ResourceAttributes{
|
||||
ResourceKind: coretypes.MustNewKind("user"),
|
||||
Resource: coretypes.ResourceUser,
|
||||
},
|
||||
errorAttributes: ErrorAttributes{
|
||||
ErrorType: "not-found",
|
||||
@@ -187,8 +187,8 @@ func TestNewBody(t *testing.T) {
|
||||
PrincipalEmail: valuer.MustNewEmail("test@acme.com"),
|
||||
},
|
||||
resourceAttributes: ResourceAttributes{
|
||||
ResourceID: "019b-5678",
|
||||
ResourceKind: coretypes.MustNewKind("dashboard"),
|
||||
ResourceID: "019b-5678",
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
},
|
||||
errorAttributes: ErrorAttributes{},
|
||||
expectedBody: "test@acme.com (019a1234-abcd-7000-8000-567800000001) failed to create dashboard (019b-5678)",
|
||||
|
||||
@@ -44,6 +44,8 @@ type AuditEvent struct {
|
||||
TransportAttributes TransportAttributes
|
||||
}
|
||||
|
||||
// NewAuditEvent builds an audit event from pre-built resource attributes (which
|
||||
// may carry attach/target context).
|
||||
func NewAuditEventFromHTTPRequest(
|
||||
req *http.Request,
|
||||
route string,
|
||||
@@ -51,16 +53,14 @@ func NewAuditEventFromHTTPRequest(
|
||||
traceID oteltrace.TraceID,
|
||||
spanID oteltrace.SpanID,
|
||||
action coretypes.Verb,
|
||||
actionCategory ActionCategory,
|
||||
actionCategory coretypes.ActionCategory,
|
||||
claims authtypes.Claims,
|
||||
resourceID string,
|
||||
resourceKind coretypes.Kind,
|
||||
resourceAttributes ResourceAttributes,
|
||||
errorType string,
|
||||
errorCode string,
|
||||
) AuditEvent {
|
||||
auditAttributes := NewAuditAttributesFromHTTP(statusCode, action, actionCategory, claims)
|
||||
principalAttributes := NewPrincipalAttributesFromClaims(claims)
|
||||
resourceAttributes := NewResourceAttributes(resourceID, resourceKind)
|
||||
errorAttributes := NewErrorAttributes(errorType, errorCode)
|
||||
transportAttributes := NewTransportAttributesFromHTTP(req, route, statusCode)
|
||||
|
||||
@@ -69,7 +69,7 @@ func NewAuditEventFromHTTPRequest(
|
||||
TraceID: traceID,
|
||||
SpanID: spanID,
|
||||
Body: newBody(auditAttributes, principalAttributes, resourceAttributes, errorAttributes),
|
||||
EventName: NewEventName(resourceAttributes.ResourceKind, auditAttributes.Action),
|
||||
EventName: NewEventName(resourceAttributes.Resource.Kind(), auditAttributes.Action),
|
||||
AuditAttributes: auditAttributes,
|
||||
PrincipalAttributes: principalAttributes,
|
||||
ResourceAttributes: resourceAttributes,
|
||||
@@ -89,7 +89,7 @@ func NewPLogsFromAuditEvents(events []AuditEvent, name string, version string, s
|
||||
groups := make(map[resourceKey][]int)
|
||||
order := make([]resourceKey, 0)
|
||||
for i, event := range events {
|
||||
key := resourceKey{kind: event.ResourceAttributes.ResourceKind.String(), id: event.ResourceAttributes.ResourceID}
|
||||
key := resourceKey{kind: event.ResourceAttributes.Resource.Kind().String(), id: event.ResourceAttributes.ResourceID}
|
||||
if _, exists := groups[key]; !exists {
|
||||
order = append(order, key)
|
||||
}
|
||||
@@ -101,7 +101,8 @@ func NewPLogsFromAuditEvents(events []AuditEvent, name string, version string, s
|
||||
resourceAttrs := resourceLogs.Resource().Attributes()
|
||||
resourceAttrs.PutStr(string(semconv.ServiceNameKey), name)
|
||||
resourceAttrs.PutStr(string(semconv.ServiceVersionKey), version)
|
||||
events[groups[key][0]].ResourceAttributes.PutResource(resourceAttrs)
|
||||
head := events[groups[key][0]]
|
||||
head.ResourceAttributes.PutResource(head.PrincipalAttributes.PrincipalOrgID, resourceAttrs)
|
||||
|
||||
scopeLogs := resourceLogs.ScopeLogs().AppendEmpty()
|
||||
scopeLogs.Scope().SetName(scope)
|
||||
|
||||
@@ -12,10 +12,10 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
testDashboardKind = coretypes.MustNewKind("dashboard")
|
||||
testDashboardResource = coretypes.ResourceMetaResourceDashboard
|
||||
)
|
||||
|
||||
func TestNewAuditEventFromHTTPRequest(t *testing.T) {
|
||||
func TestNewAuditEvent(t *testing.T) {
|
||||
traceID := oteltrace.TraceID{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
|
||||
spanID := oteltrace.SpanID{1, 2, 3, 4, 5, 6, 7, 8}
|
||||
|
||||
@@ -26,10 +26,10 @@ func TestNewAuditEventFromHTTPRequest(t *testing.T) {
|
||||
route string
|
||||
statusCode int
|
||||
action coretypes.Verb
|
||||
category ActionCategory
|
||||
category coretypes.ActionCategory
|
||||
claims authtypes.Claims
|
||||
resource coretypes.Resource
|
||||
resourceID string
|
||||
resourceKind coretypes.Kind
|
||||
errorType string
|
||||
errorCode string
|
||||
expectedOutcome Outcome
|
||||
@@ -42,10 +42,10 @@ func TestNewAuditEventFromHTTPRequest(t *testing.T) {
|
||||
route: "/api/v1/dashboards",
|
||||
statusCode: http.StatusOK,
|
||||
action: coretypes.VerbCreate,
|
||||
category: ActionCategoryConfigurationChange,
|
||||
category: coretypes.ActionCategoryConfigurationChange,
|
||||
claims: authtypes.Claims{UserID: "019a1234-abcd-7000-8000-567800000001", Email: "alice@acme.com", OrgID: "019a-0000-0000-0001", IdentNProvider: authtypes.IdentNProviderTokenizer},
|
||||
resource: testDashboardResource,
|
||||
resourceID: "019b-5678-efgh-9012",
|
||||
resourceKind: testDashboardKind,
|
||||
expectedOutcome: OutcomeSuccess,
|
||||
expectedBody: "alice@acme.com (019a1234-abcd-7000-8000-567800000001) created dashboard (019b-5678-efgh-9012)",
|
||||
},
|
||||
@@ -56,10 +56,10 @@ func TestNewAuditEventFromHTTPRequest(t *testing.T) {
|
||||
route: "/api/v1/dashboards/{id}",
|
||||
statusCode: http.StatusForbidden,
|
||||
action: coretypes.VerbUpdate,
|
||||
category: ActionCategoryConfigurationChange,
|
||||
category: coretypes.ActionCategoryConfigurationChange,
|
||||
claims: authtypes.Claims{UserID: "019aaaaa-bbbb-7000-8000-cccc00000002", Email: "viewer@acme.com", OrgID: "019a-0000-0000-0001", IdentNProvider: authtypes.IdentNProviderTokenizer},
|
||||
resource: testDashboardResource,
|
||||
resourceID: "019b-5678-efgh-9012",
|
||||
resourceKind: testDashboardKind,
|
||||
errorType: "forbidden",
|
||||
errorCode: "authz_forbidden",
|
||||
expectedOutcome: OutcomeFailure,
|
||||
@@ -80,15 +80,14 @@ func TestNewAuditEventFromHTTPRequest(t *testing.T) {
|
||||
testCase.action,
|
||||
testCase.category,
|
||||
testCase.claims,
|
||||
testCase.resourceID,
|
||||
testCase.resourceKind,
|
||||
NewResourceAttributes(testCase.resource, testCase.resourceID),
|
||||
testCase.errorType,
|
||||
testCase.errorCode,
|
||||
)
|
||||
|
||||
assert.Equal(t, testCase.expectedOutcome, event.AuditAttributes.Outcome)
|
||||
assert.Equal(t, testCase.expectedBody, event.Body)
|
||||
assert.Equal(t, testCase.resourceKind, event.ResourceAttributes.ResourceKind)
|
||||
assert.Equal(t, testCase.resource.Kind(), event.ResourceAttributes.Resource.Kind())
|
||||
assert.Equal(t, testCase.resourceID, event.ResourceAttributes.ResourceID)
|
||||
assert.Equal(t, testCase.action, event.AuditAttributes.Action)
|
||||
assert.Equal(t, testCase.category, event.AuditAttributes.ActionCategory)
|
||||
@@ -103,18 +102,18 @@ func TestNewAuditEventFromHTTPRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func newTestEvent(resourceKind coretypes.Kind, resourceID string, action coretypes.Verb) AuditEvent {
|
||||
func newTestEvent(resource coretypes.Resource, resourceID string, action coretypes.Verb) AuditEvent {
|
||||
return AuditEvent{
|
||||
Body: resourceKind.String() + "." + action.PastTense(),
|
||||
EventName: NewEventName(resourceKind, action),
|
||||
Body: resource.Kind().String() + "." + action.PastTense(),
|
||||
EventName: NewEventName(resource.Kind(), action),
|
||||
AuditAttributes: AuditAttributes{
|
||||
Action: action,
|
||||
ActionCategory: ActionCategoryConfigurationChange,
|
||||
ActionCategory: coretypes.ActionCategoryConfigurationChange,
|
||||
Outcome: OutcomeSuccess,
|
||||
},
|
||||
ResourceAttributes: ResourceAttributes{
|
||||
ResourceKind: resourceKind,
|
||||
ResourceID: resourceID,
|
||||
Resource: resource,
|
||||
ResourceID: resourceID,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -136,7 +135,7 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
|
||||
{
|
||||
name: "SingleEvent",
|
||||
events: []AuditEvent{
|
||||
newTestEvent(testDashboardKind, "d-001", coretypes.VerbCreate),
|
||||
newTestEvent(testDashboardResource, "d-001", coretypes.VerbCreate),
|
||||
},
|
||||
expectedResourceLogs: 1,
|
||||
expectedResourceKinds: []string{"dashboard"},
|
||||
@@ -146,9 +145,9 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
|
||||
{
|
||||
name: "SameResource_MultipleEvents",
|
||||
events: []AuditEvent{
|
||||
newTestEvent(testDashboardKind, "d-001", coretypes.VerbCreate),
|
||||
newTestEvent(testDashboardKind, "d-001", coretypes.VerbUpdate),
|
||||
newTestEvent(testDashboardKind, "d-001", coretypes.VerbDelete),
|
||||
newTestEvent(testDashboardResource, "d-001", coretypes.VerbCreate),
|
||||
newTestEvent(testDashboardResource, "d-001", coretypes.VerbUpdate),
|
||||
newTestEvent(testDashboardResource, "d-001", coretypes.VerbDelete),
|
||||
},
|
||||
expectedResourceLogs: 1,
|
||||
expectedResourceKinds: []string{"dashboard"},
|
||||
@@ -158,8 +157,8 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
|
||||
{
|
||||
name: "DifferentResources_SeparateGroups",
|
||||
events: []AuditEvent{
|
||||
newTestEvent(testDashboardKind, "d-001", coretypes.VerbUpdate),
|
||||
newTestEvent(coretypes.MustNewKind("user"), "u-001", coretypes.VerbDelete),
|
||||
newTestEvent(testDashboardResource, "d-001", coretypes.VerbUpdate),
|
||||
newTestEvent(coretypes.ResourceUser, "u-001", coretypes.VerbDelete),
|
||||
},
|
||||
expectedResourceLogs: 2,
|
||||
expectedResourceKinds: []string{"dashboard", "user"},
|
||||
@@ -169,8 +168,8 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
|
||||
{
|
||||
name: "SameKind_DifferentIDs_SeparateGroups",
|
||||
events: []AuditEvent{
|
||||
newTestEvent(testDashboardKind, "d-001", coretypes.VerbUpdate),
|
||||
newTestEvent(testDashboardKind, "d-002", coretypes.VerbDelete),
|
||||
newTestEvent(testDashboardResource, "d-001", coretypes.VerbUpdate),
|
||||
newTestEvent(testDashboardResource, "d-002", coretypes.VerbDelete),
|
||||
},
|
||||
expectedResourceLogs: 2,
|
||||
expectedResourceKinds: []string{"dashboard", "dashboard"},
|
||||
@@ -180,11 +179,11 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
|
||||
{
|
||||
name: "InterleavedResources_GroupedCorrectly",
|
||||
events: []AuditEvent{
|
||||
newTestEvent(testDashboardKind, "d-001", coretypes.VerbCreate),
|
||||
newTestEvent(coretypes.MustNewKind("user"), "u-001", coretypes.VerbUpdate),
|
||||
newTestEvent(testDashboardKind, "d-001", coretypes.VerbUpdate),
|
||||
newTestEvent(coretypes.MustNewKind("user"), "u-001", coretypes.VerbDelete),
|
||||
newTestEvent(testDashboardKind, "d-001", coretypes.VerbDelete),
|
||||
newTestEvent(testDashboardResource, "d-001", coretypes.VerbCreate),
|
||||
newTestEvent(coretypes.ResourceUser, "u-001", coretypes.VerbUpdate),
|
||||
newTestEvent(testDashboardResource, "d-001", coretypes.VerbUpdate),
|
||||
newTestEvent(coretypes.ResourceUser, "u-001", coretypes.VerbDelete),
|
||||
newTestEvent(testDashboardResource, "d-001", coretypes.VerbDelete),
|
||||
},
|
||||
expectedResourceLogs: 2,
|
||||
expectedResourceKinds: []string{"dashboard", "user"},
|
||||
@@ -203,7 +202,6 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
|
||||
resourceLogs := logs.ResourceLogs().At(i)
|
||||
resourceAttrs := resourceLogs.Resource().Attributes()
|
||||
|
||||
// Verify service resource attributes
|
||||
serviceName, exists := resourceAttrs.Get("service.name")
|
||||
assert.True(t, exists)
|
||||
assert.Equal(t, "signoz", serviceName.Str())
|
||||
@@ -212,7 +210,6 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
|
||||
assert.True(t, exists)
|
||||
assert.Equal(t, "0.90.0", serviceVersion.Str())
|
||||
|
||||
// Verify audit resource attributes on Resource (not event attributes)
|
||||
kind, exists := resourceAttrs.Get("signoz.audit.resource.kind")
|
||||
assert.True(t, exists)
|
||||
assert.Equal(t, testCase.expectedResourceKinds[i], kind.Str())
|
||||
@@ -221,14 +218,11 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
|
||||
assert.True(t, exists)
|
||||
assert.Equal(t, testCase.expectedResourceIDs[i], id.Str())
|
||||
|
||||
// Verify scope
|
||||
assert.Equal(t, 1, resourceLogs.ScopeLogs().Len())
|
||||
assert.Equal(t, "signoz.audit", resourceLogs.ScopeLogs().At(0).Scope().Name())
|
||||
|
||||
// Verify log record count per group
|
||||
assert.Equal(t, testCase.expectedLogRecordCounts[i], resourceLogs.ScopeLogs().At(0).LogRecords().Len())
|
||||
|
||||
// Verify resource attrs are NOT in log record event attributes
|
||||
for j := 0; j < resourceLogs.ScopeLogs().At(0).LogRecords().Len(); j++ {
|
||||
recordAttrs := resourceLogs.ScopeLogs().At(0).LogRecords().At(j).Attributes()
|
||||
_, hasKind := recordAttrs.Get("signoz.audit.resource.kind")
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
package audittypes
|
||||
package coretypes
|
||||
|
||||
import "github.com/SigNoz/signoz/pkg/valuer"
|
||||
|
||||
// ActionCategory classifies the audit event per IEC 62443.
|
||||
// See https://www.iec.ch/blog/understanding-iec-62443 for the standard reference.
|
||||
type ActionCategory struct{ valuer.String }
|
||||
|
||||
var (
|
||||
ActionCategoryAccessControl = ActionCategory{valuer.NewString("access_control")}
|
||||
ActionCategoryConfigurationChange = ActionCategory{valuer.NewString("configuration_change")}
|
||||
@@ -13,6 +9,10 @@ var (
|
||||
ActionCategorySystemEvent = ActionCategory{valuer.NewString("system_event")}
|
||||
)
|
||||
|
||||
// ActionCategory classifies an audited action per IEC 62443.
|
||||
// See https://www.iec.ch/blog/understanding-iec-62443 for the standard reference.
|
||||
type ActionCategory struct{ valuer.String }
|
||||
|
||||
func (ActionCategory) Enum() []any {
|
||||
return []any{
|
||||
ActionCategoryAccessControl,
|
||||
99
pkg/types/coretypes/extractor.go
Normal file
99
pkg/types/coretypes/extractor.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package coretypes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const (
|
||||
PhaseRequest ExtractPhase = iota
|
||||
PhaseResponse
|
||||
)
|
||||
|
||||
type ExtractPhase int
|
||||
|
||||
// ExtractorContext carries everything an extractor may read: Request + RequestBody
|
||||
// are filled pre-handler, ResponseBody post-handler.
|
||||
type ExtractorContext struct {
|
||||
Request *http.Request
|
||||
RequestBody []byte
|
||||
ResponseBody []byte
|
||||
}
|
||||
|
||||
type ResourceIDExtractor struct {
|
||||
Phase ExtractPhase
|
||||
Fn func(ExtractorContext) (string, error)
|
||||
}
|
||||
|
||||
type ResourceIDsExtractor struct {
|
||||
Phase ExtractPhase
|
||||
Fn func(ExtractorContext) ([]string, error)
|
||||
}
|
||||
|
||||
func (extractor ResourceIDExtractor) IsPhase(phase ExtractPhase) bool {
|
||||
return extractor.Fn != nil && extractor.Phase == phase
|
||||
}
|
||||
|
||||
func (extractor ResourceIDExtractor) RunFor(phase ExtractPhase, ec ExtractorContext) (string, bool) {
|
||||
if !extractor.IsPhase(phase) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
id, _ := extractor.Fn(ec)
|
||||
return id, true
|
||||
}
|
||||
|
||||
func (extractor ResourceIDsExtractor) IsPhase(phase ExtractPhase) bool {
|
||||
return extractor.Fn != nil && extractor.Phase == phase
|
||||
}
|
||||
|
||||
// OneID lifts a single-id extractor into a one-element ids extractor.
|
||||
func OneID(extractor ResourceIDExtractor) ResourceIDsExtractor {
|
||||
return ResourceIDsExtractor{Phase: extractor.Phase, Fn: func(ec ExtractorContext) ([]string, error) {
|
||||
id, err := extractor.Fn(ec)
|
||||
if err != nil || id == "" {
|
||||
return nil, err
|
||||
}
|
||||
return []string{id}, nil
|
||||
}}
|
||||
}
|
||||
|
||||
func PathParam(name string) ResourceIDExtractor {
|
||||
return ResourceIDExtractor{Phase: PhaseRequest, Fn: func(ec ExtractorContext) (string, error) {
|
||||
if ec.Request == nil {
|
||||
return "", nil
|
||||
}
|
||||
return mux.Vars(ec.Request)[name], nil
|
||||
}}
|
||||
}
|
||||
|
||||
func BodyJSONPath(path string) ResourceIDExtractor {
|
||||
return ResourceIDExtractor{Phase: PhaseRequest, Fn: func(ec ExtractorContext) (string, error) {
|
||||
return gjson.GetBytes(ec.RequestBody, path).String(), nil
|
||||
}}
|
||||
}
|
||||
|
||||
func BodyJSONArray(path string) ResourceIDsExtractor {
|
||||
return ResourceIDsExtractor{Phase: PhaseRequest, Fn: func(ec ExtractorContext) ([]string, error) {
|
||||
result := gjson.GetBytes(ec.RequestBody, path)
|
||||
if !result.Exists() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
array := result.Array()
|
||||
ids := make([]string, 0, len(array))
|
||||
for _, r := range array {
|
||||
ids = append(ids, r.String())
|
||||
}
|
||||
|
||||
return ids, nil
|
||||
}}
|
||||
}
|
||||
|
||||
func ResponseJSONPath(path string) ResourceIDExtractor {
|
||||
return ResourceIDExtractor{Phase: PhaseResponse, Fn: func(ec ExtractorContext) (string, error) {
|
||||
return gjson.GetBytes(ec.ResponseBody, path).String(), nil
|
||||
}}
|
||||
}
|
||||
64
pkg/types/coretypes/resolved.go
Normal file
64
pkg/types/coretypes/resolved.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package coretypes
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
var errCodeResolvedResourcesNotFound = errors.MustNewCode("resolved_resources_not_found")
|
||||
|
||||
type resolvedKey struct{}
|
||||
|
||||
// ResolvedResource is the resolved form of a resource def, produced by the
|
||||
// resource middleware and read by authz and audit.
|
||||
type ResolvedResource interface {
|
||||
Verb() Verb
|
||||
Category() ActionCategory
|
||||
SourceResource() Resource
|
||||
SourceIDs() []string
|
||||
SourceSelector() SelectorFunc
|
||||
ResolveResponse(ec ExtractorContext)
|
||||
// hasResponsePhase reports whether an id is resolved from the response body.
|
||||
hasResponsePhase() bool
|
||||
}
|
||||
|
||||
type ResolvedResourceWithTargetResource interface {
|
||||
ResolvedResource
|
||||
TargetResource() Resource
|
||||
TargetIDs() []string
|
||||
TargetSelector() SelectorFunc
|
||||
// IsParentChild true: the target is a child audited along but not authz-checked
|
||||
// (only the source is); false: a sibling peer that is also authz-checked.
|
||||
IsParentChild() bool
|
||||
}
|
||||
|
||||
func NewContextWithResolvedResources(ctx context.Context, resolved []ResolvedResource) context.Context {
|
||||
return context.WithValue(ctx, resolvedKey{}, resolved)
|
||||
}
|
||||
|
||||
func ResolvedResourcesFromContext(ctx context.Context) ([]ResolvedResource, error) {
|
||||
resolved, ok := ctx.Value(resolvedKey{}).([]ResolvedResource)
|
||||
if !ok {
|
||||
return nil, errors.New(errors.TypeInternal, errCodeResolvedResourcesNotFound, "resolved resources not found in context")
|
||||
}
|
||||
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// ShouldCaptureResponseBody reports whether any resolved resource in ctx derives
|
||||
// an id from the response body.
|
||||
func ShouldCaptureResponseBody(ctx context.Context) bool {
|
||||
resolved, err := ResolvedResourcesFromContext(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, resource := range resolved {
|
||||
if resource.hasResponsePhase() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
69
pkg/types/coretypes/resolved_resource.go
Normal file
69
pkg/types/coretypes/resolved_resource.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package coretypes
|
||||
|
||||
type resolvedResource struct {
|
||||
verb Verb
|
||||
category ActionCategory
|
||||
resource Resource
|
||||
selector SelectorFunc
|
||||
idExtractor ResourceIDExtractor
|
||||
ids []string
|
||||
}
|
||||
|
||||
func NewResolvedResource(
|
||||
verb Verb,
|
||||
category ActionCategory,
|
||||
resource Resource,
|
||||
idExtractor ResourceIDExtractor,
|
||||
selector SelectorFunc,
|
||||
ec ExtractorContext,
|
||||
) ResolvedResource {
|
||||
resolved := &resolvedResource{
|
||||
verb: verb,
|
||||
category: category,
|
||||
resource: resource,
|
||||
selector: selector,
|
||||
idExtractor: idExtractor,
|
||||
}
|
||||
resolved.fill(PhaseRequest, ec)
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
func (resolved *resolvedResource) fill(phase ExtractPhase, ec ExtractorContext) {
|
||||
if id, ok := resolved.idExtractor.RunFor(phase, ec); ok && id != "" {
|
||||
resolved.ids = []string{id}
|
||||
}
|
||||
}
|
||||
|
||||
func (resolved *resolvedResource) Verb() Verb {
|
||||
return resolved.verb
|
||||
}
|
||||
|
||||
func (resolved *resolvedResource) Category() ActionCategory {
|
||||
return resolved.category
|
||||
}
|
||||
|
||||
func (resolved *resolvedResource) SourceResource() Resource {
|
||||
return resolved.resource
|
||||
}
|
||||
|
||||
// An empty id (when none resolved) means collection-level access.
|
||||
func (resolved *resolvedResource) SourceIDs() []string {
|
||||
if len(resolved.ids) == 0 {
|
||||
return []string{""}
|
||||
}
|
||||
|
||||
return resolved.ids
|
||||
}
|
||||
|
||||
func (resolved *resolvedResource) SourceSelector() SelectorFunc {
|
||||
return resolved.selector
|
||||
}
|
||||
|
||||
func (resolved *resolvedResource) ResolveResponse(ec ExtractorContext) {
|
||||
resolved.fill(PhaseResponse, ec)
|
||||
}
|
||||
|
||||
func (resolved *resolvedResource) hasResponsePhase() bool {
|
||||
return resolved.idExtractor.IsPhase(PhaseResponse)
|
||||
}
|
||||
108
pkg/types/coretypes/resolved_resource_with_target.go
Normal file
108
pkg/types/coretypes/resolved_resource_with_target.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package coretypes
|
||||
|
||||
type resolvedResourceWithTarget struct {
|
||||
verb Verb
|
||||
category ActionCategory
|
||||
sourceResource Resource
|
||||
sourceSelector SelectorFunc
|
||||
sourceExtractor ResourceIDsExtractor
|
||||
sourceIDs []string
|
||||
targetResource Resource
|
||||
targetSelector SelectorFunc
|
||||
targetExtractor ResourceIDsExtractor
|
||||
targetIDs []string
|
||||
parentChild bool
|
||||
}
|
||||
|
||||
func NewResolvedResourceWithTarget(
|
||||
verb Verb,
|
||||
category ActionCategory,
|
||||
sourceResource Resource,
|
||||
sourceExtractor ResourceIDsExtractor,
|
||||
sourceSelector SelectorFunc,
|
||||
targetResource Resource,
|
||||
targetExtractor ResourceIDsExtractor,
|
||||
targetSelector SelectorFunc,
|
||||
parentChild bool,
|
||||
ec ExtractorContext,
|
||||
) ResolvedResourceWithTargetResource {
|
||||
resolved := &resolvedResourceWithTarget{
|
||||
verb: verb,
|
||||
category: category,
|
||||
sourceResource: sourceResource,
|
||||
sourceSelector: sourceSelector,
|
||||
sourceExtractor: sourceExtractor,
|
||||
targetResource: targetResource,
|
||||
targetSelector: targetSelector,
|
||||
targetExtractor: targetExtractor,
|
||||
parentChild: parentChild,
|
||||
}
|
||||
resolved.fill(PhaseRequest, ec)
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
func (resolved *resolvedResourceWithTarget) fill(phase ExtractPhase, ec ExtractorContext) {
|
||||
if resolved.sourceExtractor.IsPhase(phase) {
|
||||
if ids, _ := resolved.sourceExtractor.Fn(ec); len(ids) > 0 {
|
||||
resolved.sourceIDs = ids
|
||||
}
|
||||
}
|
||||
if resolved.targetExtractor.IsPhase(phase) {
|
||||
if ids, _ := resolved.targetExtractor.Fn(ec); len(ids) > 0 {
|
||||
resolved.targetIDs = ids
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (resolved *resolvedResourceWithTarget) Verb() Verb {
|
||||
return resolved.verb
|
||||
}
|
||||
|
||||
func (resolved *resolvedResourceWithTarget) Category() ActionCategory {
|
||||
return resolved.category
|
||||
}
|
||||
|
||||
func (resolved *resolvedResourceWithTarget) SourceResource() Resource {
|
||||
return resolved.sourceResource
|
||||
}
|
||||
|
||||
// An empty id (when none resolved) means collection-level access.
|
||||
func (resolved *resolvedResourceWithTarget) SourceIDs() []string {
|
||||
if len(resolved.sourceIDs) == 0 {
|
||||
return []string{""}
|
||||
}
|
||||
|
||||
return resolved.sourceIDs
|
||||
}
|
||||
|
||||
func (resolved *resolvedResourceWithTarget) SourceSelector() SelectorFunc {
|
||||
return resolved.sourceSelector
|
||||
}
|
||||
|
||||
func (resolved *resolvedResourceWithTarget) TargetResource() Resource {
|
||||
return resolved.targetResource
|
||||
}
|
||||
|
||||
func (resolved *resolvedResourceWithTarget) TargetIDs() []string {
|
||||
if len(resolved.targetIDs) == 0 {
|
||||
return []string{""}
|
||||
}
|
||||
|
||||
return resolved.targetIDs
|
||||
}
|
||||
func (resolved *resolvedResourceWithTarget) TargetSelector() SelectorFunc {
|
||||
return resolved.targetSelector
|
||||
}
|
||||
|
||||
func (resolved *resolvedResourceWithTarget) IsParentChild() bool {
|
||||
return resolved.parentChild
|
||||
}
|
||||
|
||||
func (resolved *resolvedResourceWithTarget) ResolveResponse(ec ExtractorContext) {
|
||||
resolved.fill(PhaseResponse, ec)
|
||||
}
|
||||
|
||||
func (resolved *resolvedResourceWithTarget) hasResponsePhase() bool {
|
||||
return resolved.sourceExtractor.IsPhase(PhaseResponse) || resolved.targetExtractor.IsPhase(PhaseResponse)
|
||||
}
|
||||
@@ -1,15 +1,48 @@
|
||||
package coretypes
|
||||
|
||||
import "encoding/json"
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
const (
|
||||
WildCardSelectorString string = "*"
|
||||
)
|
||||
|
||||
var errCodeInvalidResourceID = errors.MustNewCode("invalid_resource_id")
|
||||
|
||||
var WildcardSelector SelectorFunc = func(_ context.Context, resource Resource, _ string, _ valuer.UUID) ([]Selector, error) {
|
||||
return []Selector{resource.Type().MustSelector(WildCardSelectorString)}, nil
|
||||
}
|
||||
|
||||
var IDSelector SelectorFunc = func(_ context.Context, resource Resource, id string, _ valuer.UUID) ([]Selector, error) {
|
||||
if id == "" {
|
||||
return nil, errors.Newf(
|
||||
errors.TypeInvalidInput,
|
||||
errCodeInvalidResourceID,
|
||||
"resource id is required for %s",
|
||||
resource.Kind().String(),
|
||||
)
|
||||
}
|
||||
|
||||
selector, err := resource.Type().Selector(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []Selector{selector, resource.Type().MustSelector(WildCardSelectorString)}, nil
|
||||
}
|
||||
|
||||
type Selector struct {
|
||||
val string
|
||||
}
|
||||
|
||||
// SelectorFunc maps a resolved id (+ its resource) to authz FGA selectors.
|
||||
type SelectorFunc func(ctx context.Context, resource Resource, id string, orgID valuer.UUID) ([]Selector, error)
|
||||
|
||||
func (selector *Selector) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(selector.val)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/perses/spec/go/common"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -110,7 +109,7 @@ type listedDashboardV2 struct {
|
||||
}
|
||||
|
||||
type listedDashboardV2Spec struct {
|
||||
Display *common.Display `json:"display,omitempty"`
|
||||
Display Display `json:"display,omitempty"`
|
||||
}
|
||||
|
||||
func newListedDashboardV2(v2 *DashboardV2) *listedDashboardV2 {
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/perses/spec/go/common"
|
||||
"k8s.io/apimachinery/pkg/util/validation"
|
||||
)
|
||||
|
||||
@@ -62,10 +61,17 @@ type DashboardV2 struct {
|
||||
Spec DashboardSpec `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
func (d *DashboardV2) CanUpdate() error {
|
||||
func (d *DashboardV2) ErrIfNotMutable() error {
|
||||
if d.Source == SourceIntegration {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "integration dashboards cannot be modified")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DashboardV2) ErrIfNotUpdatable() error {
|
||||
if err := d.ErrIfNotMutable(); err != nil {
|
||||
return err
|
||||
}
|
||||
if d.Locked {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot update a locked dashboard, please unlock the dashboard to update")
|
||||
}
|
||||
@@ -73,7 +79,7 @@ func (d *DashboardV2) CanUpdate() error {
|
||||
}
|
||||
|
||||
func (d *DashboardV2) Update(updatable UpdatableDashboardV2, updatedBy string, resolvedTags []*tagtypes.Tag) error {
|
||||
if err := d.CanUpdate(); err != nil {
|
||||
if err := d.ErrIfNotUpdatable(); err != nil {
|
||||
return err
|
||||
}
|
||||
if updatable.Name != d.Name {
|
||||
@@ -87,7 +93,7 @@ func (d *DashboardV2) Update(updatable UpdatableDashboardV2, updatedBy string, r
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DashboardV2) CanLockUnlock(isAdmin bool, updatedBy string) error {
|
||||
func (d *DashboardV2) ErrIfNotLockable(isAdmin bool, updatedBy string) error {
|
||||
if d.Source == SourceIntegration {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "integration dashboards cannot be locked or unlocked")
|
||||
}
|
||||
@@ -101,7 +107,7 @@ func (d *DashboardV2) CanLockUnlock(isAdmin bool, updatedBy string) error {
|
||||
}
|
||||
|
||||
func (d *DashboardV2) LockUnlock(lock bool, isAdmin bool, updatedBy string) error {
|
||||
if err := d.CanLockUnlock(isAdmin, updatedBy); err != nil {
|
||||
if err := d.ErrIfNotLockable(isAdmin, updatedBy); err != nil {
|
||||
return err
|
||||
}
|
||||
d.Locked = lock
|
||||
@@ -110,7 +116,7 @@ func (d *DashboardV2) LockUnlock(lock bool, isAdmin bool, updatedBy string) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DashboardV2) CanDelete() error {
|
||||
func (d *DashboardV2) ErrIfNotDeletable() error {
|
||||
if d.Locked {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot delete a locked dashboard, please unlock the dashboard to delete")
|
||||
}
|
||||
@@ -168,9 +174,6 @@ func (p *PostableDashboardV2) UnmarshalJSON(data []byte) error {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s", err.Error())
|
||||
}
|
||||
*p = PostableDashboardV2(tmp)
|
||||
if p.Spec.Display == nil {
|
||||
p.Spec.Display = &common.Display{}
|
||||
}
|
||||
if !p.GenerateName && p.Spec.Display.Name == "" {
|
||||
p.Spec.Display.Name = p.Name
|
||||
}
|
||||
@@ -197,7 +200,7 @@ func (p *PostableDashboardV2) validateName() error {
|
||||
if p.Name != "" {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "name must be empty when generateName is true, got %q", p.Name)
|
||||
}
|
||||
if p.Spec.Display == nil || p.Spec.Display.Name == "" {
|
||||
if p.Spec.Display.Name == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.display.name is required when generateName is true")
|
||||
}
|
||||
return nil
|
||||
@@ -341,9 +344,6 @@ func (u *UpdatableDashboardV2) UnmarshalJSON(data []byte) error {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s", err.Error())
|
||||
}
|
||||
*u = UpdatableDashboardV2(tmp)
|
||||
if u.Spec.Display == nil {
|
||||
u.Spec.Display = &common.Display{}
|
||||
}
|
||||
if u.Spec.Display.Name == "" {
|
||||
u.Spec.Display.Name = u.Name
|
||||
}
|
||||
|
||||
@@ -8,10 +8,9 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/perses/spec/go/common"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -166,7 +165,7 @@ func TestPostableDashboardV2NewDashboardV2(t *testing.T) {
|
||||
DashboardV2MetadataBase: DashboardV2MetadataBase{SchemaVersion: SchemaVersion},
|
||||
GenerateName: true,
|
||||
Spec: DashboardSpec{
|
||||
Display: &common.Display{Name: "My Dashboard!"},
|
||||
Display: Display{Name: "My Dashboard!"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -17,12 +17,12 @@ import (
|
||||
// occurrence is replaced with a typed SigNoz plugin whose OpenAPI schema is a
|
||||
// per-site discriminated oneOf.
|
||||
type DashboardSpec struct {
|
||||
Display *common.Display `json:"display,omitempty"`
|
||||
Display Display `json:"display" required:"true"`
|
||||
Datasources map[string]*DatasourceSpec `json:"datasources,omitempty"`
|
||||
Variables []Variable `json:"variables,omitempty"`
|
||||
Panels map[string]*Panel `json:"panels"`
|
||||
Layouts []Layout `json:"layouts"`
|
||||
Duration common.DurationString `json:"duration"`
|
||||
Variables []Variable `json:"variables" required:"true" nullable:"false"`
|
||||
Panels map[string]*Panel `json:"panels" required:"true" nullable:"false"`
|
||||
Layouts []Layout `json:"layouts" required:"true" nullable:"false"`
|
||||
Duration common.DurationString `json:"duration,omitempty"`
|
||||
RefreshInterval common.DurationString `json:"refreshInterval,omitempty"`
|
||||
Links []dashboard.Link `json:"links,omitempty"`
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ import (
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
type PanelPlugin struct {
|
||||
Kind PanelPluginKind `json:"kind"`
|
||||
Spec any `json:"spec"`
|
||||
Kind PanelPluginKind `json:"kind" required:"true"`
|
||||
Spec any `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
// PrepareJSONSchema marks the envelope with x-signoz-discriminator;
|
||||
@@ -81,8 +81,8 @@ func (v PanelPluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
type QueryPlugin struct {
|
||||
Kind QueryPluginKind `json:"kind"`
|
||||
Spec any `json:"spec"`
|
||||
Kind QueryPluginKind `json:"kind" required:"true"`
|
||||
Spec any `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
func (QueryPlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
@@ -139,8 +139,8 @@ func (v QueryPluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
type VariablePlugin struct {
|
||||
Kind VariablePluginKind `json:"kind"`
|
||||
Spec any `json:"spec"`
|
||||
Kind VariablePluginKind `json:"kind" required:"true"`
|
||||
Spec any `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
func (VariablePlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
@@ -191,8 +191,8 @@ func (v VariablePluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
type DatasourcePlugin struct {
|
||||
Kind DatasourcePluginKind `json:"kind"`
|
||||
Spec any `json:"spec"`
|
||||
Kind DatasourcePluginKind `json:"kind" required:"true"`
|
||||
Spec any `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
func (DatasourcePlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
|
||||
@@ -13,6 +13,11 @@ import (
|
||||
"github.com/swaggest/jsonschema-go"
|
||||
)
|
||||
|
||||
type Display struct {
|
||||
Name string `json:"name" required:"true"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Datasource
|
||||
// ══════════════════════════════════════════════
|
||||
@@ -28,8 +33,8 @@ type DatasourceSpec struct {
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
type Panel struct {
|
||||
Kind PanelKind `json:"kind"`
|
||||
Spec PanelSpec `json:"spec"`
|
||||
Kind PanelKind `json:"kind" required:"true"`
|
||||
Spec PanelSpec `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
// PanelKind is the panel envelope discriminator. Perses leaves it a free
|
||||
@@ -54,10 +59,10 @@ func (k *PanelKind) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
type PanelSpec struct {
|
||||
Display *dashboard.PanelDisplay `json:"display,omitempty"`
|
||||
Plugin PanelPlugin `json:"plugin"`
|
||||
Queries []Query `json:"queries,omitempty"`
|
||||
Links []dashboard.Link `json:"links,omitempty"`
|
||||
Display Display `json:"display" required:"true"`
|
||||
Plugin PanelPlugin `json:"plugin" required:"true"`
|
||||
Queries []Query `json:"queries" required:"true"`
|
||||
Links []dashboard.Link `json:"links,omitempty"`
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
@@ -65,13 +70,13 @@ type PanelSpec struct {
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
type Query struct {
|
||||
Kind qb.RequestType `json:"kind"`
|
||||
Spec QuerySpec `json:"spec"`
|
||||
Kind qb.RequestType `json:"kind" required:"true"`
|
||||
Spec QuerySpec `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
type QuerySpec struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Plugin QueryPlugin `json:"plugin"`
|
||||
Plugin QueryPlugin `json:"plugin" required:"true"`
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
@@ -82,8 +87,8 @@ type QuerySpec struct {
|
||||
// *dashboard.TextVariableSpec by UnmarshalJSON based on Kind. The schema is a
|
||||
// discriminated oneOf (see JSONSchemaOneOf).
|
||||
type Variable struct {
|
||||
Kind variable.Kind `json:"kind"`
|
||||
Spec any `json:"spec"`
|
||||
Kind variable.Kind `json:"kind" required:"true"`
|
||||
Spec any `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
func (Variable) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
@@ -138,7 +143,7 @@ func (v VariableEnvelope[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
// ListVariableSpec mirrors dashboard.ListVariableSpec (variable.ListSpec
|
||||
// fields + Name) but with a typed VariablePlugin replacing common.Plugin.
|
||||
type ListVariableSpec struct {
|
||||
Display *variable.Display `json:"display,omitempty"`
|
||||
Display Display `json:"display" required:"true"`
|
||||
DefaultValue *variable.DefaultValue `json:"defaultValue,omitempty"`
|
||||
AllowAllValue bool `json:"allowAllValue"`
|
||||
AllowMultiple bool `json:"allowMultiple"`
|
||||
@@ -158,8 +163,8 @@ type ListVariableSpec struct {
|
||||
// based on Kind. No plugin is involved, so we reuse the Perses spec types as
|
||||
// leaf imports.
|
||||
type Layout struct {
|
||||
Kind dashboard.LayoutKind `json:"kind"`
|
||||
Spec any `json:"spec"`
|
||||
Kind dashboard.LayoutKind `json:"kind" required:"true"`
|
||||
Spec any `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
// layoutSpecs is the layout sum type factory. Perses only defines
|
||||
|
||||
@@ -46,9 +46,9 @@ type Store interface {
|
||||
// Returns ErrCodePinnedDashboardLimitHit when the user is at MaxPinnedDashboardsPerUser.
|
||||
PinForUser(ctx context.Context, preference *UserDashboardPreference) error
|
||||
|
||||
UnpinForUser(ctx context.Context, userID valuer.UUID, dashboardID valuer.UUID) error
|
||||
UnpinForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, dashboardID valuer.UUID) error
|
||||
|
||||
DeletePreferencesForDashboard(ctx context.Context, dashboardID valuer.UUID) error
|
||||
DeletePreferencesForDashboard(ctx context.Context, orgID valuer.UUID, dashboardID valuer.UUID) error
|
||||
|
||||
DeletePreferencesForUser(ctx context.Context, userID valuer.UUID) error
|
||||
DeletePreferencesForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
@@ -14,15 +17,20 @@ var ErrCodePinnedDashboardLimitHit = errors.MustNewCode("pinned_dashboard_limit_
|
||||
type UserDashboardPreference struct {
|
||||
bun.BaseModel `bun:"table:user_dashboard_preference,alias:user_dashboard_preference"`
|
||||
|
||||
UserID valuer.UUID `bun:"user_id,pk,type:text"`
|
||||
DashboardID valuer.UUID `bun:"dashboard_id,pk,type:text"`
|
||||
types.Identifiable
|
||||
types.TimeAuditable
|
||||
UserID valuer.UUID `bun:"user_id,type:text"`
|
||||
DashboardID valuer.UUID `bun:"dashboard_id,type:text"`
|
||||
IsPinned bool `bun:"is_pinned,notnull,default:false"`
|
||||
}
|
||||
|
||||
func NewUserDashboardPreference(userID, dashboardID valuer.UUID) *UserDashboardPreference {
|
||||
now := time.Now()
|
||||
return &UserDashboardPreference{
|
||||
UserID: userID,
|
||||
DashboardID: dashboardID,
|
||||
IsPinned: true,
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
TimeAuditable: types.TimeAuditable{CreatedAt: now, UpdatedAt: now},
|
||||
UserID: userID,
|
||||
DashboardID: dashboardID,
|
||||
IsPinned: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import uuid
|
||||
from collections.abc import Callable, Iterator
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
@@ -8,96 +8,7 @@ import requests
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.types import Operation, SigNoz
|
||||
|
||||
# The v2 dashboard API. Request shape (current):
|
||||
# {"schemaVersion": "v6", "name": "<dns-1123-label>",
|
||||
# "spec": {"display": {"name": "<human name>"}},
|
||||
# "tags": [{"key": "...", "value": "..."}]}
|
||||
# `name` is a DNS-1123 label identifier and is immutable after create;
|
||||
# `spec.display.name` is the human-facing title used for name-sort/name-filter.
|
||||
|
||||
_BASE = "/api/v2/dashboards"
|
||||
_TIMEOUT = 5
|
||||
|
||||
# This file's tests tag their dashboards with a `suite` marker so list queries
|
||||
# can be scoped server-side. Each test gets its own unique marker (the
|
||||
# suite_marker fixture) so tests stay isolated from each other and from leftovers
|
||||
# in the reused session DB.
|
||||
_SUITE_PREFIX = "dashboardv2"
|
||||
|
||||
|
||||
def _headers(token: str) -> dict:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _url(signoz: SigNoz, path: str = "") -> str:
|
||||
return signoz.self.host_configs["8080"].get(f"{_BASE}{path}")
|
||||
|
||||
|
||||
def _create(signoz: SigNoz, token: str, body: dict) -> requests.Response:
|
||||
return requests.post(_url(signoz), json=body, headers=_headers(token), timeout=_TIMEOUT)
|
||||
|
||||
|
||||
def _get(signoz: SigNoz, token: str, dashboard_id: str) -> requests.Response:
|
||||
return requests.get(_url(signoz, f"/{dashboard_id}"), headers=_headers(token), timeout=_TIMEOUT)
|
||||
|
||||
|
||||
# The tests exercise the per-user list (carries pin state); the pure list lives
|
||||
# at GET /api/v2/dashboards.
|
||||
def _list(signoz: SigNoz, token: str, **params: object) -> requests.Response:
|
||||
url = signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards")
|
||||
return requests.get(
|
||||
url,
|
||||
params={k: v for k, v in params.items() if v is not None},
|
||||
headers=_headers(token),
|
||||
timeout=_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
# The pure, user-independent list — no pin join, no pinned field.
|
||||
def _list_pure(signoz: SigNoz, token: str, **params: object) -> requests.Response:
|
||||
return requests.get(
|
||||
_url(signoz),
|
||||
params={k: v for k, v in params.items() if v is not None},
|
||||
headers=_headers(token),
|
||||
timeout=_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
def _update(signoz: SigNoz, token: str, dashboard_id: str, body: dict) -> requests.Response:
|
||||
return requests.put(
|
||||
_url(signoz, f"/{dashboard_id}"),
|
||||
json=body,
|
||||
headers=_headers(token),
|
||||
timeout=_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
def _delete(signoz: SigNoz, token: str, dashboard_id: str) -> requests.Response:
|
||||
return requests.delete(_url(signoz, f"/{dashboard_id}"), headers=_headers(token), timeout=_TIMEOUT)
|
||||
|
||||
|
||||
def _lock(signoz: SigNoz, token: str, dashboard_id: str, lock: bool) -> requests.Response:
|
||||
method = requests.put if lock else requests.delete
|
||||
return method(
|
||||
_url(signoz, f"/{dashboard_id}/lock"),
|
||||
headers=_headers(token),
|
||||
timeout=_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
def _pin(signoz: SigNoz, token: str, dashboard_id: str, pin: bool) -> requests.Response:
|
||||
method = requests.put if pin else requests.delete
|
||||
url = signoz.self.host_configs["8080"].get(f"/api/v2/users/me/dashboards/{dashboard_id}/pins")
|
||||
return method(url, headers=_headers(token), timeout=_TIMEOUT)
|
||||
|
||||
|
||||
def _minimal_body(name: str, display: str, tags: list[dict] | None = None) -> dict:
|
||||
return {
|
||||
"schemaVersion": "v6",
|
||||
"name": name,
|
||||
"spec": {"display": {"name": display}},
|
||||
"tags": tags or [],
|
||||
}
|
||||
BASE_URL = "/api/v2/dashboards"
|
||||
|
||||
|
||||
# ─── failure cases (create no dashboards) ────────────────────────────────────
|
||||
@@ -110,7 +21,12 @@ def test_create_rejects_wrong_schema_version(
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = _create(signoz, token, {})
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json={},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
body = response.json()
|
||||
@@ -126,7 +42,12 @@ def test_create_rejects_missing_name(
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = _create(signoz, token, {"schemaVersion": "v6"})
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json={"schemaVersion": "v6"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
body = response.json()
|
||||
@@ -141,7 +62,17 @@ def test_create_rejects_non_dns_name(
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = _create(signoz, token, _minimal_body(name="Not A Label", display="Not A Label"))
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": "Not A Label",
|
||||
"spec": {"display": {"name": "Not A Label"}},
|
||||
"tags": [],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response.json()["error"]["code"] == "dashboard_invalid_input"
|
||||
@@ -154,9 +85,18 @@ def test_create_rejects_unknown_field(
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
body = _minimal_body("rejects-unknown", "Rejects Unknown")
|
||||
body["unknownfield"] = "boom"
|
||||
response = _create(signoz, token, body)
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": "rejects-unknown",
|
||||
"spec": {"display": {"name": "Rejects Unknown"}},
|
||||
"tags": [],
|
||||
"unknownfield": "boom",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response.json()["error"]["code"] == "dashboard_invalid_input"
|
||||
@@ -170,8 +110,17 @@ def test_create_rejects_reserved_tag_key(
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
body = _minimal_body("rejects-reserved", "Rejects Reserved", [{"key": "source", "value": "x"}])
|
||||
response = _create(signoz, token, body)
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": "rejects-reserved",
|
||||
"spec": {"display": {"name": "Rejects Reserved"}},
|
||||
"tags": [{"key": "source", "value": "x"}],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response.json()["error"]["code"] == "dashboard_invalid_input"
|
||||
@@ -185,7 +134,17 @@ def test_create_rejects_too_many_tags(
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
tags = [{"key": f"k{i}", "value": "v"} for i in range(11)]
|
||||
response = _create(signoz, token, _minimal_body("too-many-tags", "Too Many", tags))
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": "too-many-tags",
|
||||
"spec": {"display": {"name": "Too Many"}},
|
||||
"tags": tags,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response.json()["error"]["code"] == "dashboard_invalid_input"
|
||||
@@ -208,7 +167,12 @@ def test_list_rejects_invalid_params(
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = _list(signoz, token, **params)
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards"),
|
||||
params=params,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response.json()["error"]["code"] == "dashboard_list_invalid"
|
||||
@@ -221,7 +185,11 @@ def test_get_rejects_malformed_id(
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = _get(signoz, token, "not-a-uuid")
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/not-a-uuid"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
@@ -233,7 +201,11 @@ def test_get_missing_dashboard_returns_not_found(
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = _get(signoz, token, str(uuid.uuid4()))
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{uuid.uuid4()}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
|
||||
@@ -245,7 +217,11 @@ def test_delete_missing_dashboard_returns_not_found(
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = _delete(signoz, token, str(uuid.uuid4()))
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{uuid.uuid4()}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
|
||||
@@ -257,58 +233,44 @@ def test_pin_missing_dashboard_returns_not_found(
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = _pin(signoz, token, str(uuid.uuid4()), pin=True)
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/users/me/dashboards/{uuid.uuid4()}/pins"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
|
||||
|
||||
# ─── lifecycle ───────────────────────────────────────────────────────────────
|
||||
# A single end-to-end flow through create → get → list/filter/sort → pin →
|
||||
# update → lock → delete. Every fixture dashboard carries the shared suite marker
|
||||
# tag so list queries can be scoped server-side, isolating this test from any
|
||||
# other dashboards sharing the session DB.
|
||||
|
||||
|
||||
def _display_names(body: dict) -> list[str]:
|
||||
return [d["spec"]["display"]["name"] for d in body["data"]["dashboards"]]
|
||||
|
||||
|
||||
def _delete_suite(signoz: SigNoz, token: str, suite_filter: str) -> None:
|
||||
response = _list(signoz, token, query=suite_filter, limit=200)
|
||||
if response.status_code != HTTPStatus.OK:
|
||||
return
|
||||
for dashboard in response.json()["data"]["dashboards"]:
|
||||
_delete(signoz, token, dashboard["id"])
|
||||
|
||||
|
||||
@pytest.fixture(name="suite_marker")
|
||||
def _suite_marker(
|
||||
signoz: SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> Iterator[tuple[dict, str]]:
|
||||
"""Yields a per-test unique suite (tag, filter) and deletes its dashboards on teardown.
|
||||
Unique per test so the tests stay isolated from each other and from reused-DB leftovers."""
|
||||
value = f"{_SUITE_PREFIX}-{uuid.uuid4().hex[:8]}"
|
||||
suite_tag = {"key": "suite", "value": value}
|
||||
suite_filter = f"suite = '{value}'"
|
||||
yield suite_tag, suite_filter
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
_delete_suite(signoz, token, suite_filter)
|
||||
# update → lock → delete.
|
||||
|
||||
|
||||
def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-statements
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
suite_marker: tuple[dict, str],
|
||||
):
|
||||
suite_tag, suite_filter = suite_marker
|
||||
|
||||
def _scoped(query: str) -> str:
|
||||
return f"({query}) AND {suite_filter}"
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# The dashboard test files share this package's DB and it's reused across
|
||||
# runs, so start from a clean slate: delete every dashboard (which also clears
|
||||
# pins via the delete cascade). This test then owns the whole dashboard space
|
||||
# and asserts on global counts.
|
||||
existing = requests.get(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
params={"limit": 200},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
).json()["data"]["dashboards"]
|
||||
for dashboard in existing:
|
||||
requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard['id']}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
dashboard_requests = [
|
||||
(
|
||||
"lc-alpha",
|
||||
@@ -353,18 +315,38 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
|
||||
# ── stage 1: create ──────────────────────────────────────────────────────
|
||||
ids: dict[str, str] = {}
|
||||
for name, display, tags in dashboard_requests:
|
||||
response = _create(signoz, token, _minimal_body(name, display, [suite_tag, *tags]))
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": name,
|
||||
"spec": {"display": {"name": display}},
|
||||
"tags": tags,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
ids[name] = response.json()["data"]["id"]
|
||||
|
||||
# TODO: re-enable once the dashboard name unique index lands — creating a
|
||||
# second dashboard with an existing name should conflict (409). Until the
|
||||
# index exists, duplicate names are silently allowed.
|
||||
# response = _create(signoz, token, _minimal_body("lc-alpha", "Alpha Dupe"))
|
||||
# response = requests.post(
|
||||
# signoz.self.host_configs["8080"].get(_BASE),
|
||||
# json={"schemaVersion": "v6", "name": "lc-alpha",
|
||||
# "spec": {"display": {"name": "Alpha Dupe"}}, "tags": []},
|
||||
# headers={"Authorization": f"Bearer {token}"},
|
||||
# timeout=5,
|
||||
# )
|
||||
# assert response.status_code == HTTPStatus.CONFLICT, response.text
|
||||
|
||||
# ── stage 2: get one and verify the round-tripped shape ──────────────────
|
||||
response = _get(signoz, token, ids["lc-alpha"])
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{ids['lc-alpha']}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
alpha = response.json()["data"]
|
||||
assert alpha["id"] == ids["lc-alpha"]
|
||||
@@ -375,12 +357,17 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
|
||||
assert alpha["locked"] is False
|
||||
assert {"key": "team", "value": "pulse"} in alpha["tags"]
|
||||
|
||||
# ── stage 3: list everything in the suite ────────────────────────────────
|
||||
response = _list(signoz, token, query=suite_filter, limit=200)
|
||||
# ── stage 3: list everything ─────────────────────────────────────────────
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards"),
|
||||
params={"limit": 200},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
body = response.json()
|
||||
assert body["data"]["total"] == 6
|
||||
assert set(_display_names(body)) == {
|
||||
assert {d["spec"]["display"]["name"] for d in body["data"]["dashboards"]} == {
|
||||
"Alpha Overview",
|
||||
"Beta Overview",
|
||||
"Gamma Storage",
|
||||
@@ -490,13 +477,23 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
|
||||
),
|
||||
]
|
||||
for query, expected in cases:
|
||||
response = _list(signoz, token, query=_scoped(query), limit=200)
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards"),
|
||||
params={"query": query, "limit": 200},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert set(_display_names(response.json())) == expected, query
|
||||
assert {d["spec"]["display"]["name"] for d in response.json()["data"]["dashboards"]} == expected, query
|
||||
|
||||
# ── stage 5: name sort honours order ─────────────────────────────────────
|
||||
response = _list(signoz, token, query=suite_filter, sort="name", order="asc", limit=200)
|
||||
assert _display_names(response.json()) == [
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards"),
|
||||
params={"sort": "name", "order": "asc", "limit": 200},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert [d["spec"]["display"]["name"] for d in response.json()["data"]["dashboards"]] == [
|
||||
"Alpha Overview",
|
||||
"Beta Overview",
|
||||
"Delta Storage",
|
||||
@@ -504,8 +501,13 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
|
||||
"Gamma Storage",
|
||||
"Zeta Overview",
|
||||
]
|
||||
response = _list(signoz, token, query=suite_filter, sort="name", order="desc", limit=200)
|
||||
assert _display_names(response.json()) == [
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards"),
|
||||
params={"sort": "name", "order": "desc", "limit": 200},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert [d["spec"]["display"]["name"] for d in response.json()["data"]["dashboards"]] == [
|
||||
"Zeta Overview",
|
||||
"Gamma Storage",
|
||||
"Epsilon Metrics",
|
||||
@@ -515,8 +517,20 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
|
||||
]
|
||||
|
||||
# ── stage 6: pinning floats a dashboard to the top of any ordering ───────
|
||||
assert _pin(signoz, token, ids["lc-gamma"], pin=True).status_code == HTTPStatus.NO_CONTENT
|
||||
response = _list(signoz, token, query=suite_filter, sort="name", order="asc", limit=200)
|
||||
assert (
|
||||
requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/users/me/dashboards/{ids['lc-gamma']}/pins"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
).status_code
|
||||
== HTTPStatus.NO_CONTENT
|
||||
)
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards"),
|
||||
params={"sort": "name", "order": "asc", "limit": 200},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
dashboards = response.json()["data"]["dashboards"]
|
||||
assert dashboards[0]["name"] == "lc-gamma"
|
||||
assert dashboards[0]["pinned"] is True
|
||||
@@ -524,8 +538,13 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
|
||||
|
||||
# the pure list is user-independent: the same pin neither reorders it (gamma
|
||||
# stays in natural name order, not floated to the top) nor adds a pinned field.
|
||||
response = _list_pure(signoz, token, query=suite_filter, sort="name", order="asc", limit=200)
|
||||
assert _display_names(response.json()) == [
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
params={"sort": "name", "order": "asc", "limit": 200},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert [d["spec"]["display"]["name"] for d in response.json()["data"]["dashboards"]] == [
|
||||
"Alpha Overview",
|
||||
"Beta Overview",
|
||||
"Delta Storage",
|
||||
@@ -536,9 +555,21 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
|
||||
assert all("pinned" not in d for d in response.json()["data"]["dashboards"])
|
||||
|
||||
# ── stage 7: unpinning restores the natural ordering ─────────────────────
|
||||
assert _pin(signoz, token, ids["lc-gamma"], pin=False).status_code == HTTPStatus.NO_CONTENT
|
||||
response = _list(signoz, token, query=suite_filter, sort="name", order="asc", limit=200)
|
||||
assert _display_names(response.json()) == [
|
||||
assert (
|
||||
requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/users/me/dashboards/{ids['lc-gamma']}/pins"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
).status_code
|
||||
== HTTPStatus.NO_CONTENT
|
||||
)
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards"),
|
||||
params={"sort": "name", "order": "asc", "limit": 200},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert [d["spec"]["display"]["name"] for d in response.json()["data"]["dashboards"]] == [
|
||||
"Alpha Overview",
|
||||
"Beta Overview",
|
||||
"Delta Storage",
|
||||
@@ -548,39 +579,95 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
|
||||
]
|
||||
|
||||
# ── stage 8: update mutates the spec but keeps the immutable name ────────
|
||||
update_body = _minimal_body(
|
||||
"lc-alpha",
|
||||
"Alpha Overview",
|
||||
[
|
||||
suite_tag,
|
||||
update_body = {
|
||||
"schemaVersion": "v6",
|
||||
"name": "lc-alpha",
|
||||
"spec": {"display": {"name": "Alpha Overview"}},
|
||||
"tags": [
|
||||
{"key": "team", "value": "pulse"},
|
||||
{"key": "env", "value": "prod"},
|
||||
],
|
||||
)
|
||||
}
|
||||
update_body["spec"]["display"]["description"] = "now with a description"
|
||||
response = _update(signoz, token, ids["lc-alpha"], update_body)
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{ids['lc-alpha']}"),
|
||||
json=update_body,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
response = _get(signoz, token, ids["lc-alpha"])
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{ids['lc-alpha']}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.json()["data"]["spec"]["display"]["description"] == "now with a description"
|
||||
|
||||
# ── stage 9: a locked dashboard rejects updates until unlocked ───────────
|
||||
assert _lock(signoz, token, ids["lc-beta"], lock=True).status_code == HTTPStatus.NO_CONTENT
|
||||
beta_body = _minimal_body(
|
||||
"lc-beta",
|
||||
"Beta Overview",
|
||||
[suite_tag, {"key": "team", "value": "pulse"}, {"key": "env", "value": "dev"}],
|
||||
assert (
|
||||
requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{ids['lc-beta']}/lock"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
).status_code
|
||||
== HTTPStatus.NO_CONTENT
|
||||
)
|
||||
beta_body = {
|
||||
"schemaVersion": "v6",
|
||||
"name": "lc-beta",
|
||||
"spec": {"display": {"name": "Beta Overview"}},
|
||||
"tags": [{"key": "team", "value": "pulse"}, {"key": "env", "value": "dev"}],
|
||||
}
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{ids['lc-beta']}"),
|
||||
json=beta_body,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
response = _update(signoz, token, ids["lc-beta"], beta_body)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert _lock(signoz, token, ids["lc-beta"], lock=False).status_code == HTTPStatus.NO_CONTENT
|
||||
assert _update(signoz, token, ids["lc-beta"], beta_body).status_code == HTTPStatus.OK
|
||||
assert (
|
||||
requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{ids['lc-beta']}/lock"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
).status_code
|
||||
== HTTPStatus.NO_CONTENT
|
||||
)
|
||||
assert (
|
||||
requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{ids['lc-beta']}"),
|
||||
json=beta_body,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
).status_code
|
||||
== HTTPStatus.OK
|
||||
)
|
||||
|
||||
# ── stage 10: delete removes the dashboard from get and list ─────────────
|
||||
assert _delete(signoz, token, ids["lc-gamma"]).status_code == HTTPStatus.NO_CONTENT
|
||||
assert _get(signoz, token, ids["lc-gamma"]).status_code == HTTPStatus.NOT_FOUND
|
||||
response = _list(signoz, token, query=suite_filter, limit=200)
|
||||
assert (
|
||||
requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{ids['lc-gamma']}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
).status_code
|
||||
== HTTPStatus.NO_CONTENT
|
||||
)
|
||||
assert (
|
||||
requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{ids['lc-gamma']}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
).status_code
|
||||
== HTTPStatus.NOT_FOUND
|
||||
)
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards"),
|
||||
params={"limit": 200},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.json()["data"]["total"] == 5
|
||||
assert set(_display_names(response.json())) == {
|
||||
assert {d["spec"]["display"]["name"] for d in response.json()["data"]["dashboards"]} == {
|
||||
"Alpha Overview",
|
||||
"Beta Overview",
|
||||
"Delta Storage",
|
||||
@@ -593,34 +680,89 @@ def test_dashboard_v2_pin_limit(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
suite_marker: tuple[dict, str],
|
||||
):
|
||||
suite_tag, _ = suite_marker
|
||||
max_pinned = 10
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Wipe the dashboard space (see lifecycle) so the per-user pin cap this test
|
||||
# asserts against starts empty — deleting dashboards clears their pins.
|
||||
existing = requests.get(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
params={"limit": 200},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
).json()["data"]["dashboards"]
|
||||
for dashboard in existing:
|
||||
requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard['id']}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
ids: list[str] = []
|
||||
for i in range(max_pinned + 1):
|
||||
response = _create(signoz, token, _minimal_body(f"pl-{i}", f"Pin Limit {i}", [suite_tag]))
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": f"pl-{i}",
|
||||
"spec": {"display": {"name": f"Pin Limit {i}"}},
|
||||
"tags": [],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
ids.append(response.json()["data"]["id"])
|
||||
|
||||
# pinning up to the limit succeeds
|
||||
for dashboard_id in ids[:max_pinned]:
|
||||
assert _pin(signoz, token, dashboard_id, pin=True).status_code == HTTPStatus.NO_CONTENT
|
||||
assert (
|
||||
requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/users/me/dashboards/{dashboard_id}/pins"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
).status_code
|
||||
== HTTPStatus.NO_CONTENT
|
||||
)
|
||||
|
||||
# re-pinning an already-pinned dashboard is an idempotent no-op, even at the limit
|
||||
assert _pin(signoz, token, ids[0], pin=True).status_code == HTTPStatus.NO_CONTENT
|
||||
assert (
|
||||
requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/users/me/dashboards/{ids[0]}/pins"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
).status_code
|
||||
== HTTPStatus.NO_CONTENT
|
||||
)
|
||||
|
||||
# the 11th distinct pin is rejected with the typed limit error
|
||||
response = _pin(signoz, token, ids[max_pinned], pin=True)
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/users/me/dashboards/{ids[max_pinned]}/pins"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CONFLICT, response.text
|
||||
assert response.json()["error"]["code"] == "pinned_dashboard_limit_hit"
|
||||
|
||||
# unpinning frees a slot, so the previously-rejected dashboard can now be pinned
|
||||
assert _pin(signoz, token, ids[0], pin=False).status_code == HTTPStatus.NO_CONTENT
|
||||
assert _pin(signoz, token, ids[max_pinned], pin=True).status_code == HTTPStatus.NO_CONTENT
|
||||
assert (
|
||||
requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/users/me/dashboards/{ids[0]}/pins"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
).status_code
|
||||
== HTTPStatus.NO_CONTENT
|
||||
)
|
||||
assert (
|
||||
requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/users/me/dashboards/{ids[max_pinned]}/pins"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
).status_code
|
||||
== HTTPStatus.NO_CONTENT
|
||||
)
|
||||
|
||||
|
||||
# ─── LIKE escaping ───────────────────────────────────────────────────────────
|
||||
@@ -638,12 +780,24 @@ def test_dashboard_v2_like_escaping(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
suite_marker: tuple[dict, str],
|
||||
):
|
||||
suite_tag, suite_filter = suite_marker
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Wipe the dashboard space (see lifecycle) so the filter assertions run
|
||||
# against only the dashboards this test creates.
|
||||
existing = requests.get(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
params={"limit": 200},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
).json()["data"]["dashboards"]
|
||||
for dashboard in existing:
|
||||
requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard['id']}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
dashboard_requests = [
|
||||
("esc-pct", "Cost 50% Report"),
|
||||
("esc-pct-plain", "Cost 5000 Report"),
|
||||
@@ -651,7 +805,17 @@ def test_dashboard_v2_like_escaping(
|
||||
("esc-underscore-wild", "userXid panel"),
|
||||
]
|
||||
for name, display in dashboard_requests:
|
||||
response = _create(signoz, token, _minimal_body(name, display, [suite_tag]))
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": name,
|
||||
"spec": {"display": {"name": display}},
|
||||
"tags": [],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
|
||||
cases = [
|
||||
@@ -685,11 +849,11 @@ def test_dashboard_v2_like_escaping(
|
||||
),
|
||||
]
|
||||
for query, expected in cases:
|
||||
response = _list(
|
||||
signoz,
|
||||
token,
|
||||
query=f"({query}) AND {suite_filter}",
|
||||
limit=200,
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards"),
|
||||
params={"query": query, "limit": 200},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert set(_display_names(response.json())) == expected, query
|
||||
assert {d["spec"]["display"]["name"] for d in response.json()["data"]["dashboards"]} == expected, query
|
||||
|
||||
Reference in New Issue
Block a user