Compare commits

..

1 Commits

Author SHA1 Message Date
Gaurav Tewari
1dd887f7fd feat: add config page initial draft 2026-06-11 20:30:32 +05:30
109 changed files with 3211 additions and 4872 deletions

View File

@@ -2436,6 +2436,13 @@ components:
url:
type: string
type: object
DashboardPanelDisplay:
properties:
description:
type: string
name:
type: string
type: object
DashboardTextVariableSpec:
properties:
constant:
@@ -2563,12 +2570,13 @@ components:
$ref: '#/components/schemas/DashboardtypesDatasourceSpec'
type: object
display:
$ref: '#/components/schemas/DashboardtypesDisplay'
$ref: '#/components/schemas/CommonDisplay'
duration:
type: string
layouts:
items:
$ref: '#/components/schemas/DashboardtypesLayout'
nullable: true
type: array
links:
items:
@@ -2577,6 +2585,7 @@ components:
panels:
additionalProperties:
$ref: '#/components/schemas/DashboardtypesPanel'
nullable: true
type: object
refreshInterval:
type: string
@@ -2584,11 +2593,6 @@ components:
items:
$ref: '#/components/schemas/DashboardtypesVariable'
type: array
required:
- display
- variables
- panels
- layouts
type: object
DashboardtypesDatasourcePlugin:
discriminator:
@@ -2624,15 +2628,6 @@ components:
plugin:
$ref: '#/components/schemas/DashboardtypesDatasourcePlugin'
type: object
DashboardtypesDisplay:
properties:
description:
type: string
name:
type: string
required:
- name
type: object
DashboardtypesDynamicVariableSpec:
properties:
name:
@@ -2827,7 +2822,7 @@ components:
defaultValue:
$ref: '#/components/schemas/VariableDefaultValue'
display:
$ref: '#/components/schemas/DashboardtypesDisplay'
$ref: '#/components/schemas/VariableDisplay'
name:
type: string
plugin:
@@ -2835,8 +2830,6 @@ components:
sort:
nullable: true
type: string
required:
- display
type: object
DashboardtypesListableDashboardForUserV2:
properties:
@@ -2964,7 +2957,7 @@ components:
DashboardtypesListedDashboardV2Spec:
properties:
display:
$ref: '#/components/schemas/DashboardtypesDisplay'
$ref: '#/components/schemas/CommonDisplay'
type: object
DashboardtypesNumberPanelSpec:
properties:
@@ -2984,9 +2977,6 @@ components:
$ref: '#/components/schemas/DashboardtypesPanelKind'
spec:
$ref: '#/components/schemas/DashboardtypesPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelFormatting:
properties:
@@ -3116,7 +3106,7 @@ components:
DashboardtypesPanelSpec:
properties:
display:
$ref: '#/components/schemas/DashboardtypesDisplay'
$ref: '#/components/schemas/DashboardPanelDisplay'
links:
items:
$ref: '#/components/schemas/DashboardLink'
@@ -3126,12 +3116,7 @@ components:
queries:
items:
$ref: '#/components/schemas/DashboardtypesQuery'
nullable: true
type: array
required:
- display
- plugin
- queries
type: object
DashboardtypesPatchOp:
enum:
@@ -3200,9 +3185,6 @@ components:
$ref: '#/components/schemas/Querybuildertypesv5RequestType'
spec:
$ref: '#/components/schemas/DashboardtypesQuerySpec'
required:
- kind
- spec
type: object
DashboardtypesQueryPlugin:
discriminator:
@@ -3309,8 +3291,6 @@ components:
type: string
plugin:
$ref: '#/components/schemas/DashboardtypesQueryPlugin'
required:
- plugin
type: object
DashboardtypesQueryVariableSpec:
properties:

View File

@@ -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, orgID valuer.UUID, userID valuer.UUID, id valuer.UUID) error {
return module.pkgDashboardModule.UnpinV2(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) DeletePreferencesForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error {
return module.pkgDashboardModule.DeletePreferencesForUser(ctx, orgID, userID)
func (module *module) DeletePreferencesForUser(ctx context.Context, userID valuer.UUID) error {
return module.pkgDashboardModule.DeletePreferencesForUser(ctx, userID)
}
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.Dashboard, error) {

View File

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

View File

@@ -323,3 +323,10 @@ export const AIAssistantPage = Loadable(
/* webpackChunkName: "AI Assistant Page" */ 'pages/AIAssistantPage/AIAssistantPage'
),
);
export const LLMObservabilityModelPricingPage = Loadable(
() =>
import(
/* webpackChunkName: "LLM Observability Model Pricing Page" */ 'pages/LLMObservabilityModelPricing'
),
);

View File

@@ -22,6 +22,7 @@ import {
IntegrationsDetailsPage,
LicensePage,
ListAllALertsPage,
LLMObservabilityModelPricingPage,
LiveLogs,
Login,
Logs,
@@ -507,6 +508,13 @@ const routes: AppRoutes[] = [
key: 'AI_ASSISTANT',
isPrivate: true,
},
{
path: ROUTES.LLM_OBSERVABILITY_MODEL_PRICING,
exact: true,
component: LLMObservabilityModelPricingPage,
key: 'LLM_OBSERVABILITY_MODEL_PRICING',
isPrivate: true,
},
];
export const SUPPORT_ROUTE: AppRoutes = {

View File

@@ -3156,6 +3156,17 @@ export interface DashboardLinkDTO {
url?: string;
}
export interface DashboardPanelDisplayDTO {
/**
* @type string
*/
description?: string;
/**
* @type string
*/
name?: string;
}
export interface VariableDisplayDTO {
/**
* @type string
@@ -3881,17 +3892,6 @@ 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,36 +4440,42 @@ 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: DashboardtypesDisplayDTO;
display?: DashboardPanelDisplayDTO;
/**
* @type array
*/
links?: DashboardLinkDTO[];
plugin: DashboardtypesPanelPluginDTO;
plugin?: DashboardtypesPanelPluginDTO;
/**
* @type array,null
* @type array
*/
queries: DashboardtypesQueryDTO[] | null;
queries?: DashboardtypesQueryDTO[];
}
export interface DashboardtypesPanelDTO {
kind: DashboardtypesPanelKindDTO;
spec: DashboardtypesPanelSpecDTO;
kind?: DashboardtypesPanelKindDTO;
spec?: DashboardtypesPanelSpecDTO;
}
export type DashboardtypesDashboardSpecDTOPanels = {
export type DashboardtypesDashboardSpecDTOPanelsAnyOf = {
[key: string]: DashboardtypesPanelDTO;
};
/**
* @nullable
*/
export type DashboardtypesDashboardSpecDTOPanels =
DashboardtypesDashboardSpecDTOPanelsAnyOf | null;
export enum DashboardtypesLayoutEnvelopeGithubComPersesSpecGoDashboardGridLayoutSpecDTOKind {
Grid = 'Grid',
}
@@ -4566,7 +4572,7 @@ export interface DashboardtypesListVariableSpecDTO {
*/
customAllValue?: string;
defaultValue?: VariableDefaultValueDTO;
display: DashboardtypesDisplayDTO;
display?: VariableDisplayDTO;
/**
* @type string
*/
@@ -4608,23 +4614,23 @@ export interface DashboardtypesDashboardSpecDTO {
* @type object
*/
datasources?: DashboardtypesDashboardSpecDTODatasources;
display: DashboardtypesDisplayDTO;
display?: CommonDisplayDTO;
/**
* @type string
*/
duration?: string;
/**
* @type array
* @type array,null
*/
layouts: DashboardtypesLayoutDTO[];
layouts?: DashboardtypesLayoutDTO[] | null;
/**
* @type array
*/
links?: DashboardLinkDTO[];
/**
* @type object
* @type object,null
*/
panels: DashboardtypesDashboardSpecDTOPanels;
panels?: DashboardtypesDashboardSpecDTOPanels;
/**
* @type string
*/
@@ -4632,7 +4638,7 @@ export interface DashboardtypesDashboardSpecDTO {
/**
* @type array
*/
variables: DashboardtypesVariableDTO[];
variables?: DashboardtypesVariableDTO[];
}
export enum DashboardtypesDatasourcePluginKindDTO {
@@ -4756,7 +4762,7 @@ export enum DashboardtypesListSortDTO {
name = 'name',
}
export interface DashboardtypesListedDashboardV2SpecDTO {
display?: DashboardtypesDisplayDTO;
display?: CommonDisplayDTO;
}
export interface DashboardtypesListedDashboardForUserV2DTO {

View File

@@ -91,6 +91,7 @@ const ROUTES = {
AI_ASSISTANT_BASE: '/ai-assistant',
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
MCP_SERVER: '/settings/mcp-server',
LLM_OBSERVABILITY_MODEL_PRICING: '/llm-observability/model-pricing',
} as const;
export default ROUTES;

View File

@@ -40,31 +40,13 @@ type SpeechRecognitionConstructor = new () => ISpeechRecognition;
// ── Vendor-prefix shim for Safari / older browsers ────────────────────────────
// 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
);
} catch {
return null;
}
}
const SpeechRecognitionAPI: SpeechRecognitionConstructor | null =
resolveSpeechRecognitionAPI();
typeof window !== 'undefined'
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
((window as any).SpeechRecognition ??
(window as any).webkitSpeechRecognition ??
null)
: null;
export type SpeechRecognitionError =
| 'not-supported'

View File

@@ -0,0 +1,140 @@
.llm-observability-model-pricing {
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px 32px;
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
&__title {
h1 {
margin: 0;
font-size: 22px;
font-weight: 600;
}
p {
margin: 4px 0 0;
color: var(--bg-vanilla-400);
font-size: 13px;
}
}
&__actions {
display: flex;
gap: 8px;
}
}
.page-tabs {
.ant-tabs-nav {
margin-bottom: 0;
}
}
.filters-bar {
display: flex;
gap: 12px;
align-items: center;
&__search {
flex: 1;
max-width: 360px;
}
&__source,
&__currency {
min-width: 160px;
}
&__add {
margin-left: auto;
}
}
.page-error {
padding: 12px 16px;
border-radius: 4px;
background: rgba(255, 90, 90, 0.08);
color: var(--bg-cherry-400);
font-size: 13px;
}
.page-footer {
color: var(--bg-vanilla-400);
font-size: 12px;
}
}
.model-costs-table {
.model-cell {
display: flex;
flex-direction: column;
gap: 2px;
&__name {
font-weight: 600;
}
&__canonical-id {
color: var(--bg-vanilla-400);
font-family: var(--code-font-family, monospace);
font-size: 12px;
}
}
.price-cell {
font-family: var(--code-font-family, monospace);
}
.extra-buckets {
display: flex;
flex-wrap: wrap;
gap: 6px;
&__chip {
display: inline-flex;
align-items: center;
gap: 6px;
margin: 0;
}
&__key {
font-family: var(--code-font-family, monospace);
font-size: 12px;
}
&__price {
font-family: var(--code-font-family, monospace);
font-weight: 600;
}
}
.muted {
color: var(--bg-vanilla-400);
}
.source-badge {
margin: 0;
&--auto {
background: rgba(78, 116, 248, 0.12);
color: var(--bg-robin-400);
border-color: rgba(78, 116, 248, 0.24);
}
&--override {
background: rgba(245, 175, 25, 0.12);
color: var(--bg-amber-400);
border-color: rgba(245, 175, 25, 0.24);
}
}
&__row--selected {
background: rgba(78, 116, 248, 0.06);
}
}

View File

@@ -0,0 +1,140 @@
import { useMemo, useState } from 'react';
import { Button, Input, Select, Tabs } from 'antd';
import { Plus, Search } from '@signozhq/icons';
import { useListLLMPricingRules } from 'api/generated/services/llmpricingrules';
import ModelCostDrawer from './ModelCostDrawer';
import ModelCostsTable from './ModelCostsTable';
import { useModelCostDrawer } from './useModelCostDrawer';
import { filterRules, type PricingRule, type SourceFilter } from './utils';
import './LLMObservabilityModelPricing.styles.scss';
const SOURCE_OPTIONS: { value: SourceFilter; label: string }[] = [
{ value: 'all', label: 'Source: All' },
{ value: 'auto', label: 'Auto-populated' },
{ value: 'override', label: 'User override' },
];
const CURRENCY_OPTIONS = [
{ value: 'USD', label: 'USD' },
{ value: 'EUR', label: 'EUR', disabled: true },
{ value: 'INR', label: 'INR', disabled: true },
];
const PAGE_SIZE = 100;
function LLMObservabilityModelPricing(): JSX.Element {
const [search, setSearch] = useState<string>('');
const [source, setSource] = useState<SourceFilter>('all');
const [currency, setCurrency] = useState<string>('USD');
const { data, isLoading, isError } = useListLLMPricingRules({
offset: 0,
limit: PAGE_SIZE,
});
const rules: PricingRule[] = useMemo(() => data?.data?.items || [], [data]);
const filteredRules = useMemo(
() => filterRules(rules, search, source),
[rules, search, source],
);
const drawer = useModelCostDrawer();
return (
<div
className="llm-observability-model-pricing"
data-testid="llm-observability-model-pricing-page"
>
<header className="page-header">
<div className="page-header__title">
<h1>Configuration</h1>
<p>Model pricing and cost estimation settings</p>
</div>
</header>
<Tabs
className="page-tabs"
defaultActiveKey="model-costs"
items={[
{ key: 'model-costs', label: 'Model costs' },
{
key: 'unpriced-models',
label: 'Unpriced models',
disabled: true,
},
]}
/>
<div className="filters-bar">
<Input
className="filters-bar__search"
placeholder="Search by model or provider…"
prefix={<Search size={14} />}
value={search}
onChange={(event): void => setSearch(event.target.value)}
data-testid="search-input"
allowClear
/>
<Select<SourceFilter>
className="filters-bar__source"
value={source}
onChange={(value): void => setSource(value)}
options={SOURCE_OPTIONS}
data-testid="source-select"
/>
<Select<string>
className="filters-bar__currency"
value={currency}
onChange={(value): void => setCurrency(value)}
options={CURRENCY_OPTIONS}
data-testid="currency-select"
/>
<Button
type="primary"
className="filters-bar__add"
icon={<Plus size={14} />}
onClick={(): void => drawer.openForAdd()}
data-testid="add-model-cost-btn"
>
Add model cost
</Button>
</div>
{isError && (
<div className="page-error" role="alert">
Failed to load pricing rules. Please try again.
</div>
)}
<ModelCostsTable
rules={filteredRules}
isLoading={isLoading}
selectedRuleId={drawer.selectedRuleId}
onEdit={drawer.openForEdit}
/>
<footer className="page-footer">
Showing {filteredRules.length} model{filteredRules.length === 1 ? '' : 's'}
{' · '}All prices per 1M tokens (USD)
</footer>
<ModelCostDrawer
isOpen={drawer.isOpen}
mode={drawer.mode}
draft={drawer.draft}
setDraft={drawer.setDraft}
onClose={drawer.close}
onSave={drawer.save}
onDelete={drawer.deleteRule}
isSaving={drawer.isSaving}
isDeleting={drawer.isDeleting}
saveError={drawer.saveError}
/>
</div>
);
}
export default LLMObservabilityModelPricing;

View File

@@ -0,0 +1,256 @@
.model-cost-drawer {
.ant-drawer-body {
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 18px;
}
&__title {
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
p {
margin: 4px 0 0;
color: var(--bg-vanilla-400);
font-size: 12px;
}
}
&__footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-top: 1px solid var(--bg-slate-300);
&-right {
display: flex;
gap: 8px;
margin-left: auto;
}
}
.drawer-section {
display: flex;
flex-direction: column;
gap: 6px;
label,
.field-label {
font-size: 12px;
font-weight: 600;
color: var(--bg-vanilla-200);
}
.help {
margin: 0;
font-size: 11px;
}
}
.muted {
color: var(--bg-vanilla-400);
}
.required {
color: var(--bg-cherry-400);
}
.full-width {
width: 100%;
}
.drawer-surface {
padding: 14px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--bg-slate-300);
&__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
h4 {
margin: 0;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--bg-vanilla-300);
}
}
}
.managed-label {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--bg-vanilla-400);
}
.pattern-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
min-height: 28px;
}
.pattern-chip {
display: inline-flex;
align-items: center;
gap: 4px;
&__remove {
background: transparent;
border: none;
padding: 0;
margin-left: 2px;
cursor: pointer;
color: inherit;
display: inline-flex;
align-items: center;
&:hover {
color: var(--bg-cherry-400);
}
}
}
.pattern-add {
display: flex;
gap: 6px;
}
.pattern-test {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
margin-top: 6px;
&__result {
font-size: 12px;
&--match {
color: var(--bg-forest-400);
}
&--no-match {
color: var(--bg-cherry-400);
}
}
}
.source-radio-group {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.source-radio {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
border-radius: 4px;
border: 1px solid transparent;
background: rgba(255, 255, 255, 0.02);
margin: 0;
width: 100%;
&__title {
font-weight: 600;
font-size: 13px;
}
&__desc {
font-size: 12px;
color: var(--bg-vanilla-400);
}
&.ant-radio-wrapper-checked.source-radio--auto {
background: rgba(78, 116, 248, 0.1);
border-color: rgba(78, 116, 248, 0.3);
}
&.ant-radio-wrapper-checked.source-radio--override {
background: rgba(245, 175, 25, 0.1);
border-color: rgba(245, 175, 25, 0.3);
}
}
.reset-confirm {
margin-top: 12px;
padding: 12px;
border-radius: 4px;
background: rgba(78, 116, 248, 0.06);
border: 1px solid rgba(78, 116, 248, 0.2);
p {
margin: 0 0 10px;
font-size: 12px;
}
&__actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
}
.pricing-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.pricing-field {
display: flex;
flex-direction: column;
gap: 4px;
.ant-input-number {
width: 100%;
}
}
.cache-mode-field {
margin-top: 10px;
}
.extras-divider {
margin-top: 14px;
margin-bottom: 6px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--bg-vanilla-400);
}
.cost-preview {
&__line {
font-family: var(--code-font-family, monospace);
font-size: 12px;
strong {
margin-left: 4px;
}
}
}
.drawer-error {
padding: 10px 12px;
border-radius: 4px;
background: rgba(255, 90, 90, 0.08);
color: var(--bg-cherry-400);
font-size: 12px;
}
}

View File

@@ -0,0 +1,413 @@
import { useMemo, useState } from 'react';
import { Button, Drawer, Input, InputNumber, Select, Tooltip } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { Lock, Trash2, X } from '@signozhq/icons';
import { LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO } from 'api/generated/services/sigNoz.schemas';
import {
CACHE_MODE_OPTIONS,
computeCostPreview,
matchesAnyPattern,
PROVIDER_OPTIONS,
validateDraft,
type DrawerDraft,
type DrawerMode,
} from './drawerUtils';
import './ModelCostDrawer.styles.scss';
interface ModelCostDrawerProps {
isOpen: boolean;
mode: DrawerMode;
draft: DrawerDraft;
setDraft: (next: DrawerDraft) => void;
onClose: () => void;
onSave: () => void;
onDelete: () => void;
isSaving: boolean;
isDeleting: boolean;
saveError: string | null;
}
function ModelCostDrawer({
isOpen,
mode,
draft,
setDraft,
onClose,
onSave,
onDelete,
isSaving,
isDeleting,
saveError,
}: ModelCostDrawerProps): JSX.Element {
const [patternInput, setPatternInput] = useState<string>('');
const [testInput, setTestInput] = useState<string>('');
const [showResetConfirm, setShowResetConfirm] = useState<boolean>(false);
const isReadOnly = !draft.isOverride;
const validation = validateDraft(draft, mode);
const preview = useMemo(() => computeCostPreview(draft), [draft]);
const testMatch = useMemo(
() => (testInput ? matchesAnyPattern(testInput, draft.patterns) : null),
[testInput, draft.patterns],
);
const update = (patch: Partial<DrawerDraft>): void => {
setDraft({ ...draft, ...patch });
};
const updatePricing = (patch: Partial<DrawerDraft['pricing']>): void => {
setDraft({ ...draft, pricing: { ...draft.pricing, ...patch } });
};
const addPattern = (): void => {
const next = patternInput.trim();
if (!next || draft.patterns.includes(next)) {
setPatternInput('');
return;
}
update({ patterns: [...draft.patterns, next] });
setPatternInput('');
};
const removePattern = (pattern: string): void => {
update({ patterns: draft.patterns.filter((p) => p !== pattern) });
};
const handleSourceChange = (value: 'auto' | 'override'): void => {
if (value === 'auto' && draft.isOverride) {
setShowResetConfirm(true);
return;
}
if (value === 'override' && !draft.isOverride) {
update({ isOverride: true });
}
};
const confirmReset = (): void => {
update({ isOverride: false });
setShowResetConfirm(false);
};
const hasCacheBucket =
draft.pricing.cacheRead !== null || draft.pricing.cacheWrite !== null;
return (
<Drawer
width={520}
open={isOpen}
onClose={onClose}
placement="right"
className="model-cost-drawer"
destroyOnClose
closeIcon={<X size={16} />}
title={
<div className="model-cost-drawer__title">
<h3>{mode === 'edit' ? 'Edit model cost' : 'Add model cost'}</h3>
<p>Pricing computes gen_ai.estimated_total_cost at ingest.</p>
</div>
}
footer={
<div className="model-cost-drawer__footer">
{mode === 'edit' && (
<Button
danger
type="text"
icon={<Trash2 size={14} />}
onClick={onDelete}
loading={isDeleting}
data-testid="drawer-delete-btn"
>
Delete
</Button>
)}
<div className="model-cost-drawer__footer-right">
<Button onClick={onClose} data-testid="drawer-cancel-btn">
Cancel
</Button>
<Tooltip title={validation.ok ? '' : validation.message}>
<Button
type="primary"
onClick={onSave}
loading={isSaving}
disabled={!validation.ok}
data-testid="drawer-save-btn"
>
Save
</Button>
</Tooltip>
</div>
</div>
}
>
<div className="drawer-section">
<label htmlFor="billing-model-id">Billing model ID</label>
<Input
id="billing-model-id"
placeholder="e.g. openai:gpt-4o"
value={draft.modelName}
disabled={mode === 'edit'}
onChange={(e): void => update({ modelName: e.target.value })}
data-testid="drawer-model-id-input"
/>
</div>
<div className="drawer-section">
<label htmlFor="provider-select">Provider</label>
<Select
id="provider-select"
value={draft.provider}
onChange={(value): void => update({ provider: value })}
options={PROVIDER_OPTIONS}
disabled={isReadOnly}
className="full-width"
data-testid="drawer-provider-select"
/>
</div>
<div className="drawer-section">
<span className="field-label">
Model name patterns <span className="muted">(prefix match)</span>
</span>
<div className="pattern-chips">
{draft.patterns.map((pattern) => (
<Badge
key={pattern}
color="forest"
variant="outline"
className="pattern-chip"
>
{pattern}*
{!isReadOnly && (
<button
type="button"
aria-label={`Remove pattern ${pattern}`}
className="pattern-chip__remove"
onClick={(): void => removePattern(pattern)}
>
<X size={10} />
</button>
)}
</Badge>
))}
</div>
{!isReadOnly && (
<div className="pattern-add">
<Input
placeholder="Add pattern…"
value={patternInput}
onChange={(e): void => setPatternInput(e.target.value)}
onPressEnter={addPattern}
data-testid="drawer-pattern-input"
/>
<Button onClick={addPattern} data-testid="drawer-pattern-add-btn">
+ Add
</Button>
</div>
)}
<p className="muted help">
Each pattern uses prefix matching against gen_ai.request.model.
</p>
{!isReadOnly && (
<div className="pattern-test">
<Input
placeholder="Test: type a model name…"
value={testInput}
onChange={(e): void => setTestInput(e.target.value)}
data-testid="drawer-pattern-test-input"
/>
{testInput && (
<span
className={`pattern-test__result ${
testMatch
? 'pattern-test__result--match'
: 'pattern-test__result--no-match'
}`}
data-testid="drawer-pattern-test-result"
>
{testMatch ? `Matched: ${testMatch}*` : 'No matching pattern'}
</span>
)}
</div>
)}
</div>
<div className="drawer-section drawer-surface">
<div className="drawer-surface__head">
<h4>Source</h4>
{isReadOnly && (
<span className="managed-label">
<Lock size={12} />
Managed by SigNoz
</span>
)}
</div>
<RadioGroup
value={draft.isOverride ? 'override' : 'auto'}
onChange={(value): void =>
handleSourceChange(value as 'auto' | 'override')
}
className="source-radio-group"
>
<RadioGroupItem
value="auto"
className="source-radio source-radio--auto"
testId="drawer-source-auto"
>
<div className="source-radio__title">Auto-populated</div>
<div className="source-radio__desc">
Default pricing from SigNoz. Updated automatically.
</div>
</RadioGroupItem>
<RadioGroupItem
value="override"
className="source-radio source-radio--override"
testId="drawer-source-override"
>
<div className="source-radio__title">User override</div>
<div className="source-radio__desc">
Custom pricing. Takes precedence.
</div>
</RadioGroupItem>
</RadioGroup>
{showResetConfirm && (
<div
className="reset-confirm"
role="dialog"
aria-label="Reset to default pricing"
>
<p>Reset to default pricing? Custom values will be discarded.</p>
<div className="reset-confirm__actions">
<Button
onClick={(): void => setShowResetConfirm(false)}
data-testid="drawer-reset-keep-btn"
>
Keep
</Button>
<Button
type="primary"
onClick={confirmReset}
data-testid="drawer-reset-confirm-btn"
>
Reset
</Button>
</div>
</div>
)}
</div>
<div className="drawer-section drawer-surface">
<div className="drawer-surface__head">
<h4>Pricing (per 1M tokens, USD)</h4>
{isReadOnly && (
<span className="managed-label">
<Lock size={12} />
Read-only
</span>
)}
</div>
<div className="pricing-grid">
<div className="pricing-field">
<label htmlFor="input-cost">
Input cost <span className="required">*</span>
</label>
<InputNumber
id="input-cost"
min={0}
step={0.01}
value={draft.pricing.input}
onChange={(v): void => updatePricing({ input: Number(v) || 0 })}
disabled={isReadOnly}
data-testid="drawer-input-cost"
/>
</div>
<div className="pricing-field">
<label htmlFor="output-cost">
Output cost <span className="required">*</span>
</label>
<InputNumber
id="output-cost"
min={0}
step={0.01}
value={draft.pricing.output}
onChange={(v): void => updatePricing({ output: Number(v) || 0 })}
disabled={isReadOnly}
data-testid="drawer-output-cost"
/>
</div>
</div>
<div className="extras-divider">Extra pricing buckets</div>
<div className="pricing-grid">
<div className="pricing-field">
<label htmlFor="cache-read">cache_read</label>
<InputNumber
id="cache-read"
min={0}
step={0.01}
value={draft.pricing.cacheRead ?? undefined}
placeholder="—"
onChange={(v): void =>
updatePricing({ cacheRead: v === null ? null : Number(v) })
}
disabled={isReadOnly}
data-testid="drawer-cache-read-cost"
/>
</div>
<div className="pricing-field">
<label htmlFor="cache-write">cache_write</label>
<InputNumber
id="cache-write"
min={0}
step={0.01}
value={draft.pricing.cacheWrite ?? undefined}
placeholder="—"
onChange={(v): void =>
updatePricing({ cacheWrite: v === null ? null : Number(v) })
}
disabled={isReadOnly}
data-testid="drawer-cache-write-cost"
/>
</div>
</div>
{hasCacheBucket && (
<div className="pricing-field cache-mode-field">
<label htmlFor="cache-mode">Cache mode</label>
<Select
id="cache-mode"
value={draft.pricing.cacheMode}
options={CACHE_MODE_OPTIONS}
onChange={(v): void => updatePricing({ cacheMode: v as CacheModeDTO })}
disabled={isReadOnly}
className="full-width"
data-testid="drawer-cache-mode"
/>
</div>
)}
<p className="muted help">Image tokens may be priced differently (v2).</p>
</div>
<div className="drawer-section drawer-surface cost-preview">
<div className="drawer-surface__head">
<h4>Cost preview</h4>
</div>
<div className="cost-preview__line">
{preview.breakdown.map((part) => part.label).join(' + ')} ={' '}
<strong> ${preview.total.toFixed(4)}</strong>
</div>
<p className="muted help">
Write-time attribution. Changes only affect new spans.
</p>
</div>
{saveError && (
<div className="drawer-error" role="alert">
{saveError}
</div>
)}
</Drawer>
);
}
export default ModelCostDrawer;

View File

@@ -0,0 +1,146 @@
import { Button, Table, type TableColumnsType } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { ChevronDown } from '@signozhq/icons';
import {
formatPricePerMillion,
getCanonicalId,
getExtraBuckets,
getRelativeLastSeen,
getSourceLabel,
type PricingRule,
} from './utils';
interface ModelCostsTableProps {
rules: PricingRule[];
isLoading: boolean;
selectedRuleId: string | null;
onEdit: (rule: PricingRule) => void;
}
function ModelCostsTable({
rules,
isLoading,
selectedRuleId,
onEdit,
}: ModelCostsTableProps): JSX.Element {
const columns: TableColumnsType<PricingRule> = [
{
title: 'Model',
dataIndex: 'modelName',
key: 'model',
render: (_value, rule): JSX.Element => (
<div className="model-cell">
<div className="model-cell__name">{rule.modelName}</div>
<div className="model-cell__canonical-id">{getCanonicalId(rule)}</div>
</div>
),
},
{
title: 'Provider',
dataIndex: 'provider',
key: 'provider',
},
{
title: 'Input / 1M',
key: 'input',
render: (_value, rule): JSX.Element => (
<span className="price-cell">
{formatPricePerMillion(rule.pricing?.input)}
</span>
),
},
{
title: 'Output / 1M',
key: 'output',
render: (_value, rule): JSX.Element => (
<span className="price-cell">
{formatPricePerMillion(rule.pricing?.output)}
</span>
),
},
{
title: 'Extra buckets',
key: 'extra-buckets',
render: (_value, rule): JSX.Element => {
const buckets = getExtraBuckets(rule);
if (buckets.length === 0) {
return <span className="muted"></span>;
}
return (
<div className="extra-buckets">
{buckets.map((bucket) => (
<Badge
key={bucket.key}
color="vanilla"
variant="outline"
className="extra-buckets__chip"
>
<span className="extra-buckets__key">{bucket.key}</span>
<span className="extra-buckets__price">
{formatPricePerMillion(bucket.pricePerMillion)}
</span>
</Badge>
))}
</div>
);
},
},
{
title: 'Source',
dataIndex: 'isOverride',
key: 'source',
render: (_value, rule): JSX.Element => {
const label = getSourceLabel(rule);
return (
<Badge
color={rule.isOverride ? 'amber' : 'robin'}
variant="outline"
className="source-badge"
data-testid={`source-badge-${rule.id}`}
>
{label}
</Badge>
);
},
},
{
title: 'Last seen',
key: 'last-seen',
render: (_value, rule): string => getRelativeLastSeen(rule),
},
{
title: '',
key: 'actions',
width: 80,
render: (_value, rule): JSX.Element => (
<Button
type="text"
size="small"
data-testid={`edit-rule-${rule.id}`}
onClick={(): void => onEdit(rule)}
>
Edit
<ChevronDown size={14} />
</Button>
),
},
];
return (
<Table<PricingRule>
className="model-costs-table"
rowKey="id"
columns={columns}
dataSource={rules}
loading={isLoading}
pagination={false}
rowClassName={(row): string =>
row.id === selectedRuleId ? 'model-costs-table__row--selected' : ''
}
data-testid="model-costs-table"
/>
);
}
export default ModelCostsTable;

View File

@@ -0,0 +1,108 @@
import {
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
type LlmpricingruletypesLLMPricingRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import { rest, server } from 'mocks-server/server';
import { fireEvent, render, screen } from 'tests/test-utils';
import LLMObservabilityModelPricing from '../LLMObservabilityModelPricing';
const ENDPOINT = '*/api/v1/llm_pricing_rules';
const mockRules: LlmpricingruletypesLLMPricingRuleDTO[] = [
{
id: 'rule-gpt4o',
orgId: 'org-1',
modelName: 'gpt-4o',
provider: 'OpenAI',
modelPattern: ['gpt-4o'],
isOverride: false,
enabled: true,
unit: UnitDTO.per_million_tokens,
pricing: { input: 15, output: 60 },
},
{
id: 'rule-llama',
orgId: 'org-1',
modelName: 'llama-3.1-70b',
provider: 'Self-hosted',
modelPattern: ['llama-3.1'],
isOverride: true,
enabled: true,
unit: UnitDTO.per_million_tokens,
pricing: { input: 0, output: 0 },
},
];
describe('LLMObservabilityModelPricing', () => {
beforeEach(() => {
server.use(
rest.get(ENDPOINT, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
items: mockRules,
limit: 100,
offset: 0,
total: mockRules.length,
},
}),
),
),
);
});
afterEach(() => {
server.resetHandlers();
});
it('renders the page header and both rules', async () => {
render(<LLMObservabilityModelPricing />);
await screen.findByText('gpt-4o');
expect(screen.getByText('Configuration')).toBeInTheDocument();
expect(screen.getByText('llama-3.1-70b')).toBeInTheDocument();
expect(screen.getByText('openai:gpt-4o')).toBeInTheDocument();
});
it('filters rules by the search input', async () => {
render(<LLMObservabilityModelPricing />);
await screen.findByText('gpt-4o');
fireEvent.change(screen.getByTestId('search-input'), {
target: { value: 'llama' },
});
expect(screen.queryByText('gpt-4o')).not.toBeInTheDocument();
expect(screen.getByText('llama-3.1-70b')).toBeInTheDocument();
});
it('opens the drawer in Add mode when the Add button is clicked', async () => {
render(<LLMObservabilityModelPricing />);
await screen.findByText('gpt-4o');
fireEvent.click(screen.getByTestId('add-model-cost-btn'));
const input = (await screen.findByTestId(
'drawer-model-id-input',
)) as HTMLInputElement;
expect(input).toBeInTheDocument();
expect(input.value).toBe('');
});
it('opens the drawer in Edit mode with prefilled values when a row Edit is clicked', async () => {
render(<LLMObservabilityModelPricing />);
await screen.findByText('gpt-4o');
fireEvent.click(screen.getByTestId('edit-rule-rule-gpt4o'));
const input = (await screen.findByTestId(
'drawer-model-id-input',
)) as HTMLInputElement;
expect(input).toBeInTheDocument();
expect(input.value).toBe('gpt-4o');
});
});

View File

@@ -0,0 +1,141 @@
import { useState } from 'react';
import userEvent from '@testing-library/user-event';
import { fireEvent, render, screen } from 'tests/test-utils';
import { EMPTY_DRAFT, type DrawerDraft } from '../drawerUtils';
import ModelCostDrawer from '../ModelCostDrawer';
interface HarnessProps {
initialDraft?: DrawerDraft;
mode?: 'add' | 'edit';
onSave?: () => void;
onDelete?: () => void;
}
function Harness({
initialDraft = { ...EMPTY_DRAFT, modelName: 'gpt-4o' },
mode = 'add',
onSave = jest.fn(),
onDelete = jest.fn(),
}: HarnessProps): JSX.Element {
const [draft, setDraft] = useState<DrawerDraft>(initialDraft);
return (
<ModelCostDrawer
isOpen
mode={mode}
draft={draft}
setDraft={setDraft}
onClose={jest.fn()}
onSave={onSave}
onDelete={onDelete}
isSaving={false}
isDeleting={false}
saveError={null}
/>
);
}
describe('ModelCostDrawer', () => {
it('adds a pattern chip when the user types and presses Enter', () => {
render(<Harness />);
fireEvent.change(screen.getByTestId('drawer-pattern-input'), {
target: { value: 'gpt-4o-mini' },
});
fireEvent.keyDown(screen.getByTestId('drawer-pattern-input'), {
key: 'Enter',
code: 'Enter',
});
expect(screen.getByText('gpt-4o-mini*')).toBeInTheDocument();
});
it('shows a match result when the test input matches an existing pattern', () => {
render(
<Harness
initialDraft={{
...EMPTY_DRAFT,
modelName: 'gpt-4o',
patterns: ['gpt-4o'],
isOverride: true,
}}
/>,
);
fireEvent.change(screen.getByTestId('drawer-pattern-test-input'), {
target: { value: 'gpt-4o-2024-08-06' },
});
expect(screen.getByTestId('drawer-pattern-test-result')).toHaveTextContent(
/matched: gpt-4o\*/i,
);
});
it('shows a no-match result when nothing matches', () => {
render(
<Harness
initialDraft={{
...EMPTY_DRAFT,
modelName: 'gpt-4o',
patterns: ['gpt-4o'],
isOverride: true,
}}
/>,
);
fireEvent.change(screen.getByTestId('drawer-pattern-test-input'), {
target: { value: 'claude' },
});
expect(screen.getByTestId('drawer-pattern-test-result')).toHaveTextContent(
/no matching pattern/i,
);
});
it('shows a reset confirmation when switching from Override to Auto', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<Harness
initialDraft={{
...EMPTY_DRAFT,
modelName: 'gpt-4o',
isOverride: true,
}}
/>,
);
await user.click(screen.getByTestId('drawer-source-auto'));
expect(screen.getByTestId('drawer-reset-confirm-btn')).toBeInTheDocument();
expect(screen.getByTestId('drawer-reset-keep-btn')).toBeInTheDocument();
});
it('hides the Delete action in Add mode', () => {
render(<Harness mode="add" />);
expect(screen.queryByTestId('drawer-delete-btn')).not.toBeInTheDocument();
});
it('shows the Delete action in Edit mode', () => {
render(
<Harness
mode="edit"
initialDraft={{
...EMPTY_DRAFT,
id: 'rule-1',
modelName: 'gpt-4o',
isOverride: true,
}}
/>,
);
expect(screen.getByTestId('drawer-delete-btn')).toBeInTheDocument();
});
it('calls onSave when the Save button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onSave = jest.fn();
render(<Harness onSave={onSave} />);
await user.click(screen.getByTestId('drawer-save-btn'));
expect(onSave).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,160 @@
import {
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
buildPricingPayload,
buildRulePayload,
computeCostPreview,
draftFromRule,
EMPTY_DRAFT,
matchesAnyPattern,
validateDraft,
type DrawerDraft,
} from '../drawerUtils';
import type { PricingRule } from '../utils';
const makeRule = (overrides: Partial<PricingRule> = {}): PricingRule => ({
id: 'rule-1',
orgId: 'org-1',
modelName: 'gpt-4o',
provider: 'OpenAI',
modelPattern: ['gpt-4o'],
isOverride: false,
enabled: true,
unit: UnitDTO.per_million_tokens,
pricing: { input: 15, output: 60 },
...overrides,
});
describe('drawerUtils', () => {
describe('draftFromRule', () => {
it('maps a rule to a draft with cache values when present', () => {
const rule = makeRule({
pricing: {
input: 3,
output: 15,
cache: {
mode: CacheModeDTO.additive,
read: 0.3,
write: 3.75,
},
},
});
const draft = draftFromRule(rule);
expect(draft.modelName).toBe('gpt-4o');
expect(draft.pricing.input).toBe(3);
expect(draft.pricing.cacheRead).toBe(0.3);
expect(draft.pricing.cacheWrite).toBe(3.75);
expect(draft.pricing.cacheMode).toBe(CacheModeDTO.additive);
});
it('falls back to defaults when cache is missing', () => {
const draft = draftFromRule(makeRule());
expect(draft.pricing.cacheRead).toBeNull();
expect(draft.pricing.cacheWrite).toBeNull();
expect(draft.pricing.cacheMode).toBe(CacheModeDTO.unknown);
});
});
describe('buildPricingPayload', () => {
it('omits the cache block when no cache values are set', () => {
const payload = buildPricingPayload(EMPTY_DRAFT);
expect(payload.cache).toBeUndefined();
});
it('includes only the cache values that are > 0', () => {
const draft: DrawerDraft = {
...EMPTY_DRAFT,
pricing: {
...EMPTY_DRAFT.pricing,
cacheRead: 1.5,
cacheWrite: 0,
cacheMode: CacheModeDTO.subtract,
},
};
const payload = buildPricingPayload(draft);
expect(payload.cache?.read).toBe(1.5);
expect(payload.cache?.write).toBeUndefined();
expect(payload.cache?.mode).toBe(CacheModeDTO.subtract);
});
});
describe('buildRulePayload', () => {
it('uses the modelName as a default pattern when no patterns are supplied', () => {
const draft: DrawerDraft = {
...EMPTY_DRAFT,
modelName: 'gpt-4o',
patterns: [],
provider: 'OpenAI',
};
const payload = buildRulePayload(draft);
expect(payload.modelPattern).toStrictEqual(['gpt-4o']);
expect(payload.unit).toBe(UnitDTO.per_million_tokens);
expect(payload.enabled).toBe(true);
});
it('omits id and sourceId for an Add draft', () => {
const payload = buildRulePayload(EMPTY_DRAFT);
expect(payload.id).toBeUndefined();
expect(payload.sourceId).toBeUndefined();
});
});
describe('validateDraft', () => {
it('requires a model name in Add mode', () => {
const result = validateDraft(EMPTY_DRAFT, 'add');
expect(result.ok).toBe(false);
expect(result.message).toMatch(/billing model id/i);
});
it('rejects negative pricing', () => {
const draft: DrawerDraft = {
...EMPTY_DRAFT,
modelName: 'gpt-4o',
pricing: { ...EMPTY_DRAFT.pricing, input: -1 },
};
expect(validateDraft(draft, 'add').ok).toBe(false);
});
it('accepts a valid Add draft', () => {
const draft: DrawerDraft = {
...EMPTY_DRAFT,
modelName: 'gpt-4o',
pricing: { ...EMPTY_DRAFT.pricing, input: 1, output: 2 },
};
expect(validateDraft(draft, 'add').ok).toBe(true);
});
});
describe('matchesAnyPattern', () => {
it('returns the matching prefix pattern, case-insensitive', () => {
expect(matchesAnyPattern('GPT-4o-2024', ['gpt-4o'])).toBe('gpt-4o');
});
it('returns null when nothing matches', () => {
expect(matchesAnyPattern('claude', ['gpt-4o'])).toBeNull();
});
});
describe('computeCostPreview', () => {
it('adds cache buckets when they are set', () => {
const draft: DrawerDraft = {
...EMPTY_DRAFT,
pricing: {
...EMPTY_DRAFT.pricing,
input: 10,
output: 30,
cacheRead: 5,
},
};
const preview = computeCostPreview(draft);
const labels = preview.breakdown.map((part) => part.label);
expect(labels).toContain('2000 input');
expect(labels).toContain('500 output');
expect(labels).toContain('1000 cache_read');
expect(preview.total).toBeGreaterThan(0);
});
});
});

View File

@@ -0,0 +1,119 @@
import {
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
filterRules,
formatPricePerMillion,
getCanonicalId,
getExtraBuckets,
getRelativeLastSeen,
getSourceLabel,
type PricingRule,
} from '../utils';
const makeRule = (overrides: Partial<PricingRule> = {}): PricingRule => ({
id: 'rule-1',
orgId: 'org-1',
modelName: 'gpt-4o',
provider: 'OpenAI',
modelPattern: ['gpt-4o'],
isOverride: false,
enabled: true,
unit: UnitDTO.per_million_tokens,
pricing: { input: 15, output: 60 },
...overrides,
});
describe('utils', () => {
describe('formatPricePerMillion', () => {
it('formats numbers with 2 decimals and dollar prefix', () => {
expect(formatPricePerMillion(15)).toBe('$15.00');
expect(formatPricePerMillion(0.15)).toBe('$0.15');
});
it('returns em-dash for nullish or NaN', () => {
expect(formatPricePerMillion(undefined)).toBe('—');
expect(formatPricePerMillion(Number.NaN)).toBe('—');
});
});
describe('getExtraBuckets', () => {
it('returns an empty array when there is no cache pricing', () => {
expect(getExtraBuckets(makeRule())).toStrictEqual([]);
});
it('returns only buckets with values > 0', () => {
const rule = makeRule({
pricing: {
input: 3,
output: 15,
cache: {
mode: CacheModeDTO.additive,
read: 0.3,
write: 0,
},
},
});
const buckets = getExtraBuckets(rule);
expect(buckets).toStrictEqual([{ key: 'cache_read', pricePerMillion: 0.3 }]);
});
});
describe('getSourceLabel', () => {
it('returns "Auto" for non-override and "User override" otherwise', () => {
expect(getSourceLabel(makeRule({ isOverride: false }))).toBe('Auto');
expect(getSourceLabel(makeRule({ isOverride: true }))).toBe('User override');
});
});
describe('getCanonicalId', () => {
it('lowercases the provider and joins with the model name', () => {
expect(getCanonicalId(makeRule({ provider: 'OpenAI' }))).toBe(
'openai:gpt-4o',
);
});
});
describe('getRelativeLastSeen', () => {
it('returns em-dash when no timestamp is present', () => {
expect(getRelativeLastSeen(makeRule())).toBe('—');
});
it('formats minutes-old timestamps', () => {
const recent = new Date(Date.now() - 5 * 60 * 1000).toISOString();
expect(getRelativeLastSeen(makeRule({ updatedAt: recent }))).toMatch(
/min ago/,
);
});
});
describe('filterRules', () => {
const auto = makeRule({ id: 'r1', modelName: 'gpt-4o', isOverride: false });
const override = makeRule({
id: 'r2',
modelName: 'llama-3',
provider: 'Self-hosted',
modelPattern: ['llama-3'],
isOverride: true,
});
it('returns everything when no filters are applied', () => {
expect(filterRules([auto, override], '', 'all')).toHaveLength(2);
});
it('narrows by source = override', () => {
expect(filterRules([auto, override], '', 'override')).toStrictEqual([
override,
]);
});
it('narrows by free-text search across model and provider', () => {
expect(filterRules([auto, override], 'self', 'all')).toStrictEqual([
override,
]);
expect(filterRules([auto, override], 'gpt-4', 'all')).toStrictEqual([auto]);
});
});
});

View File

@@ -0,0 +1,191 @@
import {
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
type LlmpricingruletypesLLMPricingCacheCostsDTO,
type LlmpricingruletypesLLMRulePricingDTO,
type LlmpricingruletypesUpdatableLLMPricingRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { PricingRule } from './utils';
export const PROVIDER_OPTIONS = [
{ value: 'OpenAI', label: 'OpenAI' },
{ value: 'Anthropic', label: 'Anthropic' },
{ value: 'Azure OpenAI', label: 'Azure OpenAI' },
{ value: 'Google', label: 'Google' },
{ value: 'Self-hosted', label: 'Self-hosted' },
{ value: 'Other', label: 'Other' },
];
export const CACHE_MODE_OPTIONS = [
{
value: CacheModeDTO.subtract,
label: 'Subtract (OpenAI style)',
},
{
value: CacheModeDTO.additive,
label: 'Additive (Anthropic style)',
},
{
value: CacheModeDTO.unknown,
label: 'Unknown',
},
];
export type DrawerMode = 'add' | 'edit';
export interface DrawerDraft {
id: string | null;
sourceId: string | null;
modelName: string;
provider: string;
patterns: string[];
isOverride: boolean;
pricing: {
input: number;
output: number;
cacheMode: CacheModeDTO;
cacheRead: number | null;
cacheWrite: number | null;
};
}
export const EMPTY_DRAFT: DrawerDraft = {
id: null,
sourceId: null,
modelName: '',
provider: 'OpenAI',
patterns: [],
isOverride: true,
pricing: {
input: 0,
output: 0,
cacheMode: CacheModeDTO.unknown,
cacheRead: null,
cacheWrite: null,
},
};
export const draftFromRule = (rule: PricingRule): DrawerDraft => ({
id: rule.id,
sourceId: rule.sourceId ?? null,
modelName: rule.modelName,
provider: rule.provider || 'OpenAI',
patterns: rule.modelPattern || [],
isOverride: !!rule.isOverride,
pricing: {
input: rule.pricing?.input ?? 0,
output: rule.pricing?.output ?? 0,
cacheMode: rule.pricing?.cache?.mode ?? CacheModeDTO.unknown,
cacheRead: rule.pricing?.cache?.read ?? null,
cacheWrite: rule.pricing?.cache?.write ?? null,
},
});
const hasCacheValue = (value: number | null): boolean =>
typeof value === 'number' && value > 0;
export const buildPricingPayload = (
draft: DrawerDraft,
): LlmpricingruletypesLLMRulePricingDTO => {
const pricing: LlmpricingruletypesLLMRulePricingDTO = {
input: draft.pricing.input,
output: draft.pricing.output,
};
if (
hasCacheValue(draft.pricing.cacheRead) ||
hasCacheValue(draft.pricing.cacheWrite)
) {
const cache: LlmpricingruletypesLLMPricingCacheCostsDTO = {
mode: draft.pricing.cacheMode,
};
if (hasCacheValue(draft.pricing.cacheRead)) {
cache.read = draft.pricing.cacheRead as number;
}
if (hasCacheValue(draft.pricing.cacheWrite)) {
cache.write = draft.pricing.cacheWrite as number;
}
pricing.cache = cache;
}
return pricing;
};
export const buildRulePayload = (
draft: DrawerDraft,
): LlmpricingruletypesUpdatableLLMPricingRuleDTO => ({
id: draft.id || undefined,
sourceId: draft.sourceId || undefined,
modelName: draft.modelName.trim(),
provider: draft.provider.trim(),
modelPattern:
draft.patterns.length > 0 ? draft.patterns : [draft.modelName.trim()],
isOverride: draft.isOverride,
enabled: true,
unit: UnitDTO.per_million_tokens,
pricing: buildPricingPayload(draft),
});
export interface ValidationResult {
ok: boolean;
message?: string;
}
export const validateDraft = (
draft: DrawerDraft,
mode: DrawerMode,
): ValidationResult => {
if (mode === 'add' && !draft.modelName.trim()) {
return { ok: false, message: 'Billing model ID is required.' };
}
if (!draft.provider.trim()) {
return { ok: false, message: 'Provider is required.' };
}
if (draft.pricing.input < 0 || draft.pricing.output < 0) {
return { ok: false, message: 'Pricing values must be non-negative.' };
}
return { ok: true };
};
export const matchesAnyPattern = (
candidate: string,
patterns: string[],
): string | null => {
const lowered = candidate.toLowerCase();
const match = patterns.find((pattern) =>
lowered.startsWith(pattern.toLowerCase()),
);
return match || null;
};
const EXAMPLE_INPUT_TOKENS = 2000;
const EXAMPLE_OUTPUT_TOKENS = 500;
const EXAMPLE_CACHE_TOKENS = 1000;
const PER_MILLION = 1_000_000;
export interface CostPreviewParts {
total: number;
breakdown: { label: string; cost: number }[];
}
export const computeCostPreview = (draft: DrawerDraft): CostPreviewParts => {
const breakdown: { label: string; cost: number }[] = [];
const inputCost = (EXAMPLE_INPUT_TOKENS / PER_MILLION) * draft.pricing.input;
const outputCost =
(EXAMPLE_OUTPUT_TOKENS / PER_MILLION) * draft.pricing.output;
breakdown.push({ label: `${EXAMPLE_INPUT_TOKENS} input`, cost: inputCost });
breakdown.push({ label: `${EXAMPLE_OUTPUT_TOKENS} output`, cost: outputCost });
let total = inputCost + outputCost;
if (hasCacheValue(draft.pricing.cacheRead)) {
const cost =
(EXAMPLE_CACHE_TOKENS / PER_MILLION) * (draft.pricing.cacheRead as number);
breakdown.push({ label: `${EXAMPLE_CACHE_TOKENS} cache_read`, cost });
total += cost;
}
if (hasCacheValue(draft.pricing.cacheWrite)) {
const cost =
(EXAMPLE_CACHE_TOKENS / PER_MILLION) * (draft.pricing.cacheWrite as number);
breakdown.push({ label: `${EXAMPLE_CACHE_TOKENS} cache_write`, cost });
total += cost;
}
return { total, breakdown };
};

View File

@@ -0,0 +1,125 @@
import { useCallback, useState } from 'react';
import { useQueryClient } from 'react-query';
import {
getListLLMPricingRulesQueryKey,
useCreateOrUpdateLLMPricingRules,
useDeleteLLMPricingRule,
} from 'api/generated/services/llmpricingrules';
import {
buildRulePayload,
draftFromRule,
EMPTY_DRAFT,
type DrawerDraft,
type DrawerMode,
} from './drawerUtils';
import type { PricingRule } from './utils';
interface UseModelCostDrawerResult {
isOpen: boolean;
mode: DrawerMode;
draft: DrawerDraft;
setDraft: (next: DrawerDraft) => void;
openForAdd: (prefillModelName?: string) => void;
openForEdit: (rule: PricingRule) => void;
close: () => void;
save: () => Promise<void>;
deleteRule: () => Promise<void>;
isSaving: boolean;
isDeleting: boolean;
saveError: string | null;
selectedRuleId: string | null;
}
export function useModelCostDrawer(): UseModelCostDrawerResult {
const queryClient = useQueryClient();
const [isOpen, setIsOpen] = useState<boolean>(false);
const [mode, setMode] = useState<DrawerMode>('add');
const [draft, setDraft] = useState<DrawerDraft>(EMPTY_DRAFT);
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
const { mutateAsync: createOrUpdate, isLoading: isSaving } =
useCreateOrUpdateLLMPricingRules();
const { mutateAsync: deleteRuleApi, isLoading: isDeleting } =
useDeleteLLMPricingRule();
const invalidateList = useCallback(async (): Promise<void> => {
await queryClient.invalidateQueries({
queryKey: getListLLMPricingRulesQueryKey(),
});
}, [queryClient]);
const openForAdd = useCallback((prefillModelName?: string): void => {
setMode('add');
setDraft({
...EMPTY_DRAFT,
modelName: prefillModelName || '',
patterns: prefillModelName ? [prefillModelName] : [],
});
setSelectedRuleId(null);
setSaveError(null);
setIsOpen(true);
}, []);
const openForEdit = useCallback((rule: PricingRule): void => {
setMode('edit');
setDraft(draftFromRule(rule));
setSelectedRuleId(rule.id);
setSaveError(null);
setIsOpen(true);
}, []);
const close = useCallback((): void => {
setIsOpen(false);
setSelectedRuleId(null);
setSaveError(null);
}, []);
const save = useCallback(async (): Promise<void> => {
setSaveError(null);
try {
await createOrUpdate({
data: { rules: [buildRulePayload(draft)] },
});
await invalidateList();
setIsOpen(false);
setSelectedRuleId(null);
} catch (error) {
const message = error instanceof Error ? error.message : 'Save failed';
setSaveError(message);
}
}, [createOrUpdate, draft, invalidateList]);
const deleteRule = useCallback(async (): Promise<void> => {
if (!draft.id) {
return;
}
setSaveError(null);
try {
await deleteRuleApi({ pathParams: { id: draft.id } });
await invalidateList();
setIsOpen(false);
setSelectedRuleId(null);
} catch (error) {
const message = error instanceof Error ? error.message : 'Delete failed';
setSaveError(message);
}
}, [deleteRuleApi, draft.id, invalidateList]);
return {
isOpen,
mode,
draft,
setDraft,
openForAdd,
openForEdit,
close,
save,
deleteRule,
isSaving,
isDeleting,
saveError,
selectedRuleId,
};
}

View File

@@ -0,0 +1,101 @@
import type { LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
export type PricingRule = LlmpricingruletypesLLMPricingRuleDTO;
export type SourceFilter = 'all' | 'auto' | 'override';
export interface ExtraBucket {
key: string;
pricePerMillion: number;
}
export const formatPricePerMillion = (value: number | undefined): string => {
if (value === undefined || value === null || Number.isNaN(value)) {
return '—';
}
return `$${value.toFixed(2)}`;
};
export const getExtraBuckets = (rule: PricingRule): ExtraBucket[] => {
const cache = rule.pricing?.cache;
if (!cache) {
return [];
}
const buckets: ExtraBucket[] = [];
if (typeof cache.read === 'number' && cache.read > 0) {
buckets.push({ key: 'cache_read', pricePerMillion: cache.read });
}
if (typeof cache.write === 'number' && cache.write > 0) {
buckets.push({ key: 'cache_write', pricePerMillion: cache.write });
}
return buckets;
};
export const getSourceLabel = (rule: PricingRule): 'Auto' | 'User override' =>
rule.isOverride ? 'User override' : 'Auto';
const MINUTE = 60;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
const MONTH = 30 * DAY;
const YEAR = 365 * DAY;
export const getRelativeLastSeen = (rule: PricingRule): string => {
const ts = rule.updatedAt || rule.syncedAt || rule.createdAt;
if (!ts) {
return '—';
}
const now = Date.now();
const target = new Date(ts).getTime();
if (Number.isNaN(target)) {
return '—';
}
const seconds = Math.max(0, Math.round((now - target) / 1000));
if (seconds < MINUTE) {
return 'just now';
}
if (seconds < HOUR) {
return `${Math.floor(seconds / MINUTE)} min ago`;
}
if (seconds < DAY) {
return `${Math.floor(seconds / HOUR)} hr ago`;
}
if (seconds < MONTH) {
return `${Math.floor(seconds / DAY)} days ago`;
}
if (seconds < YEAR) {
return `${Math.floor(seconds / MONTH)} mo ago`;
}
return `${Math.floor(seconds / YEAR)} yr ago`;
};
const lc = (value: string): string => value.toLowerCase();
export const filterRules = (
rules: PricingRule[],
search: string,
source: SourceFilter,
): PricingRule[] => {
const normalized = lc(search.trim());
return rules.filter((rule) => {
if (source === 'auto' && rule.isOverride) {
return false;
}
if (source === 'override' && !rule.isOverride) {
return false;
}
if (!normalized) {
return true;
}
return (
lc(rule.modelName).includes(normalized) ||
lc(rule.provider).includes(normalized) ||
(rule.modelPattern || []).some((pattern) => lc(pattern).includes(normalized))
);
});
};
export const getCanonicalId = (rule: PricingRule): string => {
const provider = rule.provider?.trim() || 'unknown';
return `${lc(provider)}:${rule.modelName}`;
};

View File

@@ -142,15 +142,6 @@
}
}
.reset-password-back-action {
margin-top: var(--spacing-12);
width: 100%;
button {
width: 100%;
}
}
@media (max-width: 768px) {
width: 100%;
padding: 0 16px;

View File

@@ -1,10 +1,7 @@
import { ArrowLeft, CircleAlert } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { CircleAlert } from '@signozhq/icons';
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';
@@ -62,16 +59,6 @@ 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>
);

View File

@@ -119,10 +119,6 @@
border-radius: 0px 4px 4px 0px;
background: var(--l3-background);
&.version-container-standalone {
border-radius: 4px;
}
}
.version {

View File

@@ -1010,7 +1010,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
<img src={signozBrandLogoUrl} alt="SigNoz" />
</div>
{(licenseTag || currentVersion) && (
{licenseTag && (
<div
className={cx(
'brand-title-section',
@@ -1021,7 +1021,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
'version-update-notification',
)}
>
{licenseTag && <span className="license-type"> {licenseTag} </span>}
<span className="license-type"> {licenseTag} </span>
{currentVersion && (
<Tooltip
@@ -1043,12 +1043,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
)
}
>
<div
className={cx(
'version-container',
!licenseTag && 'version-container-standalone',
)}
>
<div className="version-container">
<span
className={cx('version', changelog && 'version-clickable')}
onClick={onClickVersionHandler}

View File

@@ -11,6 +11,7 @@ import {
Building2,
ChartArea,
Cloudy,
Coins,
DraftingCompass,
FileKey2,
Github,
@@ -365,6 +366,13 @@ export const settingsNavSections: SettingsNavSection[] = [
isEnabled: false,
itemKey: 'mcp-server',
},
{
key: ROUTES.LLM_OBSERVABILITY_MODEL_PRICING,
label: 'Model Pricing',
icon: <Coins size={16} />,
isEnabled: true,
itemKey: 'llm-observability-model-pricing',
},
],
},

View File

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

View File

@@ -1,15 +1,53 @@
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import DashboardPageV2 from 'pages/DashboardPageV2';
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 DashboardPage from './DashboardPage';
function DashboardPage(): JSX.Element {
const { dashboardId } = useParams<{ dashboardId: string }>();
// 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 [onModal, Content] = Modal.useModal();
return isDashboardV2 ? <DashboardPageV2 /> : <DashboardPage />;
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 DashboardPageEntry;
export default DashboardPage;

View File

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

View File

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

View File

@@ -1,310 +0,0 @@
/* 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;
}

View File

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

View File

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

View File

@@ -1,101 +0,0 @@
.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);
}

View File

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

View File

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

View File

@@ -1,51 +0,0 @@
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 };
}

View File

@@ -1,154 +0,0 @@
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 };

View File

@@ -1,78 +0,0 @@
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',
};
}

View File

@@ -1,22 +0,0 @@
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,
},
];
}

View File

@@ -1,39 +1,48 @@
import { useMemo } from 'react';
import { Braces, Globe, Table } from '@signozhq/icons';
import { TabItemProps, Tabs } from '@signozhq/ui/tabs';
import { Tabs } from '@signozhq/ui/tabs';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import GeneralSettings from './General';
import { SettingsTabPlaceholder } from './utils';
import VariablesSettings from './Variables';
import styles from './DashboardSettings.module.scss';
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: TabItemProps[] = useMemo(
const items = useMemo(
() => [
{
key: 'general',
label: 'General',
label: tabLabel(<Table size={14} />, 'General'),
children: <GeneralSettings dashboard={dashboard} />,
prefixIcon: <Table size={14} />,
},
{
key: 'variables',
label: 'Variables',
children: <VariablesSettings dashboard={dashboard} />,
prefixIcon: <Braces size={14} />,
label: tabLabel(<Braces size={14} />, 'Variables'),
children: (
<SettingsTabPlaceholder message="V2 dashboard variables coming next." />
),
},
{
key: 'public-dashboard',
label: 'Publish',
label: tabLabel(<Globe size={14} />, 'Publish'),
children: (
<SettingsTabPlaceholder message="V2 public dashboard publishing coming next." />
),
prefixIcon: <Globe size={14} />,
},
],
[dashboard],

View File

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

View File

@@ -1,29 +0,0 @@
.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;
}

View File

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

View File

@@ -1,56 +0,0 @@
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 ');
}

View File

@@ -1,16 +0,0 @@
/** 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>;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,41 +0,0 @@
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]);
}

View File

@@ -1,116 +0,0 @@
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 };
}

View File

@@ -1,199 +0,0 @@
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));
}

View File

@@ -9,7 +9,6 @@ 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 {
@@ -46,7 +45,6 @@ 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"

View File

@@ -4,7 +4,7 @@ import type {
DashboardtypesLayoutDTO,
DashboardtypesPanelDTO,
} from 'api/generated/services/sigNoz.schemas';
import { DashboardtypesPatchOpDTO } from 'api/generated/services/sigNoz.schemas';
import { DashboardtypesJSONPatchOperationDTOOp } 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 } = DashboardtypesPatchOpDTO;
const { add, replace, remove } = DashboardtypesJSONPatchOperationDTOOp;
const PANEL_REF_PREFIX = '#/spec/panels/';

View File

@@ -1,55 +0,0 @@
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] ?? {};

View File

@@ -9,36 +9,25 @@ import {
createCollapseSlice,
type CollapseSlice,
} from './slices/collapseSlice';
import {
createVariableSelectionSlice,
type VariableSelectionSlice,
} from './slices/variableSelectionSlice';
export type DashboardStore = EditContextSlice &
CollapseSlice &
VariableSelectionSlice;
export type DashboardStore = EditContextSlice & CollapseSlice;
/**
* V2 dashboard session store. Holds cross-cutting client state only — never the
* dashboard spec (that stays in react-query via useGetDashboardV2). Slices:
* dashboard spec (that stays in react-query via useGetDashboardV2). Two 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 UI-only state (context incl. the refetch fn is transient).
partialize: (state) => ({
collapsed: state.collapsed,
variableValues: state.variableValues,
}),
// Persist only the collapse map — context (incl. the refetch fn) is transient.
partialize: (state) => ({ collapsed: state.collapsed }),
},
),
);

View File

@@ -1,15 +1,3 @@
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import DashboardsListPageV2 from 'pages/DashboardsListPageV2';
import DashboardsListPage from './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;
export default DashboardsListPage;

View File

@@ -8,10 +8,6 @@ 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';
@@ -28,6 +24,8 @@ import {
useSearch,
useSortColumn,
useSortOrder,
type SortColumn,
type SortOrder,
} from '../../hooks/useDashboardsListQueryParams';
import type { DashboardListItem } from '../../utils';
import ConfigureMetadataModal from '../ConfigureMetadataModal/ConfigureMetadataModal';
@@ -133,10 +131,6 @@ 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(
@@ -156,7 +150,7 @@ function DashboardsList(): JSX.Element {
}, []);
const onSortChange = useCallback(
(column: DashboardtypesListSortDTO): void => {
(column: SortColumn): void => {
void setSortColumn(column);
void setPage(1);
},
@@ -164,7 +158,7 @@ function DashboardsList(): JSX.Element {
);
const onOrderChange = useCallback(
(order: DashboardtypesListOrderDTO): void => {
(order: SortOrder): void => {
void setSortOrder(order);
void setPage(1);
},

View File

@@ -7,18 +7,18 @@ import {
HdmiPort,
} from '@signozhq/icons';
import {
DashboardtypesListOrderDTO,
DashboardtypesListSortDTO,
} from 'api/generated/services/sigNoz.schemas';
import type {
SortColumn,
SortOrder,
} from '../../hooks/useDashboardsListQueryParams';
import styles from './ListHeader.module.scss';
interface Props {
sortColumn: DashboardtypesListSortDTO;
onSortChange: (column: DashboardtypesListSortDTO) => void;
sortOrder: DashboardtypesListOrderDTO;
onOrderChange: (order: DashboardtypesListOrderDTO) => void;
sortColumn: SortColumn;
onSortChange: (column: SortColumn) => void;
sortOrder: SortOrder;
onOrderChange: (order: SortOrder) => void;
onConfigureMetadata: () => void;
}
@@ -44,57 +44,49 @@ function ListHeader({
<Button
type="text"
className={styles.sortButton}
onClick={(): void => onSortChange(DashboardtypesListSortDTO.name)}
onClick={(): void => onSortChange('name')}
data-testid="sort-by-name"
>
Name
{sortColumn === DashboardtypesListSortDTO.name && <Check size={14} />}
{sortColumn === 'name' && <Check size={14} />}
</Button>
<Button
type="text"
className={styles.sortButton}
onClick={(): void =>
onSortChange(DashboardtypesListSortDTO.created_at)
}
onClick={(): void => onSortChange('created_at')}
data-testid="sort-by-last-created"
>
Last created
{sortColumn === DashboardtypesListSortDTO.created_at && (
<Check size={14} />
)}
{sortColumn === 'created_at' && <Check size={14} />}
</Button>
<Button
type="text"
className={styles.sortButton}
onClick={(): void =>
onSortChange(DashboardtypesListSortDTO.updated_at)
}
onClick={(): void => onSortChange('updated_at')}
data-testid="sort-by-last-updated"
>
Last updated
{sortColumn === DashboardtypesListSortDTO.updated_at && (
<Check size={14} />
)}
{sortColumn === '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(DashboardtypesListOrderDTO.asc)}
onClick={(): void => onOrderChange('asc')}
data-testid="sort-order-asc"
>
Ascending
{sortOrder === DashboardtypesListOrderDTO.asc && <Check size={14} />}
{sortOrder === 'asc' && <Check size={14} />}
</Button>
<Button
type="text"
className={styles.sortButton}
onClick={(): void => onOrderChange(DashboardtypesListOrderDTO.desc)}
onClick={(): void => onOrderChange('desc')}
data-testid="sort-order-desc"
>
Descending
{sortOrder === DashboardtypesListOrderDTO.desc && <Check size={14} />}
{sortOrder === 'desc' && <Check size={14} />}
</Button>
</div>
}

View File

@@ -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;

View File

@@ -1,7 +1,3 @@
import {
DashboardtypesListOrderDTO,
DashboardtypesListSortDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
parseAsInteger,
parseAsString,
@@ -11,31 +7,26 @@ import {
type UseQueryStateReturn,
} from 'nuqs';
export const SORT_COLUMNS = Object.values(DashboardtypesListSortDTO);
export const SORT_ORDERS = Object.values(DashboardtypesListOrderDTO);
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];
const opts: Options = { history: 'push' };
export const useSortColumn = (): UseQueryStateReturn<
DashboardtypesListSortDTO,
DashboardtypesListSortDTO
> =>
export const useSortColumn = (): UseQueryStateReturn<SortColumn, SortColumn> =>
useQueryState(
'sort',
parseAsStringLiteral(SORT_COLUMNS)
.withDefault(DashboardtypesListSortDTO.updated_at)
.withDefault('updated_at')
.withOptions(opts),
);
export const useSortOrder = (): UseQueryStateReturn<
DashboardtypesListOrderDTO,
DashboardtypesListOrderDTO
> =>
export const useSortOrder = (): UseQueryStateReturn<SortOrder, SortOrder> =>
useQueryState(
'order',
parseAsStringLiteral(SORT_ORDERS)
.withDefault(DashboardtypesListOrderDTO.desc)
.withOptions(opts),
parseAsStringLiteral(SORT_ORDERS).withDefault('desc').withOptions(opts),
);
export const usePage = (): UseQueryStateReturn<number, number> =>

View File

@@ -1,8 +1,8 @@
import dayjs from 'dayjs';
import { isEmpty } from 'lodash-es';
import type { DashboardtypesListedDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import type { DashboardtypesGettableDashboardWithPinDTO } from 'api/generated/services/sigNoz.schemas';
export type DashboardListItem = DashboardtypesListedDashboardV2DTO;
export type DashboardListItem = DashboardtypesGettableDashboardWithPinDTO;
export const tagsToStrings = (
tags: { key: string; value: string }[] | null | undefined,

View File

@@ -0,0 +1,7 @@
import LLMObservabilityModelPricing from 'container/LLMObservabilityModelPricing/LLMObservabilityModelPricing';
function LLMObservabilityModelPricingPage(): JSX.Element {
return <LLMObservabilityModelPricing />;
}
export default LLMObservabilityModelPricingPage;

View File

@@ -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, fireEvent } from 'tests/test-utils';
import { render, screen, waitFor } from 'tests/test-utils';
import ResetPassword from '../index';
@@ -103,7 +103,6 @@ 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 () => {
@@ -138,32 +137,6 @@ 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 () => {

View File

@@ -136,4 +136,5 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
AI_ASSISTANT_ICON_PREVIEW: ['ADMIN', 'EDITOR', 'VIEWER'],
MCP_SERVER: ['ADMIN', 'EDITOR', 'VIEWER'],
AI_ASSISTANT_BASE: ['ADMIN', 'EDITOR', 'VIEWER'],
LLM_OBSERVABILITY_MODEL_PRICING: ['ADMIN', 'EDITOR', 'VIEWER'],
};

View File

@@ -48,7 +48,9 @@
"node_modules",
"src/parser/*.ts",
"src/parser/TraceOperatorParser/*.ts",
"orval.config.ts"
"orval.config.ts",
"src/pages/DashboardsListPageV2/**/*",
"src/pages/DashboardPageV2/**/*"
],
"include": [
"./src",

View File

@@ -50,8 +50,8 @@ func (handler *healthOpenAPIHandler) ServeOpenAPI(opCtx openapi.OperationContext
)
}
func (handler *healthOpenAPIHandler) ResourceDefs() []pkghandler.ResourceDef {
// Health endpoints don't act on resources.
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.
return nil
}

View File

@@ -7,197 +7,166 @@ 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.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 {
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 {
return err
}
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 {
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 {
return err
}
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 {
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 {
return err
}
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 {
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 {
return err
}
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 {
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 {
return err
}
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 {
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 {
return err
}
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 {
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 {
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
}

View File

@@ -1,10 +1,13 @@
package signozapiserver
import (
"context"
"bytes"
"encoding/json"
"io"
"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"
@@ -14,56 +17,41 @@ import (
)
func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
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 {
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 {
return err
}
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 {
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 {
return err
}
@@ -84,117 +72,89 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
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 {
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 {
return err
}
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 {
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 {
return err
}
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 {
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 {
return err
}
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 {
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 {
return err
}
@@ -215,209 +175,208 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
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 {
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 {
return err
}
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 {
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 {
return err
}
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 {
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 {
return err
}
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 {
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 {
return err
}
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 {
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 {
return err
}
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 {
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 {
return err
}
return nil
}
// 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)
func (provider *provider) roleDetachSelectorFromPath(req *http.Request, claims authtypes.Claims) ([]coretypes.Selector, error) {
roleID, err := valuer.NewUUID(mux.Vars(req)["rid"])
if err != nil {
return nil, err
}
role, err := provider.authzService.Get(ctx, orgID, roleID)
role, err := provider.authzService.Get(req.Context(), valuer.MustNewUUID(claims.OrgID), roleID)
if err != nil {
return nil, err
}
return []coretypes.Selector{
resource.Type().MustSelector(role.Name),
resource.Type().MustSelector(coretypes.WildCardSelectorString),
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),
}, nil
}

View File

@@ -20,16 +20,16 @@ func newTestSettings() factory.ScopedProviderSettings {
return factory.NewScopedProviderSettings(instrumentationtest.New().ToProviderSettings(), "auditorserver_test")
}
func newTestEvent(resource coretypes.Resource, action coretypes.Verb) audittypes.AuditEvent {
func newTestEvent(resource string, action coretypes.Verb) audittypes.AuditEvent {
return audittypes.AuditEvent{
Timestamp: time.Now(),
EventName: audittypes.NewEventName(resource.Kind(), action),
EventName: audittypes.NewEventName(coretypes.MustNewKind(resource), action),
AuditAttributes: audittypes.AuditAttributes{
Action: action,
Outcome: audittypes.OutcomeSuccess,
},
ResourceAttributes: audittypes.ResourceAttributes{
Resource: resource,
ResourceKind: coretypes.MustNewKind(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(coretypes.ResourceMetaResourceDashboard, coretypes.VerbCreate))
server.Add(ctx, newTestEvent("dashboard", 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(coretypes.ResourceUser, coretypes.VerbUpdate))
server.Add(ctx, newTestEvent("user", 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(coretypes.ResourceMetaResourceDashboard, coretypes.VerbCreate))
server.Add(ctx, newTestEvent(coretypes.ResourceMetaResourceDashboard, coretypes.VerbUpdate))
server.Add(ctx, newTestEvent(coretypes.ResourceMetaResourceDashboard, coretypes.VerbDelete))
server.Add(ctx, newTestEvent("dashboard", coretypes.VerbCreate))
server.Add(ctx, newTestEvent("dashboard", coretypes.VerbUpdate))
server.Add(ctx, newTestEvent("dashboard", 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(coretypes.ResourceMetaResourceRule, coretypes.VerbCreate))
server.Add(ctx, newTestEvent("alert-rule", 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(coretypes.ResourceUser, coretypes.VerbDelete))
server.Add(ctx, newTestEvent(coretypes.ResourceUser, coretypes.VerbDelete))
server.Add(ctx, newTestEvent("user", coretypes.VerbDelete))
server.Add(ctx, newTestEvent("user", 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(coretypes.ResourceMetaResourceDashboard, coretypes.VerbCreate))
server.Add(ctx, newTestEvent("dashboard", coretypes.VerbCreate))
}()
}
wg.Wait()

View File

@@ -15,13 +15,13 @@ type ServeOpenAPIFunc func(openapi.OperationContext)
type Handler interface {
http.Handler
ServeOpenAPI(openapi.OperationContext)
ResourceDefs() []ResourceDef
AuditDef() *AuditDef
}
type handler struct {
handlerFunc http.HandlerFunc
openAPIDef OpenAPIDef
resourceDefs []ResourceDef
handlerFunc http.HandlerFunc
openAPIDef OpenAPIDef
auditDef *AuditDef
}
func New(handlerFunc http.HandlerFunc, openAPIDef OpenAPIDef, opts ...Option) Handler {
@@ -130,6 +130,6 @@ func (handler *handler) ServeOpenAPI(opCtx openapi.OperationContext) {
}
}
func (handler *handler) ResourceDefs() []ResourceDef {
return handler.resourceDefs
func (handler *handler) AuditDef() *AuditDef {
return handler.auditDef
}

View File

@@ -1,9 +1,25 @@
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)
func WithResourceDefs(defs ...ResourceDef) Option {
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 {
return func(h *handler) {
h.resourceDefs = append(h.resourceDefs, defs...)
h.auditDef = &def
}
}

View File

@@ -1,99 +0,0 @@
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,
),
}
}

View File

@@ -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,12 +61,6 @@ 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()
@@ -86,7 +80,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 statusCode >= 400 && responseBuffer.Len() != 0 {
if responseBuffer.Len() != 0 {
fields = append(fields, "response.body", responseBuffer.String())
}
@@ -100,85 +94,76 @@ func (middleware *Audit) emitAuditEvent(req *http.Request, writer responseCaptur
return
}
resolved, err := coretypes.ResolvedResourcesFromContext(req.Context())
if err != nil || len(resolved) == 0 {
def := auditDefFromRequest(req)
if def == nil {
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())
}
extractorCtx := coretypes.ExtractorContext{Request: req, ResponseBody: 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,
)
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,
))
}
}
}
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]
}

View File

@@ -1,8 +1,6 @@
package middleware
import (
"context"
"fmt"
"log/slog"
"net/http"
@@ -21,6 +19,18 @@ 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 {
@@ -191,9 +201,7 @@ func (middleware *AuthZ) OpenAccess(next http.HandlerFunc) 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 {
func (middleware *AuthZ) Check(next http.HandlerFunc, relation authtypes.Relation, typeable coretypes.Resource, cb selectorCallbackWithClaimsFn, roles []string) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
@@ -202,7 +210,40 @@ func (middleware *AuthZ) CheckResources(next http.HandlerFunc, roles ...string)
return
}
resolved, err := coretypes.ResolvedResourcesFromContext(ctx)
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)
if err != nil {
render.Error(rw, err)
return
@@ -210,23 +251,33 @@ func (middleware *AuthZ) CheckResources(next http.HandlerFunc, roles ...string)
orgID := valuer.MustNewUUID(claims.OrgID)
roleSelectors := make([]coretypes.Selector, len(roles))
for idx, role := range roles {
roleSelectors[idx] = coretypes.TypeRole.MustSelector(role)
}
for _, group := range groups {
groupPassed := false
var lastErr error
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 {
for _, check := range group {
selectors, err := check.SelectorCallback(req, claims)
if 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
}
}
@@ -234,68 +285,6 @@ func (middleware *AuthZ) CheckResources(next http.HandlerFunc, roles ...string)
})
}
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()

View File

@@ -1,67 +0,0 @@
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()
}

View File

@@ -23,14 +23,9 @@ type responseCapture interface {
// WriteError returns the error (if any) from the downstream Write call.
WriteError() error
// BodyBytes returns the captured response body bytes. Populated for error
// responses (status >= 400), or for any response once EnableBodyCapture is called.
// BodyBytes returns the captured response body bytes. Only populated
// for error responses (status >= 400).
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 {
@@ -77,13 +72,12 @@ func (b *byteBuffer) String() string {
}
type nonFlushingResponseCapture struct {
rw http.ResponseWriter
buffer *byteBuffer
captureBody bool
forceCaptureBody bool
bodyBytesLeft int
statusCode int
writeError error
rw http.ResponseWriter
buffer *byteBuffer
captureBody bool
bodyBytesLeft int
statusCode int
writeError error
}
type flushingResponseCapture struct {
@@ -104,17 +98,13 @@ func (writer *nonFlushingResponseCapture) Header() http.Header {
// WriteHeader writes the HTTP response header.
func (writer *nonFlushingResponseCapture) WriteHeader(statusCode int) {
writer.statusCode = statusCode
if statusCode >= 400 || writer.forceCaptureBody {
if statusCode >= 400 {
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 {

View File

@@ -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, orgID valuer.UUID, userID valuer.UUID, id valuer.UUID) error
UnpinV2(ctx context.Context, userID valuer.UUID, id valuer.UUID) error
DeleteV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error
DeletePreferencesForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error
DeletePreferencesForUser(ctx context.Context, userID valuer.UUID) error
}
type Handler interface {

View File

@@ -13,16 +13,9 @@ 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 &Compiled{}, nil
return nil, nil //nolint:nilnil
}
queryVisitor := newVisitor(formatter)
@@ -36,6 +29,9 @@ 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,

View File

@@ -17,7 +17,7 @@ import (
type compileCase struct {
subtestName string
dslQueryToCompile string
emptyQueryExpected bool
nilExpected bool
expectedSQL string
expectedArgs []any
expectedErrShouldContain string
@@ -41,8 +41,8 @@ func runCompileCases(t *testing.T, cases []compileCase) {
}
require.NoError(t, err)
if c.emptyQueryExpected {
assert.True(t, out.IsEmpty())
if c.nilExpected {
assert.Nil(t, out)
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: "", emptyQueryExpected: true},
{subtestName: "empty query yields nil", dslQueryToCompile: "", nilExpected: true},
})
}

View File

@@ -103,7 +103,7 @@ func (store *store) ListForUser(
Where("dashboard.org_id = ?", orgID).
Where("dashboard.source != ?", dashboardtypes.SourceSystem)
if !compiled.IsEmpty() {
if compiled != nil {
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.IsEmpty() {
if compiled != nil {
q = q.Where(compiled.SQL, compiled.Args...)
}
@@ -383,16 +383,15 @@ 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 (id, user_id, dashboard_id, is_pinned, created_at, updated_at)
SELECT ?, ?, ?, true, ?, ?
INSERT INTO user_dashboard_preference (user_id, dashboard_id, is_pinned)
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, updated_at = ?
ON CONFLICT (user_id, dashboard_id) DO UPDATE SET is_pinned = true
`,
preference.ID, preference.UserID, preference.DashboardID, preference.CreatedAt, preference.UpdatedAt,
preference.UserID, preference.DashboardID,
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")
@@ -411,21 +410,12 @@ 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, 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)
func (store *store) UnpinForUser(ctx context.Context, userID valuer.UUID, dashboardID valuer.UUID) error {
_, 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")
@@ -433,19 +423,11 @@ func (store *store) UnpinForUser(ctx context.Context, orgID valuer.UUID, userID
return nil
}
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)
func (store *store) DeletePreferencesForDashboard(ctx context.Context, dashboardID valuer.UUID) error {
_, 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")
@@ -453,19 +435,11 @@ func (store *store) DeletePreferencesForDashboard(ctx context.Context, orgID val
return nil
}
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)
func (store *store) DeletePreferencesForUser(ctx context.Context, userID valuer.UUID) error {
_, 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")

View File

@@ -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, orgID, userID, dashboardID)
err = handler.module.UnpinV2(ctx, userID, dashboardID)
}
if err != nil {
render.Error(rw, err)

View File

@@ -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.ErrIfNotUpdatable(); err != nil {
if err := existing.CanUpdate(); 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.ErrIfNotUpdatable(); err != nil {
if err := existing.CanUpdate(); 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.ErrIfNotDeletable(); err != nil {
if err := existing.CanDelete(); 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, orgID, id); err != nil {
if err := module.store.DeletePreferencesForDashboard(ctx, 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, orgID valuer.UUID, userID valuer.UUID, id valuer.UUID) error {
return module.store.UnpinForUser(ctx, orgID, 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) DeletePreferencesForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error {
return module.store.DeletePreferencesForUser(ctx, orgID, userID)
func (module *module) DeletePreferencesForUser(ctx context.Context, userID valuer.UUID) error {
return module.store.DeletePreferencesForUser(ctx, userID)
}

View File

@@ -13,6 +13,7 @@ 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"
@@ -34,11 +35,11 @@ type setter struct {
analytics analytics.Analytics
config root.Config
getter root.Getter
onDeleteUser []root.OnDeleteUser
dashboard dashboard.Module
}
// 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, onDeleteUser []root.OnDeleteUser) 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, dashboard dashboard.Module) root.Setter {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/user/impluser")
return &setter{
store: store,
@@ -51,7 +52,7 @@ func NewSetter(store types.UserStore, tokenizer tokenizer.Tokenizer, emailing em
authz: authz,
config: config,
getter: getter,
onDeleteUser: onDeleteUser,
dashboard: dashboard,
}
}
@@ -408,10 +409,8 @@ func (module *setter) DeleteUser(ctx context.Context, orgID valuer.UUID, id stri
return err
}
for _, onDeleteUser := range module.onDeleteUser {
if err := onDeleteUser(ctx, orgID, user.ID); err != nil {
return err
}
if err := module.dashboard.DeletePreferencesForUser(ctx, user.ID); err != nil {
return err
}
traitsOrProperties := types.NewTraitsFromUser(user)

View File

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

View File

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

View File

@@ -122,11 +122,7 @@ func NewModules(
) Modules {
quickfilter := implquickfilter.NewModule(implquickfilter.NewStore(sqlstore))
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter)
// 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)
userSetter := impluser.NewSetter(impluser.NewStore(sqlstore, providerSettings), tokenizer, emailing, providerSettings, orgSetter, authz, analytics, config.User, userRoleStore, userGetter, dashboard)
ruleStore := sqlrulestore.NewRuleStore(sqlstore, queryParser, providerSettings)
return Modules{

View File

@@ -212,7 +212,6 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewFixChangelogOperationTypeFactory(sqlstore, sqlschema),
sqlmigration.NewCloudIntegrationRemoveCascadeDeleteFactory(sqlschema),
sqlmigration.NewAddUserDashboardPreferenceFactory(sqlstore, sqlschema),
sqlmigration.NewRecreateUserDashboardPreferenceFactory(sqlstore, sqlschema),
)
}

View File

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

View File

@@ -13,13 +13,13 @@ import (
// Audit attributes — Action (What).
type AuditAttributes struct {
Action coretypes.Verb // guaranteed to be present
ActionCategory coretypes.ActionCategory // guaranteed to be present
Outcome Outcome // guaranteed to be present
Action coretypes.Verb // guaranteed to be present
ActionCategory ActionCategory // guaranteed to be present
Outcome Outcome // guaranteed to be present
IdentNProvider authtypes.IdentNProvider
}
func NewAuditAttributesFromHTTP(statusCode int, action coretypes.Verb, category coretypes.ActionCategory, claims authtypes.Claims) AuditAttributes {
func NewAuditAttributesFromHTTP(statusCode int, action coretypes.Verb, category ActionCategory, claims authtypes.Claims) AuditAttributes {
outcome := OutcomeFailure
if statusCode >= 200 && statusCode < 400 {
outcome = OutcomeSuccess
@@ -71,50 +71,23 @@ 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 {
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
ResourceID string
ResourceKind coretypes.Kind // guaranteed to be present
}
func NewResourceAttributes(resource coretypes.Resource, resourceID string) ResourceAttributes {
func NewResourceAttributes(resourceID string, resourceKind coretypes.Kind) ResourceAttributes {
return ResourceAttributes{
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,
ResourceID: resourceID,
ResourceKind: resourceKind,
}
}
// 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(orgID valuer.UUID, dest pcommon.Map) {
putStrIfNotEmpty(dest, "signoz.audit.resource.kind", attributes.Resource.Kind().String())
func (attributes ResourceAttributes) PutResource(dest pcommon.Map) {
putStrIfNotEmpty(dest, "signoz.audit.resource.kind", attributes.ResourceKind.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)
@@ -220,24 +193,13 @@ func newBody(auditAttributes AuditAttributes, principalAttributes PrincipalAttri
// Resource: " kind (id)" or " kind".
b.WriteString(" ")
b.WriteString(resourceAttributes.Resource.Kind().String())
b.WriteString(resourceAttributes.ResourceKind.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

View File

@@ -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, coretypes.ActionCategoryConfigurationChange, claims)
attrs := NewAuditAttributesFromHTTP(testCase.statusCode, coretypes.VerbUpdate, 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: coretypes.ActionCategoryConfigurationChange,
ActionCategory: ActionCategoryConfigurationChange,
Outcome: OutcomeSuccess,
},
principalAttributes: PrincipalAttributes{
@@ -63,8 +63,8 @@ func TestNewBody(t *testing.T) {
PrincipalEmail: valuer.MustNewEmail("test@acme.com"),
},
resourceAttributes: ResourceAttributes{
ResourceID: "",
Resource: coretypes.ResourceMetaResourceDashboard,
ResourceID: "",
ResourceKind: coretypes.MustNewKind("dashboard"),
},
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: coretypes.ActionCategoryConfigurationChange,
ActionCategory: ActionCategoryConfigurationChange,
Outcome: OutcomeSuccess,
},
principalAttributes: PrincipalAttributes{
@@ -81,8 +81,8 @@ func TestNewBody(t *testing.T) {
PrincipalEmail: valuer.Email{},
},
resourceAttributes: ResourceAttributes{
ResourceID: "abd",
Resource: coretypes.ResourceMetaResourceDashboard,
ResourceID: "abd",
ResourceKind: coretypes.MustNewKind("dashboard"),
},
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: coretypes.ActionCategoryConfigurationChange,
ActionCategory: ActionCategoryConfigurationChange,
Outcome: OutcomeSuccess,
},
principalAttributes: PrincipalAttributes{
@@ -99,8 +99,8 @@ func TestNewBody(t *testing.T) {
PrincipalEmail: valuer.Email{},
},
resourceAttributes: ResourceAttributes{
ResourceID: "abd",
Resource: coretypes.ResourceMetaResourceDashboard,
ResourceID: "abd",
ResourceKind: coretypes.MustNewKind("dashboard"),
},
errorAttributes: ErrorAttributes{},
expectedBody: "deleted dashboard (abd)",
@@ -109,7 +109,7 @@ func TestNewBody(t *testing.T) {
name: "Success_AllPresent",
auditAttributes: AuditAttributes{
Action: coretypes.VerbCreate,
ActionCategory: coretypes.ActionCategoryConfigurationChange,
ActionCategory: 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",
Resource: coretypes.ResourceMetaResourceDashboard,
ResourceID: "019b-5678",
ResourceKind: coretypes.MustNewKind("dashboard"),
},
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: coretypes.ActionCategoryConfigurationChange,
ActionCategory: ActionCategoryConfigurationChange,
Outcome: OutcomeSuccess,
},
principalAttributes: PrincipalAttributes{},
resourceAttributes: ResourceAttributes{
Resource: coretypes.ResourceMetaResourceRule,
ResourceKind: coretypes.MustNewKind("alert-rule"),
},
errorAttributes: ErrorAttributes{},
expectedBody: "updated rule",
expectedBody: "updated alert-rule",
},
{
name: "Failure_AllPresent",
auditAttributes: AuditAttributes{
Action: coretypes.VerbUpdate,
ActionCategory: coretypes.ActionCategoryConfigurationChange,
ActionCategory: 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",
Resource: coretypes.ResourceMetaResourceDashboard,
ResourceID: "019b-5678",
ResourceKind: coretypes.MustNewKind("dashboard"),
},
errorAttributes: ErrorAttributes{
ErrorType: "forbidden",
@@ -169,7 +169,7 @@ func TestNewBody(t *testing.T) {
PrincipalEmail: valuer.MustNewEmail("test@acme.com"),
},
resourceAttributes: ResourceAttributes{
Resource: coretypes.ResourceUser,
ResourceKind: coretypes.MustNewKind("user"),
},
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",
Resource: coretypes.ResourceMetaResourceDashboard,
ResourceID: "019b-5678",
ResourceKind: coretypes.MustNewKind("dashboard"),
},
errorAttributes: ErrorAttributes{},
expectedBody: "test@acme.com (019a1234-abcd-7000-8000-567800000001) failed to create dashboard (019b-5678)",

View File

@@ -1,7 +1,11 @@
package coretypes
package audittypes
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")}
@@ -9,10 +13,6 @@ 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,

View File

@@ -44,8 +44,6 @@ 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,
@@ -53,14 +51,16 @@ func NewAuditEventFromHTTPRequest(
traceID oteltrace.TraceID,
spanID oteltrace.SpanID,
action coretypes.Verb,
actionCategory coretypes.ActionCategory,
actionCategory ActionCategory,
claims authtypes.Claims,
resourceAttributes ResourceAttributes,
resourceID string,
resourceKind coretypes.Kind,
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.Resource.Kind(), auditAttributes.Action),
EventName: NewEventName(resourceAttributes.ResourceKind, 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.Resource.Kind().String(), id: event.ResourceAttributes.ResourceID}
key := resourceKey{kind: event.ResourceAttributes.ResourceKind.String(), id: event.ResourceAttributes.ResourceID}
if _, exists := groups[key]; !exists {
order = append(order, key)
}
@@ -101,8 +101,7 @@ 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)
head := events[groups[key][0]]
head.ResourceAttributes.PutResource(head.PrincipalAttributes.PrincipalOrgID, resourceAttrs)
events[groups[key][0]].ResourceAttributes.PutResource(resourceAttrs)
scopeLogs := resourceLogs.ScopeLogs().AppendEmpty()
scopeLogs.Scope().SetName(scope)

View File

@@ -12,10 +12,10 @@ import (
)
var (
testDashboardResource = coretypes.ResourceMetaResourceDashboard
testDashboardKind = coretypes.MustNewKind("dashboard")
)
func TestNewAuditEvent(t *testing.T) {
func TestNewAuditEventFromHTTPRequest(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 TestNewAuditEvent(t *testing.T) {
route string
statusCode int
action coretypes.Verb
category coretypes.ActionCategory
category ActionCategory
claims authtypes.Claims
resource coretypes.Resource
resourceID string
resourceKind coretypes.Kind
errorType string
errorCode string
expectedOutcome Outcome
@@ -42,10 +42,10 @@ func TestNewAuditEvent(t *testing.T) {
route: "/api/v1/dashboards",
statusCode: http.StatusOK,
action: coretypes.VerbCreate,
category: coretypes.ActionCategoryConfigurationChange,
category: 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 TestNewAuditEvent(t *testing.T) {
route: "/api/v1/dashboards/{id}",
statusCode: http.StatusForbidden,
action: coretypes.VerbUpdate,
category: coretypes.ActionCategoryConfigurationChange,
category: 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,14 +80,15 @@ func TestNewAuditEvent(t *testing.T) {
testCase.action,
testCase.category,
testCase.claims,
NewResourceAttributes(testCase.resource, testCase.resourceID),
testCase.resourceID,
testCase.resourceKind,
testCase.errorType,
testCase.errorCode,
)
assert.Equal(t, testCase.expectedOutcome, event.AuditAttributes.Outcome)
assert.Equal(t, testCase.expectedBody, event.Body)
assert.Equal(t, testCase.resource.Kind(), event.ResourceAttributes.Resource.Kind())
assert.Equal(t, testCase.resourceKind, event.ResourceAttributes.ResourceKind)
assert.Equal(t, testCase.resourceID, event.ResourceAttributes.ResourceID)
assert.Equal(t, testCase.action, event.AuditAttributes.Action)
assert.Equal(t, testCase.category, event.AuditAttributes.ActionCategory)
@@ -102,18 +103,18 @@ func TestNewAuditEvent(t *testing.T) {
}
}
func newTestEvent(resource coretypes.Resource, resourceID string, action coretypes.Verb) AuditEvent {
func newTestEvent(resourceKind coretypes.Kind, resourceID string, action coretypes.Verb) AuditEvent {
return AuditEvent{
Body: resource.Kind().String() + "." + action.PastTense(),
EventName: NewEventName(resource.Kind(), action),
Body: resourceKind.String() + "." + action.PastTense(),
EventName: NewEventName(resourceKind, action),
AuditAttributes: AuditAttributes{
Action: action,
ActionCategory: coretypes.ActionCategoryConfigurationChange,
ActionCategory: ActionCategoryConfigurationChange,
Outcome: OutcomeSuccess,
},
ResourceAttributes: ResourceAttributes{
Resource: resource,
ResourceID: resourceID,
ResourceKind: resourceKind,
ResourceID: resourceID,
},
}
}
@@ -135,7 +136,7 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
{
name: "SingleEvent",
events: []AuditEvent{
newTestEvent(testDashboardResource, "d-001", coretypes.VerbCreate),
newTestEvent(testDashboardKind, "d-001", coretypes.VerbCreate),
},
expectedResourceLogs: 1,
expectedResourceKinds: []string{"dashboard"},
@@ -145,9 +146,9 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
{
name: "SameResource_MultipleEvents",
events: []AuditEvent{
newTestEvent(testDashboardResource, "d-001", coretypes.VerbCreate),
newTestEvent(testDashboardResource, "d-001", coretypes.VerbUpdate),
newTestEvent(testDashboardResource, "d-001", coretypes.VerbDelete),
newTestEvent(testDashboardKind, "d-001", coretypes.VerbCreate),
newTestEvent(testDashboardKind, "d-001", coretypes.VerbUpdate),
newTestEvent(testDashboardKind, "d-001", coretypes.VerbDelete),
},
expectedResourceLogs: 1,
expectedResourceKinds: []string{"dashboard"},
@@ -157,8 +158,8 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
{
name: "DifferentResources_SeparateGroups",
events: []AuditEvent{
newTestEvent(testDashboardResource, "d-001", coretypes.VerbUpdate),
newTestEvent(coretypes.ResourceUser, "u-001", coretypes.VerbDelete),
newTestEvent(testDashboardKind, "d-001", coretypes.VerbUpdate),
newTestEvent(coretypes.MustNewKind("user"), "u-001", coretypes.VerbDelete),
},
expectedResourceLogs: 2,
expectedResourceKinds: []string{"dashboard", "user"},
@@ -168,8 +169,8 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
{
name: "SameKind_DifferentIDs_SeparateGroups",
events: []AuditEvent{
newTestEvent(testDashboardResource, "d-001", coretypes.VerbUpdate),
newTestEvent(testDashboardResource, "d-002", coretypes.VerbDelete),
newTestEvent(testDashboardKind, "d-001", coretypes.VerbUpdate),
newTestEvent(testDashboardKind, "d-002", coretypes.VerbDelete),
},
expectedResourceLogs: 2,
expectedResourceKinds: []string{"dashboard", "dashboard"},
@@ -179,11 +180,11 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
{
name: "InterleavedResources_GroupedCorrectly",
events: []AuditEvent{
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),
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),
},
expectedResourceLogs: 2,
expectedResourceKinds: []string{"dashboard", "user"},
@@ -202,6 +203,7 @@ 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())
@@ -210,6 +212,7 @@ 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())
@@ -218,11 +221,14 @@ 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")

View File

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

View File

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

View File

@@ -1,69 +0,0 @@
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)
}

View File

@@ -1,108 +0,0 @@
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)
}

View File

@@ -1,48 +1,15 @@
package coretypes
import (
"context"
"encoding/json"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
)
import "encoding/json"
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)
}

Some files were not shown because too many files have changed in this diff Show More