mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-04 16:10:26 +01:00
Compare commits
3 Commits
nv/dashboa
...
feat/chart
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71036f4e12 | ||
|
|
5d9f1502f2 | ||
|
|
331daef14b |
File diff suppressed because it is too large
Load Diff
@@ -221,55 +221,6 @@ func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UU
|
||||
return module.pkgDashboardModule.GetV2(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error) {
|
||||
return module.pkgDashboardModule.UpdateV2(ctx, orgID, id, updatedBy, updatable)
|
||||
}
|
||||
|
||||
func (module *module) PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, patch dashboardtypes.PatchableDashboardV2) (*dashboardtypes.DashboardV2, error) {
|
||||
return module.pkgDashboardModule.PatchV2(ctx, orgID, id, updatedBy, patch)
|
||||
}
|
||||
|
||||
func (module *module) DeleteV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
|
||||
return module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
if err := module.store.DeletePublic(ctx, id.String()); err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return err
|
||||
}
|
||||
return module.pkgDashboardModule.DeleteV2(ctx, orgID, id)
|
||||
})
|
||||
}
|
||||
|
||||
func (module *module) LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
|
||||
return module.pkgDashboardModule.LockUnlockV2(ctx, orgID, id, updatedBy, isAdmin, lock)
|
||||
}
|
||||
|
||||
func (module *module) ListV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, params *dashboardtypes.ListDashboardsV2Params) (*dashboardtypes.ListableDashboardV2, error) {
|
||||
return module.pkgDashboardModule.ListV2(ctx, orgID, userID, params)
|
||||
}
|
||||
|
||||
func (module *module) PinV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, id valuer.UUID) error {
|
||||
return module.pkgDashboardModule.PinV2(ctx, orgID, userID, id)
|
||||
}
|
||||
|
||||
func (module *module) UnpinV2(ctx context.Context, userID valuer.UUID, id valuer.UUID) error {
|
||||
return module.pkgDashboardModule.UnpinV2(ctx, userID, id)
|
||||
}
|
||||
|
||||
func (module *module) CreateView(ctx context.Context, orgID valuer.UUID, postable dashboardtypes.PostableDashboardView) (*dashboardtypes.DashboardView, error) {
|
||||
return module.pkgDashboardModule.CreateView(ctx, orgID, postable)
|
||||
}
|
||||
|
||||
func (module *module) ListViews(ctx context.Context, orgID valuer.UUID) (*dashboardtypes.ListableDashboardView, error) {
|
||||
return module.pkgDashboardModule.ListViews(ctx, orgID)
|
||||
}
|
||||
|
||||
func (module *module) UpdateView(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updateable dashboardtypes.UpdateableDashboardView) (*dashboardtypes.DashboardView, error) {
|
||||
return module.pkgDashboardModule.UpdateView(ctx, orgID, id, updateable)
|
||||
}
|
||||
|
||||
func (module *module) DeleteView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
|
||||
return module.pkgDashboardModule.DeleteView(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.Dashboard, error) {
|
||||
return module.pkgDashboardModule.Get(ctx, orgID, id)
|
||||
}
|
||||
|
||||
@@ -16,11 +16,10 @@ func newFormatter(dialect schema.Dialect) sqlstore.SQLFormatter {
|
||||
}
|
||||
|
||||
func (f *formatter) JSONExtractString(column, path string) []byte {
|
||||
ops := f.convertJSONPathToPostgres(path)
|
||||
if len(ops) == 0 {
|
||||
return f.bunf.AppendIdent(nil, column)
|
||||
}
|
||||
return append(f.TextToJsonColumn(column), ops...)
|
||||
var sql []byte
|
||||
sql = f.bunf.AppendIdent(sql, column)
|
||||
sql = append(sql, f.convertJSONPathToPostgres(path)...)
|
||||
return sql
|
||||
}
|
||||
|
||||
func (f *formatter) JSONType(column, path string) []byte {
|
||||
|
||||
@@ -18,19 +18,19 @@ func TestJSONExtractString(t *testing.T) {
|
||||
name: "simple path",
|
||||
column: "data",
|
||||
path: "$.field",
|
||||
expected: `"data"::jsonb->>'field'`,
|
||||
expected: `"data"->>'field'`,
|
||||
},
|
||||
{
|
||||
name: "nested path",
|
||||
column: "metadata",
|
||||
path: "$.user.name",
|
||||
expected: `"metadata"::jsonb->'user'->>'name'`,
|
||||
expected: `"metadata"->'user'->>'name'`,
|
||||
},
|
||||
{
|
||||
name: "deeply nested path",
|
||||
column: "json_col",
|
||||
path: "$.level1.level2.level3",
|
||||
expected: `"json_col"::jsonb->'level1'->'level2'->>'level3'`,
|
||||
expected: `"json_col"->'level1'->'level2'->>'level3'`,
|
||||
},
|
||||
{
|
||||
name: "root path",
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
package postgressqlstore
|
||||
|
||||
// Lives in this package (rather than the listfilter package) so it can use
|
||||
// the unexported newFormatter constructor without driving a real Postgres
|
||||
// connection. Covers the only listfilter cases whose emitted SQL differs
|
||||
// between SQLite and Postgres — the ones that go through JSONExtractString
|
||||
// (`name`, `description`). All other operators (=, !=, BETWEEN, LIKE, IN,
|
||||
// EXISTS, lower(...)) emit identical ANSI SQL on both dialects and are
|
||||
// covered by the SQLite tests in the listfilter package itself.
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/uptrace/bun/dialect/pgdialect"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes/listfilter"
|
||||
)
|
||||
|
||||
func TestListFilterCompile_Postgres(t *testing.T) {
|
||||
f := newFormatter(pgdialect.New())
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
query string
|
||||
wantSQL string
|
||||
wantArgs []any
|
||||
}{
|
||||
{
|
||||
name: "name = uses Postgres -> / ->> chain",
|
||||
query: `name = 'overview'`,
|
||||
wantSQL: `"dashboard"."data"::jsonb->'spec'->'display'->>'name' = ?`,
|
||||
wantArgs: []any{"overview"},
|
||||
},
|
||||
{
|
||||
name: "name CONTAINS — same JSON path, LIKE pattern",
|
||||
query: `name CONTAINS 'overview'`,
|
||||
wantSQL: `"dashboard"."data"::jsonb->'spec'->'display'->>'name' LIKE ?`,
|
||||
wantArgs: []any{"%overview%"},
|
||||
},
|
||||
{
|
||||
name: "name ILIKE — LOWER wraps the JSON path",
|
||||
query: `name ILIKE 'Prod%'`,
|
||||
wantSQL: `lower("dashboard"."data"::jsonb->'spec'->'display'->>'name') LIKE LOWER(?)`,
|
||||
wantArgs: []any{"Prod%"},
|
||||
},
|
||||
{
|
||||
name: "description = follows the same path shape",
|
||||
query: `description = 'd1'`,
|
||||
wantSQL: `"dashboard"."data"::jsonb->'spec'->'display'->>'description' = ?`,
|
||||
wantArgs: []any{"d1"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
out, err := listfilter.Compile(c.query, f)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, out)
|
||||
assert.Equal(t, c.wantSQL, out.SQL)
|
||||
assert.Equal(t, c.wantArgs, out.Args)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,15 @@ if (typeof window.IntersectionObserver === 'undefined') {
|
||||
(window as any).IntersectionObserver = IntersectionObserverMock;
|
||||
}
|
||||
|
||||
if (typeof window.ResizeObserver === 'undefined') {
|
||||
class ResizeObserverMock {
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
(window as any).ResizeObserver = ResizeObserverMock;
|
||||
}
|
||||
|
||||
// Patch getComputedStyle to handle CSS parsing errors from @signozhq/* packages.
|
||||
// These packages inject CSS at import time via style-inject / vite-plugin-css-injected-by-js.
|
||||
// jsdom's nwsapi cannot parse some of the injected selectors (e.g. Tailwind's :animate-in),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4568,54 +4568,6 @@ export interface DashboardtypesDashboardSpecDTO {
|
||||
variables?: DashboardtypesVariableDTO[];
|
||||
}
|
||||
|
||||
export enum DashboardtypesListOrderDTO {
|
||||
asc = 'asc',
|
||||
desc = 'desc',
|
||||
}
|
||||
export enum DashboardtypesListSortDTO {
|
||||
updated_at = 'updated_at',
|
||||
created_at = 'created_at',
|
||||
name = 'name',
|
||||
}
|
||||
export interface DashboardtypesDashboardViewDataDTO {
|
||||
order?: DashboardtypesListOrderDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
query?: string;
|
||||
sort?: DashboardtypesListSortDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface DashboardtypesDashboardViewDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: string;
|
||||
data: DashboardtypesDashboardViewDataDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export enum DashboardtypesDatasourcePluginKindDTO {
|
||||
'signoz/Datasource' = 'signoz/Datasource',
|
||||
}
|
||||
@@ -4701,110 +4653,6 @@ export interface DashboardtypesGettablePublicDashboardDataDTO {
|
||||
publicDashboard?: DashboardtypesGettablePublicDasbhboardDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesPatchOpDTO {
|
||||
add = 'add',
|
||||
remove = 'remove',
|
||||
replace = 'replace',
|
||||
move = 'move',
|
||||
copy = 'copy',
|
||||
test = 'test',
|
||||
}
|
||||
export interface DashboardtypesJSONPatchOperationDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @description Source JSON Pointer for move/copy ops; ignored for other ops.
|
||||
*/
|
||||
from?: string;
|
||||
op: DashboardtypesPatchOpDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description JSON Pointer (RFC 6901) into the dashboard's postable shape — e.g. /spec/display/name, /spec/panels/<id>, /spec/panels/<id>/spec/queries/0, /tags/-.
|
||||
*/
|
||||
path: string;
|
||||
/**
|
||||
* @description Value to add/replace/test against. The expected type depends on the path. Common shapes (see referenced schemas for the exact field set): /spec/panels/<id> takes a DashboardtypesPanel; /spec/panels/<id>/spec/queries/N (or /-) takes a DashboardtypesQuery; /spec/variables/N takes a DashboardtypesVariable; /spec/layouts/N takes a DashboardtypesLayout; /tags/N (or /-) takes a TagtypesPostableTag; /spec/display/name and other leaf string fields take a string. Required for add/replace/test; ignored for remove/move/copy.
|
||||
*/
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
export interface DashboardtypesListedDashboardV2SpecDTO {
|
||||
display?: CommonDisplayDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesListedDashboardV2DTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
createdBy?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
locked: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
pinned: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
schemaVersion: string;
|
||||
source: DashboardtypesSourceDTO;
|
||||
spec: DashboardtypesListedDashboardV2SpecDTO;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
tags: TagtypesPostableTagDTO[];
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export interface DashboardtypesListableDashboardV2DTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
dashboards: DashboardtypesListedDashboardV2DTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
tags: TagtypesPostableTagDTO[];
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface DashboardtypesListableDashboardViewDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
views: DashboardtypesDashboardViewDTO[];
|
||||
}
|
||||
|
||||
export enum DashboardtypesPanelPluginKindDTO {
|
||||
'signoz/TimeSeriesPanel' = 'signoz/TimeSeriesPanel',
|
||||
'signoz/BarChartPanel' = 'signoz/BarChartPanel',
|
||||
@@ -4814,13 +4662,6 @@ export enum DashboardtypesPanelPluginKindDTO {
|
||||
'signoz/HistogramPanel' = 'signoz/HistogramPanel',
|
||||
'signoz/ListPanel' = 'signoz/ListPanel',
|
||||
}
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type DashboardtypesPatchableDashboardV2DTO =
|
||||
| DashboardtypesJSONPatchOperationDTO[]
|
||||
| null;
|
||||
|
||||
export interface DashboardtypesPostableDashboardV2DTO {
|
||||
/**
|
||||
* @type boolean
|
||||
@@ -4845,14 +4686,6 @@ export interface DashboardtypesPostableDashboardV2DTO {
|
||||
tags: TagtypesPostableTagDTO[] | null;
|
||||
}
|
||||
|
||||
export interface DashboardtypesPostableDashboardViewDTO {
|
||||
data: DashboardtypesDashboardViewDataDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface DashboardtypesPostablePublicDashboardDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -4872,26 +4705,6 @@ export enum DashboardtypesQueryPluginKindDTO {
|
||||
'signoz/ClickHouseSQL' = 'signoz/ClickHouseSQL',
|
||||
'signoz/TraceOperator' = 'signoz/TraceOperator',
|
||||
}
|
||||
export interface DashboardtypesUpdatableDashboardV2DTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
image?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
schemaVersion: string;
|
||||
spec: DashboardtypesDashboardSpecDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
tags: TagtypesPostableTagDTO[] | null;
|
||||
}
|
||||
|
||||
export interface DashboardtypesUpdatablePublicDashboardDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -9644,40 +9457,6 @@ export type GetUserPreference200 = {
|
||||
export type UpdateUserPreferencePathParameters = {
|
||||
name: string;
|
||||
};
|
||||
export type ListDashboardsV2Params = {
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
query?: string;
|
||||
/**
|
||||
* @description undefined
|
||||
*/
|
||||
sort?: DashboardtypesListSortDTO;
|
||||
/**
|
||||
* @description undefined
|
||||
*/
|
||||
order?: DashboardtypesListOrderDTO;
|
||||
/**
|
||||
* @type integer
|
||||
* @description undefined
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @description undefined
|
||||
*/
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export type ListDashboardsV2200 = {
|
||||
data: DashboardtypesListableDashboardV2DTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateDashboardV2201 = {
|
||||
data: DashboardtypesGettableDashboardV2DTO;
|
||||
/**
|
||||
@@ -9686,9 +9465,6 @@ export type CreateDashboardV2201 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteDashboardV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetDashboardV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
@@ -9700,70 +9476,6 @@ export type GetDashboardV2200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type PatchDashboardV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type PatchDashboardV2200 = {
|
||||
data: DashboardtypesGettableDashboardV2DTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type UpdateDashboardV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type UpdateDashboardV2200 = {
|
||||
data: DashboardtypesGettableDashboardV2DTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type UnlockDashboardV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type LockDashboardV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type UnpinDashboardV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type PinDashboardV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type ListDashboardViews200 = {
|
||||
data: DashboardtypesListableDashboardViewDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateDashboardView201 = {
|
||||
data: DashboardtypesDashboardViewDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteDashboardViewPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type UpdateDashboardViewPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type UpdateDashboardView200 = {
|
||||
data: DashboardtypesDashboardViewDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetFeatures200 = {
|
||||
/**
|
||||
* @type array
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import ChartLayout from 'container/DashboardContainer/visualization/layout/ChartLayout/ChartLayout';
|
||||
import Legend from 'lib/uPlotV2/components/Legend/Legend';
|
||||
import UPlotLegend from 'lib/uPlotV2/components/Legend/UPlotLegend';
|
||||
import {
|
||||
LegendPosition,
|
||||
TooltipRenderArgs,
|
||||
@@ -47,7 +47,7 @@ export default function ChartWrapper({
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Legend
|
||||
<UPlotLegend
|
||||
config={config}
|
||||
position={legendConfig.position}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
.pieChartWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pieChartNoData {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
// Size is set inline from the computed chart dimensions (mirrors the uPlot
|
||||
// chart/legend split); this just centres the donut within that box.
|
||||
.pieChartContainer {
|
||||
flex: 0 0 auto;
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pieChartTooltip {
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||
background-color: var(--l2-background) !important;
|
||||
border: 1px solid var(--l2-border) !important;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.pieTooltipContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pieChartIndicator {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
margin-right: 8px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.tooltipValue {
|
||||
font-weight: bold;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
// Wraps the shared chart Legend. Its width/height are set inline from the
|
||||
// computed chart dimensions, so the VirtuosoGrid inside gets the same bounded
|
||||
// box (right column / bottom rows) the uPlot charts use.
|
||||
.pieLegend {
|
||||
flex: 0 0 auto;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
padding: 8px;
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Group } from '@visx/group';
|
||||
import { Pie as VisxPie } from '@visx/shape';
|
||||
import { defaultStyles, useTooltip, useTooltipInPortal } from '@visx/tooltip';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import Legend from 'lib/uPlotV2/components/Legend/Legend';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
|
||||
import { PieChartProps, PieSlice } from '../types';
|
||||
import { calculateChartDimensions } from '../utils';
|
||||
|
||||
import PieArc from './PieArc';
|
||||
import PieCenterLabel from './PieCenterLabel';
|
||||
import styles from './Pie.module.scss';
|
||||
import { usePieInteractions } from './usePieInteractions';
|
||||
import { getFillColor } from './utils';
|
||||
|
||||
interface PieTooltipData {
|
||||
label: string;
|
||||
value: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Donut chart rendered with @visx. Splits its area into chart + legend with the
|
||||
* same `calculateChartDimensions` logic as the uPlot charts (right column /
|
||||
* up-to-two bottom rows), renders the shared chart Legend, and delegates the
|
||||
* arcs, centre total and interaction state to PieArc / PieCenterLabel /
|
||||
* usePieInteractions. Pure presentation — slices are pre-resolved by the caller.
|
||||
*/
|
||||
export default function Pie({
|
||||
data,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
isDarkMode,
|
||||
position = LegendPosition.BOTTOM,
|
||||
id,
|
||||
onSliceClick,
|
||||
'data-testid': testId,
|
||||
}: PieChartProps): JSX.Element {
|
||||
const {
|
||||
active,
|
||||
setActive,
|
||||
visibleData,
|
||||
legendItems,
|
||||
focusedSeriesIndex,
|
||||
onLegendClick,
|
||||
onLegendMouseMove,
|
||||
onLegendMouseLeave,
|
||||
} = usePieInteractions(data, id);
|
||||
|
||||
const {
|
||||
tooltipOpen,
|
||||
tooltipLeft,
|
||||
tooltipTop,
|
||||
tooltipData,
|
||||
hideTooltip,
|
||||
showTooltip,
|
||||
} = useTooltip<PieTooltipData>();
|
||||
|
||||
const { containerRef, TooltipInPortal } = useTooltipInPortal({
|
||||
scroll: true,
|
||||
detectBounds: true,
|
||||
});
|
||||
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const { width: containerWidth, height: containerHeight } =
|
||||
useResizeObserver(wrapperRef);
|
||||
|
||||
// Reuse the uPlot chart/legend split so the donut + legend get the same area
|
||||
// allocation (right column, or up-to-two bottom rows) as every other panel.
|
||||
const dimensions = useMemo(
|
||||
() =>
|
||||
calculateChartDimensions({
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
legendConfig: { position },
|
||||
seriesLabels: data.map((slice) => slice.label),
|
||||
}),
|
||||
[containerWidth, containerHeight, position, data],
|
||||
);
|
||||
|
||||
const size = Math.min(dimensions.width, dimensions.height);
|
||||
const radius = size * 0.35;
|
||||
const innerRadius = radius * 0.6;
|
||||
const totalValue = visibleData.reduce((sum, slice) => sum + slice.value, 0);
|
||||
const labelColor = isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_400;
|
||||
const activeColor = active?.color ?? null;
|
||||
|
||||
const handleSliceEnter = useCallback(
|
||||
(slice: PieSlice, centroidX: number, centroidY: number): void => {
|
||||
showTooltip({
|
||||
tooltipData: {
|
||||
label: slice.label,
|
||||
value: getYAxisFormattedValue(
|
||||
slice.value.toString(),
|
||||
yAxisUnit || 'none',
|
||||
decimalPrecision,
|
||||
),
|
||||
color: slice.color,
|
||||
},
|
||||
tooltipTop: centroidY + dimensions.height / 2,
|
||||
tooltipLeft: centroidX + dimensions.width / 2,
|
||||
});
|
||||
setActive(slice);
|
||||
},
|
||||
[
|
||||
showTooltip,
|
||||
setActive,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
dimensions.height,
|
||||
dimensions.width,
|
||||
],
|
||||
);
|
||||
|
||||
const handleSliceLeave = useCallback((): void => {
|
||||
hideTooltip();
|
||||
setActive(null);
|
||||
}, [hideTooltip, setActive]);
|
||||
|
||||
if (!data.length) {
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={styles.pieChartWrapper}
|
||||
data-testid={testId}
|
||||
>
|
||||
<div className={styles.pieChartNoData}>No data</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isRightLegend = position === LegendPosition.RIGHT;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={styles.pieChartWrapper}
|
||||
style={{ flexDirection: isRightLegend ? 'row' : 'column' }}
|
||||
data-testid={testId}
|
||||
>
|
||||
<div
|
||||
className={styles.pieChartContainer}
|
||||
style={{ width: dimensions.width, height: dimensions.height }}
|
||||
>
|
||||
{size > 0 && (
|
||||
<svg
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
ref={containerRef}
|
||||
>
|
||||
<Group top={dimensions.height / 2} left={dimensions.width / 2}>
|
||||
<VisxPie
|
||||
data={visibleData}
|
||||
pieValue={(slice: PieSlice): number => slice.value}
|
||||
outerRadius={radius}
|
||||
innerRadius={innerRadius}
|
||||
padAngle={0.01}
|
||||
cornerRadius={3}
|
||||
width={size}
|
||||
height={size}
|
||||
>
|
||||
{(pie): JSX.Element[] =>
|
||||
pie.arcs.map((arc) => (
|
||||
<PieArc
|
||||
key={`arc-${arc.data.label}-${arc.data.value}-${arc.startAngle.toFixed(
|
||||
6,
|
||||
)}`}
|
||||
slice={arc.data}
|
||||
arcPath={pie.path(arc) || ''}
|
||||
centroid={pie.path.centroid(arc)}
|
||||
startAngle={arc.startAngle}
|
||||
endAngle={arc.endAngle}
|
||||
radius={radius}
|
||||
totalValue={totalValue}
|
||||
yAxisUnit={yAxisUnit}
|
||||
decimalPrecision={decimalPrecision}
|
||||
labelColor={labelColor}
|
||||
fill={getFillColor(arc.data.color, activeColor)}
|
||||
onEnter={handleSliceEnter}
|
||||
onLeave={handleSliceLeave}
|
||||
onClick={onSliceClick}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</VisxPie>
|
||||
<PieCenterLabel
|
||||
total={totalValue}
|
||||
yAxisUnit={yAxisUnit}
|
||||
decimalPrecision={decimalPrecision}
|
||||
radius={radius}
|
||||
innerRadius={innerRadius}
|
||||
color={labelColor}
|
||||
/>
|
||||
</Group>
|
||||
</svg>
|
||||
)}
|
||||
{tooltipOpen && tooltipData && (
|
||||
<TooltipInPortal
|
||||
top={tooltipTop}
|
||||
left={tooltipLeft}
|
||||
className={styles.pieChartTooltip}
|
||||
style={{
|
||||
...defaultStyles,
|
||||
color: labelColor,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={styles.pieChartIndicator}
|
||||
style={{ background: tooltipData.color }}
|
||||
/>
|
||||
<div className={styles.pieTooltipContent}>
|
||||
<span>{tooltipData.label}</span>
|
||||
<span className={styles.tooltipValue}>{tooltipData.value}</span>
|
||||
</div>
|
||||
</TooltipInPortal>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={styles.pieLegend}
|
||||
style={{
|
||||
width: dimensions.legendWidth,
|
||||
height: dimensions.legendHeight,
|
||||
}}
|
||||
>
|
||||
<Legend
|
||||
items={legendItems}
|
||||
position={position}
|
||||
averageLegendWidth={dimensions.averageLegendWidth}
|
||||
focusedSeriesIndex={focusedSeriesIndex}
|
||||
onClick={onLegendClick}
|
||||
onMouseMove={onLegendMouseMove}
|
||||
onMouseLeave={onLegendMouseLeave}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { PrecisionOption } from 'components/Graph/types';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
|
||||
import { PieSlice } from '../types';
|
||||
|
||||
import { getArcGeometry } from './utils';
|
||||
|
||||
// Slices below this share of the total don't get a leader label (too cramped).
|
||||
const MIN_LABEL_SHARE = 0.03;
|
||||
const MAX_LABEL_LENGTH = 15;
|
||||
|
||||
interface PieArcProps {
|
||||
slice: PieSlice;
|
||||
/** SVG path `d` for the arc, from the visx pie generator. */
|
||||
arcPath: string;
|
||||
/** Arc centroid `[x, y]`, used to anchor the leader line and tooltip. */
|
||||
centroid: [number, number];
|
||||
startAngle: number;
|
||||
endAngle: number;
|
||||
radius: number;
|
||||
/** Sum of visible slice values — drives the show-label threshold. */
|
||||
totalValue: number;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
labelColor: string;
|
||||
/** Resolved fill (already dimmed if another slice is active). */
|
||||
fill: string;
|
||||
onEnter: (slice: PieSlice, centroidX: number, centroidY: number) => void;
|
||||
onLeave: () => void;
|
||||
onClick?: (slice: PieSlice) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single donut slice: the arc path plus, for non-tiny slices, a leader line
|
||||
* out to an external label + value. Pure presentation — interaction is
|
||||
* delegated to the `onEnter`/`onLeave`/`onClick` callbacks.
|
||||
*/
|
||||
export default function PieArc({
|
||||
slice,
|
||||
arcPath,
|
||||
centroid,
|
||||
startAngle,
|
||||
endAngle,
|
||||
radius,
|
||||
totalValue,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
labelColor,
|
||||
fill,
|
||||
onEnter,
|
||||
onLeave,
|
||||
onClick,
|
||||
}: PieArcProps): JSX.Element {
|
||||
const { label, value } = slice;
|
||||
const [centroidX, centroidY] = centroid;
|
||||
const { labelX, labelY, lineEndX, lineEndY, textAnchor } = getArcGeometry(
|
||||
startAngle,
|
||||
endAngle,
|
||||
radius,
|
||||
);
|
||||
|
||||
const displayValue = getYAxisFormattedValue(
|
||||
value.toString(),
|
||||
yAxisUnit || 'none',
|
||||
decimalPrecision,
|
||||
);
|
||||
const shortenedLabel =
|
||||
label.length > MAX_LABEL_LENGTH ? `${label.substring(0, 12)}...` : label;
|
||||
const shouldShowLabel = value / totalValue > MIN_LABEL_SHARE;
|
||||
|
||||
return (
|
||||
<g
|
||||
onMouseEnter={(): void => onEnter(slice, centroidX, centroidY)}
|
||||
onMouseLeave={onLeave}
|
||||
onClick={(): void => onClick?.(slice)}
|
||||
>
|
||||
<path d={arcPath} fill={fill} />
|
||||
{shouldShowLabel && (
|
||||
<>
|
||||
<line
|
||||
x1={centroidX}
|
||||
y1={centroidY}
|
||||
x2={lineEndX}
|
||||
y2={lineEndY}
|
||||
stroke={labelColor}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<line
|
||||
x1={lineEndX}
|
||||
y1={lineEndY}
|
||||
x2={labelX}
|
||||
y2={labelY}
|
||||
stroke={labelColor}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<text
|
||||
x={labelX}
|
||||
y={labelY - 8}
|
||||
dy=".33em"
|
||||
fill={labelColor}
|
||||
fontSize={10}
|
||||
textAnchor={textAnchor}
|
||||
pointerEvents="none"
|
||||
>
|
||||
{shortenedLabel}
|
||||
</text>
|
||||
<text
|
||||
x={labelX}
|
||||
y={labelY + 8}
|
||||
dy=".33em"
|
||||
fill={labelColor}
|
||||
fontSize={10}
|
||||
fontWeight="bold"
|
||||
textAnchor={textAnchor}
|
||||
pointerEvents="none"
|
||||
>
|
||||
{displayValue}
|
||||
</text>
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { PrecisionOption } from 'components/Graph/types';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
|
||||
import { getScaledFontSize } from './utils';
|
||||
|
||||
interface PieCenterLabelProps {
|
||||
/** Sum of the visible slice values, shown in the donut hole. */
|
||||
total: number;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
radius: number;
|
||||
innerRadius: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The total shown in the centre of the donut. Splits the formatted value into
|
||||
* its numeric part and unit so each can be sized independently, and scales the
|
||||
* numeric font down for long values so it never overflows the hole.
|
||||
*/
|
||||
export default function PieCenterLabel({
|
||||
total,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
radius,
|
||||
innerRadius,
|
||||
color,
|
||||
}: PieCenterLabelProps): JSX.Element {
|
||||
const formattedTotal = getYAxisFormattedValue(
|
||||
total.toString(),
|
||||
yAxisUnit || 'none',
|
||||
decimalPrecision,
|
||||
);
|
||||
const matches = formattedTotal.match(/([\d.]+[KMB]?)(.*)$/);
|
||||
const numericTotal = matches?.[1] || formattedTotal;
|
||||
const unitTotal = matches?.[2]?.trim() || '';
|
||||
|
||||
const numericFontSize = getScaledFontSize({
|
||||
text: numericTotal,
|
||||
baseSize: radius * 0.3,
|
||||
innerRadius,
|
||||
});
|
||||
const unitFontSize = numericFontSize * 0.5;
|
||||
|
||||
return (
|
||||
<text textAnchor="middle" dominantBaseline="central" fill={color}>
|
||||
<tspan fontSize={numericFontSize} fontWeight="bold">
|
||||
{numericTotal}
|
||||
</tspan>
|
||||
{unitTotal && (
|
||||
<tspan fontSize={unitFontSize} opacity={0.9} dx={2}>
|
||||
{unitTotal}
|
||||
</tspan>
|
||||
)}
|
||||
</text>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import { PieSlice } from '../../types';
|
||||
import Pie from '../Pie';
|
||||
|
||||
jest.mock('hooks/useDimensions', () => ({
|
||||
useResizeObserver: jest.fn().mockReturnValue({ width: 400, height: 300 }),
|
||||
}));
|
||||
|
||||
jest.mock('components/Graph/yAxisConfig', () => ({
|
||||
getYAxisFormattedValue: jest.fn((value: string) => value),
|
||||
}));
|
||||
|
||||
// VirtuosoGrid only renders a window in jsdom; render every item so we can
|
||||
// assert on legend entries.
|
||||
jest.mock('react-virtuoso', () => ({
|
||||
VirtuosoGrid: ({
|
||||
data,
|
||||
itemContent,
|
||||
}: {
|
||||
data: LegendItem[];
|
||||
itemContent: (index: number, item: LegendItem) => React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<div data-testid="virtuoso-grid">
|
||||
{data.map((item, index) => (
|
||||
<div key={item.seriesIndex ?? index}>{itemContent(index, item)}</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const DATA: PieSlice[] = [
|
||||
{ label: 'frontend', value: 100, color: '#aa0000' },
|
||||
{ label: 'cart', value: 60, color: '#00aa00' },
|
||||
{ label: 'checkout', value: 40, color: '#0000aa' },
|
||||
];
|
||||
|
||||
function renderPie(
|
||||
props: Partial<React.ComponentProps<typeof Pie>> = {},
|
||||
): void {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<Pie data={DATA} isDarkMode={false} data-testid="pie" {...props} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('Pie', () => {
|
||||
it('renders the "No data" state for empty data', () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<Pie data={[]} isDarkMode={false} data-testid="pie" />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.getByText('No data')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders one arc per slice plus the legend entries and centre total', () => {
|
||||
renderPie();
|
||||
|
||||
const svg = screen.getByTestId('pie').querySelector('svg') as SVGElement;
|
||||
expect(svg.querySelectorAll('path')).toHaveLength(DATA.length);
|
||||
|
||||
const legend = screen.getByTestId('virtuoso-grid');
|
||||
expect(within(legend).getByText('frontend')).toBeInTheDocument();
|
||||
expect(within(legend).getByText('cart')).toBeInTheDocument();
|
||||
expect(within(legend).getByText('checkout')).toBeInTheDocument();
|
||||
|
||||
// Centre total = 100 + 60 + 40 (formatter mocked to echo the value).
|
||||
expect(screen.getByText('200')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lays the legend out in a row for the right position and a column for bottom', () => {
|
||||
const { rerender } = render(
|
||||
<TooltipProvider>
|
||||
<Pie
|
||||
data={DATA}
|
||||
isDarkMode={false}
|
||||
position={LegendPosition.RIGHT}
|
||||
data-testid="pie"
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.getByTestId('pie')).toHaveStyle({ flexDirection: 'row' });
|
||||
|
||||
rerender(
|
||||
<TooltipProvider>
|
||||
<Pie
|
||||
data={DATA}
|
||||
isDarkMode={false}
|
||||
position={LegendPosition.BOTTOM}
|
||||
data-testid="pie"
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.getByTestId('pie')).toHaveStyle({ flexDirection: 'column' });
|
||||
});
|
||||
|
||||
it('hides a slice when its legend marker is clicked', () => {
|
||||
renderPie();
|
||||
const svg = screen.getByTestId('pie').querySelector('svg') as SVGElement;
|
||||
expect(svg.querySelectorAll('path')).toHaveLength(3);
|
||||
|
||||
const marker = document.querySelector(
|
||||
'[data-legend-item-id="1"] [data-is-legend-marker="true"]',
|
||||
) as HTMLElement;
|
||||
fireEvent.click(marker);
|
||||
|
||||
// One slice hidden → one fewer arc drawn.
|
||||
expect(svg.querySelectorAll('path')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import { PieSlice } from '../../types';
|
||||
import PieArc from '../PieArc';
|
||||
|
||||
jest.mock('components/Graph/yAxisConfig', () => ({
|
||||
// Echo the raw value so assertions are deterministic.
|
||||
getYAxisFormattedValue: jest.fn((value: string) => value),
|
||||
}));
|
||||
|
||||
const SLICE: PieSlice = { label: 'frontend', value: 50, color: '#f00' };
|
||||
|
||||
function renderArc(props: Partial<React.ComponentProps<typeof PieArc>> = {}): {
|
||||
onEnter: jest.Mock;
|
||||
onLeave: jest.Mock;
|
||||
onClick: jest.Mock;
|
||||
container: HTMLElement;
|
||||
} {
|
||||
const onEnter = jest.fn();
|
||||
const onLeave = jest.fn();
|
||||
const onClick = jest.fn();
|
||||
const { container } = render(
|
||||
<svg>
|
||||
<PieArc
|
||||
slice={SLICE}
|
||||
arcPath="M0,0L1,1"
|
||||
centroid={[10, 20]}
|
||||
startAngle={0}
|
||||
endAngle={Math.PI}
|
||||
radius={100}
|
||||
totalValue={100}
|
||||
labelColor="#fff"
|
||||
fill="#f00"
|
||||
onEnter={onEnter}
|
||||
onLeave={onLeave}
|
||||
onClick={onClick}
|
||||
{...props}
|
||||
/>
|
||||
</svg>,
|
||||
);
|
||||
return { onEnter, onLeave, onClick, container };
|
||||
}
|
||||
|
||||
describe('PieArc', () => {
|
||||
it('renders the arc path with the resolved fill', () => {
|
||||
const { container } = renderArc();
|
||||
const path = container.querySelector('path');
|
||||
expect(path).toHaveAttribute('d', 'M0,0L1,1');
|
||||
expect(path).toHaveAttribute('fill', '#f00');
|
||||
});
|
||||
|
||||
it('shows the leader label + value for a slice above the threshold', () => {
|
||||
renderArc(); // 50 / 100 = 0.5
|
||||
expect(screen.getByText('frontend')).toBeInTheDocument();
|
||||
expect(screen.getByText('50')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the leader label for a slice below the 3% threshold', () => {
|
||||
renderArc({ totalValue: 10000 }); // 50 / 10000 = 0.005
|
||||
expect(screen.queryByText('frontend')).not.toBeInTheDocument();
|
||||
// the arc path itself still renders
|
||||
expect(screen.queryByText('50')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('truncates labels longer than 15 chars', () => {
|
||||
renderArc({
|
||||
slice: { label: 'a-really-long-service-name', value: 50, color: '#f00' },
|
||||
});
|
||||
expect(screen.getByText('a-really-lon...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fires onEnter with the slice + centroid, and onLeave / onClick', () => {
|
||||
const { onEnter, onLeave, onClick, container } = renderArc();
|
||||
const g = container.querySelector('g') as SVGGElement;
|
||||
|
||||
fireEvent.mouseEnter(g);
|
||||
expect(onEnter).toHaveBeenCalledWith(SLICE, 10, 20);
|
||||
|
||||
fireEvent.mouseLeave(g);
|
||||
expect(onLeave).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(g);
|
||||
expect(onClick).toHaveBeenCalledWith(SLICE);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
|
||||
import PieCenterLabel from '../PieCenterLabel';
|
||||
|
||||
jest.mock('components/Graph/yAxisConfig', () => ({
|
||||
getYAxisFormattedValue: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockFormat = getYAxisFormattedValue as jest.MockedFunction<
|
||||
typeof getYAxisFormattedValue
|
||||
>;
|
||||
|
||||
function renderInSvg(node: JSX.Element): ReturnType<typeof render> {
|
||||
// PieCenterLabel returns an SVG <text>, so it needs an <svg> host.
|
||||
return render(<svg>{node}</svg>);
|
||||
}
|
||||
|
||||
describe('PieCenterLabel', () => {
|
||||
const baseProps = {
|
||||
total: 3700,
|
||||
radius: 100,
|
||||
innerRadius: 60,
|
||||
color: '#fff',
|
||||
};
|
||||
|
||||
it('renders the formatted total (numeric + unit suffix) as one numeric tspan when there is no separate unit', () => {
|
||||
mockFormat.mockReturnValue('3.7K');
|
||||
renderInSvg(<PieCenterLabel {...baseProps} />);
|
||||
expect(screen.getByText('3.7K')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('splits the numeric part and the trailing unit into separate tspans', () => {
|
||||
mockFormat.mockReturnValue('1.2 MB');
|
||||
renderInSvg(<PieCenterLabel {...baseProps} />);
|
||||
expect(screen.getByText('1.2')).toBeInTheDocument();
|
||||
expect(screen.getByText('MB')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes the unit + precision through to the formatter', () => {
|
||||
mockFormat.mockReturnValue('100');
|
||||
renderInSvg(<PieCenterLabel {...baseProps} total={100} yAxisUnit="bytes" />);
|
||||
expect(mockFormat).toHaveBeenCalledWith('100', 'bytes', undefined);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import {
|
||||
getStoredSeriesVisibility,
|
||||
updateSeriesVisibilityToLocalStorage,
|
||||
} from 'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils';
|
||||
import type { MouseEvent } from 'react';
|
||||
|
||||
import { PieSlice } from '../../types';
|
||||
import { usePieInteractions } from '../usePieInteractions';
|
||||
|
||||
jest.mock(
|
||||
'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils',
|
||||
);
|
||||
|
||||
const mockGetStored = getStoredSeriesVisibility as jest.MockedFunction<
|
||||
typeof getStoredSeriesVisibility
|
||||
>;
|
||||
const mockUpdateStored =
|
||||
updateSeriesVisibilityToLocalStorage as jest.MockedFunction<
|
||||
typeof updateSeriesVisibilityToLocalStorage
|
||||
>;
|
||||
|
||||
const DATA: PieSlice[] = [
|
||||
{ label: 'frontend', value: 100, color: '#a' },
|
||||
{ label: 'cart', value: 60, color: '#b' },
|
||||
{ label: 'checkout', value: 40, color: '#c' },
|
||||
];
|
||||
|
||||
// Builds a fake legend click/move event: `e.target.closest('[data-legend-item-id]')`
|
||||
// resolves to the item at `index`, and `e.target.dataset.isLegendMarker` flags marker clicks.
|
||||
function legendEvent(
|
||||
index: number | null,
|
||||
isMarker = false,
|
||||
): MouseEvent<HTMLDivElement> {
|
||||
const itemEl =
|
||||
index == null ? null : { dataset: { legendItemId: String(index) } };
|
||||
return {
|
||||
target: {
|
||||
closest: (): unknown => itemEl,
|
||||
dataset: { isLegendMarker: isMarker ? 'true' : undefined },
|
||||
},
|
||||
} as unknown as MouseEvent<HTMLDivElement>;
|
||||
}
|
||||
|
||||
describe('usePieInteractions', () => {
|
||||
beforeEach(() => {
|
||||
mockGetStored.mockReturnValue(null);
|
||||
mockUpdateStored.mockReset();
|
||||
});
|
||||
|
||||
it('starts with everything visible and nothing focused', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
|
||||
expect(result.current.visibleData).toStrictEqual(DATA);
|
||||
expect(result.current.legendItems.map((i) => i.show)).toStrictEqual([
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
]);
|
||||
expect(result.current.focusedSeriesIndex).toBeNull();
|
||||
expect(result.current.active).toBeNull();
|
||||
});
|
||||
|
||||
describe('marker click (toggle one)', () => {
|
||||
it('hides then unhides the clicked slice', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA, 'panel-1'));
|
||||
|
||||
act(() => result.current.onLegendClick(legendEvent(1, true)));
|
||||
|
||||
expect(result.current.visibleData).toStrictEqual([DATA[0], DATA[2]]);
|
||||
expect(result.current.legendItems[1].show).toBe(false);
|
||||
expect(mockUpdateStored).toHaveBeenLastCalledWith('panel-1', [
|
||||
{ label: 'frontend', show: true },
|
||||
{ label: 'cart', show: false },
|
||||
{ label: 'checkout', show: true },
|
||||
]);
|
||||
|
||||
act(() => result.current.onLegendClick(legendEvent(1, true)));
|
||||
|
||||
expect(result.current.visibleData).toStrictEqual(DATA);
|
||||
expect(result.current.legendItems[1].show).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('label click (isolate / reset)', () => {
|
||||
it('isolates the clicked slice, then resets on a second click', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
|
||||
act(() => result.current.onLegendClick(legendEvent(0, false)));
|
||||
|
||||
expect(result.current.visibleData).toStrictEqual([DATA[0]]);
|
||||
expect(result.current.legendItems.map((i) => i.show)).toStrictEqual([
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
|
||||
act(() => result.current.onLegendClick(legendEvent(0, false)));
|
||||
|
||||
expect(result.current.visibleData).toStrictEqual(DATA);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hover', () => {
|
||||
it('focuses the hovered slice and clears on leave', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
|
||||
act(() => result.current.onLegendMouseMove(legendEvent(2)));
|
||||
expect(result.current.active).toStrictEqual(DATA[2]);
|
||||
expect(result.current.focusedSeriesIndex).toBe(2);
|
||||
|
||||
act(() => result.current.onLegendMouseLeave());
|
||||
expect(result.current.active).toBeNull();
|
||||
expect(result.current.focusedSeriesIndex).toBeNull();
|
||||
});
|
||||
|
||||
it('does not focus a hidden slice', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
|
||||
act(() => result.current.onLegendClick(legendEvent(1, true))); // hide cart
|
||||
act(() => result.current.onLegendMouseMove(legendEvent(1)));
|
||||
|
||||
expect(result.current.active).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistence', () => {
|
||||
it('does not write to storage when no id is provided', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
act(() => result.current.onLegendClick(legendEvent(0, true)));
|
||||
expect(mockUpdateStored).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rehydrates hidden slices from storage on mount (matched by label)', () => {
|
||||
mockGetStored.mockReturnValue([
|
||||
{ label: 'frontend', show: true },
|
||||
{ label: 'cart', show: false },
|
||||
{ label: 'checkout', show: true },
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() => usePieInteractions(DATA, 'panel-1'));
|
||||
|
||||
expect(result.current.visibleData).toStrictEqual([DATA[0], DATA[2]]);
|
||||
expect(result.current.legendItems[1].show).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
getArcGeometry,
|
||||
getFillColor,
|
||||
getScaledFontSize,
|
||||
lightenColor,
|
||||
} from '../utils';
|
||||
|
||||
describe('Pie utils', () => {
|
||||
describe('getScaledFontSize', () => {
|
||||
it('returns the base size for empty text', () => {
|
||||
expect(getScaledFontSize({ text: '', baseSize: 30, innerRadius: 100 })).toBe(
|
||||
30,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not scale short text (length <= 3)', () => {
|
||||
// scaleFactor = max(0.3, 1) = 1 → baseSize, capped by innerRadius * 0.9.
|
||||
expect(
|
||||
getScaledFontSize({ text: '3.7', baseSize: 30, innerRadius: 100 }),
|
||||
).toBe(30);
|
||||
});
|
||||
|
||||
it('scales longer text down', () => {
|
||||
// length 8 → scaleFactor = max(0.3, 1 - 5 * 0.09) = 0.55 → 30 * 0.55.
|
||||
expect(
|
||||
getScaledFontSize({ text: '12345678', baseSize: 30, innerRadius: 100 }),
|
||||
).toBeCloseTo(16.5);
|
||||
});
|
||||
|
||||
it('floors the scale factor at 0.3 for very long text', () => {
|
||||
// length 20 → 1 - 17 * 0.09 < 0.3 → floored to 0.3 → 100 * 0.3.
|
||||
expect(
|
||||
getScaledFontSize({
|
||||
text: '12345678901234567890',
|
||||
baseSize: 100,
|
||||
innerRadius: 1000,
|
||||
}),
|
||||
).toBeCloseTo(30);
|
||||
});
|
||||
|
||||
it('caps the size at 90% of the inner radius', () => {
|
||||
expect(
|
||||
getScaledFontSize({ text: '3.7', baseSize: 200, innerRadius: 10 }),
|
||||
).toBeCloseTo(9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getArcGeometry', () => {
|
||||
it('places the label below for a slice centred at the top (angle 0)', () => {
|
||||
const g = getArcGeometry(0, 0, 100);
|
||||
expect(g.labelX).toBeCloseTo(0);
|
||||
expect(g.labelY).toBeCloseTo(-130);
|
||||
expect(g.lineEndX).toBeCloseTo(0);
|
||||
expect(g.lineEndY).toBeCloseTo(-110);
|
||||
// sin(0) is not > 0 → anchor end.
|
||||
expect(g.textAnchor).toBe('end');
|
||||
});
|
||||
|
||||
it('anchors to the start on the right half (angle pi/2)', () => {
|
||||
const g = getArcGeometry(0, Math.PI, 100);
|
||||
expect(g.labelX).toBeCloseTo(130);
|
||||
expect(g.labelY).toBeCloseTo(0);
|
||||
expect(g.textAnchor).toBe('start');
|
||||
});
|
||||
|
||||
it('anchors to the end on the left half (angle 3pi/2)', () => {
|
||||
const g = getArcGeometry(Math.PI, 2 * Math.PI, 100);
|
||||
expect(g.labelX).toBeCloseTo(-130);
|
||||
expect(g.textAnchor).toBe('end');
|
||||
});
|
||||
});
|
||||
|
||||
describe('lightenColor', () => {
|
||||
it('converts a #rrggbb hex to rgba at the given opacity', () => {
|
||||
expect(lightenColor('#ff0000', 0.4)).toBe('rgba(255, 0, 0, 0.4)');
|
||||
});
|
||||
|
||||
it('accepts hex without a leading #', () => {
|
||||
expect(lightenColor('00ff00', 0.4)).toBe('rgba(0, 255, 0, 0.4)');
|
||||
});
|
||||
|
||||
it('returns the original colour when it is not parseable hex', () => {
|
||||
expect(lightenColor('rgba(0,0,0,1)', 0.4)).toBe('rgba(0,0,0,1)');
|
||||
expect(lightenColor('red', 0.4)).toBe('red');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFillColor', () => {
|
||||
it('returns the colour unchanged when nothing is active', () => {
|
||||
expect(getFillColor('#ff0000', null)).toBe('#ff0000');
|
||||
});
|
||||
|
||||
it('returns the colour unchanged for the active slice', () => {
|
||||
expect(getFillColor('#ff0000', '#ff0000')).toBe('#ff0000');
|
||||
});
|
||||
|
||||
it('dims non-active slices to 40% opacity', () => {
|
||||
expect(getFillColor('#00ff00', '#ff0000')).toBe('rgba(0, 255, 0, 0.4)');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import type { Dispatch, MouseEvent, SetStateAction } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
getStoredSeriesVisibility,
|
||||
updateSeriesVisibilityToLocalStorage,
|
||||
} from '../../panels/utils/legendVisibilityUtils';
|
||||
import { PieSlice } from '../types';
|
||||
|
||||
export interface UsePieInteractionsResult {
|
||||
/** The hovered/focused slice (drives donut dimming + tooltip). */
|
||||
active: PieSlice | null;
|
||||
setActive: Dispatch<SetStateAction<PieSlice | null>>;
|
||||
/** Slices currently shown (hidden ones removed). */
|
||||
visibleData: PieSlice[];
|
||||
/** Legend item per slice (`show` reflects hide state). */
|
||||
legendItems: LegendItem[];
|
||||
/** Index of the active slice for the legend's focus highlight, or null. */
|
||||
focusedSeriesIndex: number | null;
|
||||
onLegendClick: (e: MouseEvent<HTMLDivElement>) => void;
|
||||
onLegendMouseMove: (e: MouseEvent<HTMLDivElement>) => void;
|
||||
onLegendMouseLeave: () => void;
|
||||
}
|
||||
|
||||
// Reads the slice index off the nearest `[data-legend-item-id]` ancestor of the
|
||||
// event target (the shared Legend tags each item with its seriesIndex).
|
||||
function getLegendIndex(e: MouseEvent<HTMLDivElement>): number | null {
|
||||
const el = (e.target as HTMLElement | null)?.closest<HTMLElement>(
|
||||
'[data-legend-item-id]',
|
||||
);
|
||||
const id = el?.dataset.legendItemId;
|
||||
return id != null ? Number(id) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pie interaction + derived state: hover/focus, slice hide/unhide (mirroring the
|
||||
* uPlot legend — marker toggles one, label isolates), and persistence of the
|
||||
* hidden set to localStorage (keyed by `id`, matched by label) so it survives
|
||||
* reloads. Returns the visible slices, legend items, focus index, and the
|
||||
* legend container handlers.
|
||||
*/
|
||||
export function usePieInteractions(
|
||||
data: PieSlice[],
|
||||
id?: string,
|
||||
): UsePieInteractionsResult {
|
||||
const [active, setActive] = useState<PieSlice | null>(null);
|
||||
const [hiddenIndices, setHiddenIndices] = useState<Set<number>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const isolatedIndexRef = useRef<number | null>(null);
|
||||
|
||||
const legendItems = useMemo<LegendItem[]>(
|
||||
() =>
|
||||
data.map((slice, index) => ({
|
||||
seriesIndex: index,
|
||||
label: slice.label,
|
||||
color: slice.color,
|
||||
show: !hiddenIndices.has(index),
|
||||
})),
|
||||
[data, hiddenIndices],
|
||||
);
|
||||
|
||||
// Hidden slices drop out so the remaining arcs + centre total recompute.
|
||||
const visibleData = useMemo(
|
||||
() => data.filter((_, index) => !hiddenIndices.has(index)),
|
||||
[data, hiddenIndices],
|
||||
);
|
||||
|
||||
// Rehydrate hide/unhide from localStorage (matched by label) whenever the
|
||||
// data set changes — including first load and every refetch, since the store
|
||||
// is the source of truth and toggles write back to it.
|
||||
useEffect(() => {
|
||||
if (!id || !data.length) {
|
||||
return;
|
||||
}
|
||||
const stored = getStoredSeriesVisibility(id);
|
||||
if (!stored) {
|
||||
return;
|
||||
}
|
||||
const hidden = new Set<number>();
|
||||
data.forEach((slice, index) => {
|
||||
if (stored.find((s) => s.label === slice.label)?.show === false) {
|
||||
hidden.add(index);
|
||||
}
|
||||
});
|
||||
setHiddenIndices(hidden);
|
||||
}, [id, data]);
|
||||
|
||||
// Apply a new hidden set and persist it (label + show) to localStorage.
|
||||
const applyHidden = useCallback(
|
||||
(hidden: Set<number>): void => {
|
||||
setHiddenIndices(hidden);
|
||||
if (id) {
|
||||
updateSeriesVisibilityToLocalStorage(
|
||||
id,
|
||||
data.map((slice, index) => ({
|
||||
label: slice.label,
|
||||
show: !hidden.has(index),
|
||||
})),
|
||||
);
|
||||
}
|
||||
},
|
||||
[id, data],
|
||||
);
|
||||
|
||||
const onLegendMouseMove = useCallback(
|
||||
(e: MouseEvent<HTMLDivElement>): void => {
|
||||
const index = getLegendIndex(e);
|
||||
// Don't focus/dim for hidden slices — they aren't on the donut.
|
||||
setActive(index != null && !hiddenIndices.has(index) ? data[index] : null);
|
||||
},
|
||||
[data, hiddenIndices],
|
||||
);
|
||||
|
||||
// Marker click toggles just that slice on/off; label click isolates it
|
||||
// (clicking the isolated one again resets to all) — mirrors the uPlot legend.
|
||||
const onLegendClick = useCallback(
|
||||
(e: MouseEvent<HTMLDivElement>): void => {
|
||||
const index = getLegendIndex(e);
|
||||
if (index == null) {
|
||||
return;
|
||||
}
|
||||
const isMarker = (e.target as HTMLElement).dataset.isLegendMarker;
|
||||
|
||||
if (isMarker) {
|
||||
const next = new Set(hiddenIndices);
|
||||
if (next.has(index)) {
|
||||
next.delete(index);
|
||||
} else {
|
||||
next.add(index);
|
||||
}
|
||||
applyHidden(next);
|
||||
return;
|
||||
}
|
||||
|
||||
const isReset = isolatedIndexRef.current === index;
|
||||
isolatedIndexRef.current = isReset ? null : index;
|
||||
if (isReset) {
|
||||
applyHidden(new Set());
|
||||
return;
|
||||
}
|
||||
const next = new Set<number>();
|
||||
data.forEach((_, i) => {
|
||||
if (i !== index) {
|
||||
next.add(i);
|
||||
}
|
||||
});
|
||||
applyHidden(next);
|
||||
},
|
||||
[data, hiddenIndices, applyHidden],
|
||||
);
|
||||
|
||||
const onLegendMouseLeave = useCallback((): void => setActive(null), []);
|
||||
|
||||
const focusedIndex = active ? data.indexOf(active) : -1;
|
||||
|
||||
return {
|
||||
active,
|
||||
setActive,
|
||||
visibleData,
|
||||
legendItems,
|
||||
focusedSeriesIndex: focusedIndex >= 0 ? focusedIndex : null,
|
||||
onLegendClick,
|
||||
onLegendMouseMove,
|
||||
onLegendMouseLeave,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Pure presentation helpers for the Pie chart. Kept out of the component file
|
||||
* so the renderer stays declarative (per the one-component-per-file rule).
|
||||
*/
|
||||
|
||||
interface ScaledFontSizeArgs {
|
||||
text: string;
|
||||
baseSize: number;
|
||||
innerRadius: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shrinks the centre-total font as the text gets longer so it never overflows
|
||||
* the donut hole. Ported from the V1 PiePanelWrapper.
|
||||
*/
|
||||
export function getScaledFontSize({
|
||||
text,
|
||||
baseSize,
|
||||
innerRadius,
|
||||
}: ScaledFontSizeArgs): number {
|
||||
if (!text) {
|
||||
return baseSize;
|
||||
}
|
||||
|
||||
const { length } = text;
|
||||
// More aggressive scaling for very long numbers.
|
||||
const scaleFactor = Math.max(0.3, 1 - (length - 3) * 0.09);
|
||||
// Don't use more than 90% of the inner radius.
|
||||
const maxSize = innerRadius * 0.9;
|
||||
|
||||
return Math.min(baseSize * scaleFactor, maxSize);
|
||||
}
|
||||
|
||||
export interface ArcGeometry {
|
||||
/** Outer point where the leader label sits. */
|
||||
labelX: number;
|
||||
labelY: number;
|
||||
/** Elbow point where the leader line bends toward the label. */
|
||||
lineEndX: number;
|
||||
lineEndY: number;
|
||||
/** Anchor the label left/right depending on which half of the circle it's in. */
|
||||
textAnchor: 'start' | 'end';
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the leader-line / label geometry for one arc from its angular span.
|
||||
* Pulled out of the render prop so the SVG markup stays declarative.
|
||||
*/
|
||||
export function getArcGeometry(
|
||||
startAngle: number,
|
||||
endAngle: number,
|
||||
radius: number,
|
||||
): ArcGeometry {
|
||||
const angle = (startAngle + endAngle) / 2;
|
||||
const labelRadius = radius * 1.3;
|
||||
const lineEndRadius = radius * 1.1;
|
||||
return {
|
||||
labelX: Math.sin(angle) * labelRadius,
|
||||
labelY: -Math.cos(angle) * labelRadius,
|
||||
lineEndX: Math.sin(angle) * lineEndRadius,
|
||||
lineEndY: -Math.cos(angle) * lineEndRadius,
|
||||
textAnchor: Math.sin(angle) > 0 ? 'start' : 'end',
|
||||
};
|
||||
}
|
||||
|
||||
interface ParsedRgb {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
// Parses `#rrggbb` into its components. Returns null for anything else (e.g. an
|
||||
// already-rgba string), letting callers fall back to the original colour.
|
||||
function hexToRgb(color: string): ParsedRgb | null {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(color);
|
||||
return result
|
||||
? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an rgba() string for `color` at the given opacity. Used to dim the
|
||||
* non-hovered slices. Falls back to the original colour if it can't be parsed.
|
||||
*/
|
||||
export function lightenColor(color: string, opacity: number): string {
|
||||
const rgb = hexToRgb(color);
|
||||
if (!rgb) {
|
||||
return color;
|
||||
}
|
||||
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${opacity})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the fill for a slice given the currently-hovered slice colour:
|
||||
* everything but the active slice dims to 40% opacity. With nothing hovered
|
||||
* (`activeColor === null`) every slice keeps its full colour.
|
||||
*/
|
||||
export function getFillColor(
|
||||
color: string,
|
||||
activeColor: string | null,
|
||||
): string {
|
||||
if (activeColor === null) {
|
||||
return color;
|
||||
}
|
||||
return activeColor === color ? color : lightenColor(color, 0.4);
|
||||
}
|
||||
@@ -3,13 +3,14 @@ import { PrecisionOption } from 'components/Graph/types';
|
||||
import {
|
||||
IRenderTooltipFooterArgs,
|
||||
LegendConfig,
|
||||
LegendPosition,
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import {
|
||||
DashboardCursorSync,
|
||||
SyncTooltipFilterMode,
|
||||
TooltipClickData,
|
||||
ChartClickData,
|
||||
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
@@ -22,10 +23,10 @@ interface BaseChartProps {
|
||||
/** Key that pins the tooltip while hovering. Defaults to DEFAULT_PIN_TOOLTIP_KEY ('l'). */
|
||||
pinKey?: string;
|
||||
/** Called when the user clicks the uPlot overlay. Receives resolved click data. */
|
||||
onClick?: (clickData: TooltipClickData) => void;
|
||||
onClick?: (clickData: ChartClickData) => void;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
pinnedTooltipElement?: (clickData: TooltipClickData) => React.ReactNode;
|
||||
pinnedTooltipElement?: (clickData: ChartClickData) => React.ReactNode;
|
||||
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => React.ReactNode;
|
||||
customTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
|
||||
'data-testid'?: string;
|
||||
@@ -69,3 +70,36 @@ export type ChartProps =
|
||||
| TimeSeriesChartProps
|
||||
| BarChartProps
|
||||
| HistogramChartProps;
|
||||
|
||||
/**
|
||||
* One resolved pie/donut slice: a display label, its (already parsed) positive
|
||||
* numeric value, and the colour used for the arc + legend swatch.
|
||||
*/
|
||||
export interface PieSlice {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the Pie chart. Unlike the others above, Pie is NOT uPlot-based
|
||||
* (it renders with @visx), so it deliberately does not extend BaseChartProps /
|
||||
* UPlotBasedChartProps — it takes pre-resolved slices and self-measures its
|
||||
* draw area rather than receiving a uPlot config + aligned data.
|
||||
*/
|
||||
export interface PieChartProps {
|
||||
data: PieSlice[];
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
isDarkMode: boolean;
|
||||
/** Legend placement. Drives the chart-vs-legend layout. Default BOTTOM. */
|
||||
position?: LegendPosition;
|
||||
/**
|
||||
* Widget id used to persist per-slice hide/unhide state to localStorage
|
||||
* (shared GRAPH_VISIBILITY_STATES, keyed by label). Omit to disable persistence.
|
||||
*/
|
||||
id?: string;
|
||||
/** Fired when a slice (or its legend entry) is clicked. */
|
||||
onSliceClick?: (slice: PieSlice) => void;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
@@ -171,17 +171,20 @@
|
||||
}
|
||||
|
||||
.legend-copy-button {
|
||||
display: none;
|
||||
// Always laid out (space reserved) but transparent, so revealing it on
|
||||
// hover fades the icon in without reflowing the row / shifting the label.
|
||||
display: flex;
|
||||
opacity: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
padding: 2px;
|
||||
margin: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--l2-foreground);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
opacity: 1;
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
color 0.15s ease;
|
||||
@@ -192,9 +195,8 @@
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--l1-foreground) 5%, transparent);
|
||||
background: var(--l3-background);
|
||||
.legend-copy-button {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,40 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { VirtuosoGrid } from 'react-virtuoso';
|
||||
import { Input, Tooltip as AntdTooltip } from 'antd';
|
||||
import { Input } from 'antd';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import cx from 'classnames';
|
||||
import { useCopyToClipboard } from 'hooks/useCopyToClipboard';
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
|
||||
import { Check, Copy } from '@signozhq/icons';
|
||||
|
||||
import { useLegendActions } from '../../hooks/useLegendActions';
|
||||
import { LegendPosition, LegendProps } from '../types';
|
||||
|
||||
import './Legend.styles.scss';
|
||||
|
||||
export const MAX_LEGEND_WIDTH = 240;
|
||||
|
||||
/**
|
||||
* Presentational legend. Renders the supplied `items` (markers + labels, an
|
||||
* optional copy button, and a search box for the RIGHT position) and delegates
|
||||
* all interaction to the container handlers. Source-agnostic — the uPlot
|
||||
* charts feed it via UPlotLegend; Pie feeds it directly.
|
||||
*/
|
||||
export default function Legend({
|
||||
items,
|
||||
position = LegendPosition.BOTTOM,
|
||||
config,
|
||||
averageLegendWidth = MAX_LEGEND_WIDTH,
|
||||
focusedSeriesIndex = null,
|
||||
onClick,
|
||||
onMouseMove,
|
||||
onMouseLeave,
|
||||
showCopy = true,
|
||||
showSearch,
|
||||
}: LegendProps): JSX.Element {
|
||||
const { legendItemsMap, focusedSeriesIndex, setFocusedSeriesIndex } =
|
||||
useLegendsSync({ config });
|
||||
const { onLegendClick, onLegendMouseMove, onLegendMouseLeave } =
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex,
|
||||
focusedSeriesIndex,
|
||||
});
|
||||
const legendContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [legendSearchQuery, setLegendSearchQuery] = useState('');
|
||||
const { copyToClipboard, id: copiedId } = useCopyToClipboard();
|
||||
|
||||
const legendItems = useMemo(
|
||||
() => Object.values(legendItemsMap),
|
||||
[legendItemsMap],
|
||||
);
|
||||
const searchEnabled = showSearch ?? position === LegendPosition.RIGHT;
|
||||
|
||||
const isSingleRow = useMemo(() => {
|
||||
if (!legendContainerRef.current || position !== LegendPosition.BOTTOM) {
|
||||
@@ -41,21 +42,19 @@ export default function Legend({
|
||||
}
|
||||
const containerWidth = legendContainerRef.current.clientWidth;
|
||||
|
||||
const totalLegendWidth = legendItems.length * (averageLegendWidth + 16);
|
||||
const totalLegendWidth = items.length * (averageLegendWidth + 16);
|
||||
const totalRows = Math.ceil(totalLegendWidth / containerWidth);
|
||||
return totalRows <= 1;
|
||||
}, [averageLegendWidth, legendContainerRef, legendItems.length, position]);
|
||||
}, [averageLegendWidth, items.length, position]);
|
||||
|
||||
const visibleLegendItems = useMemo(() => {
|
||||
if (position !== LegendPosition.RIGHT || !legendSearchQuery.trim()) {
|
||||
return legendItems;
|
||||
if (!searchEnabled || !legendSearchQuery.trim()) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const query = legendSearchQuery.trim().toLowerCase();
|
||||
return legendItems.filter((item) =>
|
||||
item.label?.toLowerCase().includes(query),
|
||||
);
|
||||
}, [position, legendSearchQuery, legendItems]);
|
||||
return items.filter((item) => item.label?.toLowerCase().includes(query));
|
||||
}, [searchEnabled, legendSearchQuery, items]);
|
||||
|
||||
const handleCopyLegendItem = useCallback(
|
||||
(e: React.MouseEvent, seriesIndex: number, label: string): void => {
|
||||
@@ -68,6 +67,9 @@ export default function Legend({
|
||||
const renderLegendItem = useCallback(
|
||||
(item: LegendItem): JSX.Element => {
|
||||
const isCopied = copiedId === item.seriesIndex;
|
||||
// `color` is uPlot's stroke union (string | fn | gradient); only a string
|
||||
// is a usable CSS colour for the marker.
|
||||
const markerColor = typeof item.color === 'string' ? item.color : undefined;
|
||||
return (
|
||||
<div
|
||||
key={item.seriesIndex}
|
||||
@@ -77,54 +79,56 @@ export default function Legend({
|
||||
'legend-item-focused': focusedSeriesIndex === item.seriesIndex,
|
||||
})}
|
||||
>
|
||||
<AntdTooltip title={item.label}>
|
||||
<TooltipSimple title={item.label} arrow side="top">
|
||||
<div className="legend-item-label-trigger">
|
||||
<div
|
||||
className="legend-marker"
|
||||
style={{ borderColor: String(item.color) }}
|
||||
style={{ borderColor: markerColor }}
|
||||
data-is-legend-marker={true}
|
||||
/>
|
||||
<span className="legend-label">{item.label}</span>
|
||||
</div>
|
||||
</AntdTooltip>
|
||||
<AntdTooltip title={isCopied ? 'Copied' : 'Copy'}>
|
||||
<button
|
||||
type="button"
|
||||
className="legend-copy-button"
|
||||
onClick={(e): void =>
|
||||
handleCopyLegendItem(e, item.seriesIndex, item.label ?? '')
|
||||
}
|
||||
aria-label={`Copy ${item.label}`}
|
||||
data-testid="legend-copy"
|
||||
>
|
||||
{isCopied ? <Check size={12} /> : <Copy size={12} />}
|
||||
</button>
|
||||
</AntdTooltip>
|
||||
</TooltipSimple>
|
||||
{showCopy && (
|
||||
<TooltipSimple title={isCopied ? 'Copied' : 'Copy'} arrow side="top">
|
||||
<button
|
||||
type="button"
|
||||
className="legend-copy-button"
|
||||
onClick={(e): void =>
|
||||
handleCopyLegendItem(e, item.seriesIndex, item.label ?? '')
|
||||
}
|
||||
aria-label={`Copy ${item.label}`}
|
||||
data-testid="legend-copy"
|
||||
>
|
||||
{isCopied ? <Check size={12} /> : <Copy size={12} />}
|
||||
</button>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[copiedId, focusedSeriesIndex, handleCopyLegendItem, position],
|
||||
[copiedId, focusedSeriesIndex, handleCopyLegendItem, position, showCopy],
|
||||
);
|
||||
|
||||
const isEmptyState = useMemo(() => {
|
||||
if (position !== LegendPosition.RIGHT || !legendSearchQuery.trim()) {
|
||||
if (!searchEnabled || !legendSearchQuery.trim()) {
|
||||
return false;
|
||||
}
|
||||
return visibleLegendItems.length === 0;
|
||||
}, [position, legendSearchQuery, visibleLegendItems]);
|
||||
}, [searchEnabled, legendSearchQuery, visibleLegendItems]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={legendContainerRef}
|
||||
className="legend-container"
|
||||
onClick={onLegendClick}
|
||||
onMouseMove={onLegendMouseMove}
|
||||
onMouseLeave={onLegendMouseLeave}
|
||||
onClick={onClick}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseLeave={onMouseLeave}
|
||||
style={{
|
||||
['--legend-average-width' as string]: `${averageLegendWidth + 16}px`, // 16px is the marker width
|
||||
}}
|
||||
>
|
||||
{position === LegendPosition.RIGHT && (
|
||||
{searchEnabled && (
|
||||
<div className="legend-search-container">
|
||||
<Input
|
||||
allowClear
|
||||
|
||||
41
frontend/src/lib/uPlotV2/components/Legend/UPlotLegend.tsx
Normal file
41
frontend/src/lib/uPlotV2/components/Legend/UPlotLegend.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useMemo } from 'react';
|
||||
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
|
||||
|
||||
import { useLegendActions } from '../../hooks/useLegendActions';
|
||||
import { LegendPosition, UPlotLegendProps } from '../types';
|
||||
|
||||
import Legend from './Legend';
|
||||
|
||||
/**
|
||||
* uPlot legend controller. Derives the legend items + focus/visibility state
|
||||
* from the chart config (useLegendsSync) and the toggle/focus interactions from
|
||||
* the plot context (useLegendActions), then renders the presentational Legend.
|
||||
* Must be rendered inside a PlotContextProvider.
|
||||
*/
|
||||
export default function UPlotLegend({
|
||||
position = LegendPosition.BOTTOM,
|
||||
config,
|
||||
averageLegendWidth,
|
||||
}: UPlotLegendProps): JSX.Element {
|
||||
const { legendItemsMap, focusedSeriesIndex, setFocusedSeriesIndex } =
|
||||
useLegendsSync({ config });
|
||||
const { onLegendClick, onLegendMouseMove, onLegendMouseLeave } =
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex,
|
||||
focusedSeriesIndex,
|
||||
});
|
||||
|
||||
const items = useMemo(() => Object.values(legendItemsMap), [legendItemsMap]);
|
||||
|
||||
return (
|
||||
<Legend
|
||||
items={items}
|
||||
position={position}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
focusedSeriesIndex={focusedSeriesIndex}
|
||||
onClick={onLegendClick}
|
||||
onMouseMove={onLegendMouseMove}
|
||||
onMouseLeave={onLegendMouseLeave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -7,11 +7,12 @@ import {
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
|
||||
|
||||
import { useLegendActions } from '../../hooks/useLegendActions';
|
||||
import Legend from '../Legend/Legend';
|
||||
import UPlotLegend from '../Legend/UPlotLegend';
|
||||
import { LegendPosition } from '../types';
|
||||
|
||||
const mockWriteText = jest.fn().mockResolvedValue(undefined);
|
||||
@@ -47,7 +48,7 @@ const mockUseLegendActions = useLegendActions as jest.MockedFunction<
|
||||
typeof useLegendActions
|
||||
>;
|
||||
|
||||
describe('Legend', () => {
|
||||
describe('UPlotLegend', () => {
|
||||
beforeAll(() => {
|
||||
// JSDOM does not define navigator.clipboard; add it so we can spy on writeText
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
@@ -115,11 +116,13 @@ describe('Legend', () => {
|
||||
|
||||
const renderLegend = (position?: LegendPosition): RenderResult =>
|
||||
render(
|
||||
<Legend
|
||||
position={position}
|
||||
// config is not used directly in the component, it's consumed by the mocked hook
|
||||
config={{} as any}
|
||||
/>,
|
||||
<TooltipProvider>
|
||||
<UPlotLegend
|
||||
position={position}
|
||||
// config is consumed by the mocked useLegendsSync hook, not directly
|
||||
config={{} as any}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
describe('layout and position', () => {
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { MouseEventHandler, ReactNode } from 'react';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
|
||||
import { LegendItem } from '../config/types';
|
||||
import { SyncTooltipFilterMode } from '../plugins/TooltipPlugin/types';
|
||||
|
||||
/**
|
||||
@@ -109,7 +110,33 @@ export enum LegendPosition {
|
||||
export interface LegendConfig {
|
||||
position: LegendPosition;
|
||||
}
|
||||
/**
|
||||
* Presentational legend props. Source-agnostic: it renders whatever
|
||||
* `items` it's given and delegates interaction to the container handlers, so
|
||||
* it serves both uPlot charts (via UPlotLegend) and non-uPlot charts (Pie).
|
||||
*/
|
||||
export interface LegendProps {
|
||||
items: LegendItem[];
|
||||
position?: LegendPosition;
|
||||
averageLegendWidth?: number;
|
||||
/** Series index to highlight (hovered/focused). */
|
||||
focusedSeriesIndex?: number | null;
|
||||
/**
|
||||
* Container-delegated handlers. Items carry `data-legend-item-id`, so the
|
||||
* handler reads the target's id rather than binding per item.
|
||||
*/
|
||||
onClick?: MouseEventHandler<HTMLDivElement>;
|
||||
onMouseMove?: MouseEventHandler<HTMLDivElement>;
|
||||
onMouseLeave?: () => void;
|
||||
/** Show the per-item copy button. Default true. */
|
||||
showCopy?: boolean;
|
||||
/** Show the filter search box. Default: only for the RIGHT position. */
|
||||
showSearch?: boolean;
|
||||
}
|
||||
|
||||
/** Props for the uPlot legend controller, which derives items + interaction
|
||||
* from the chart config and renders the presentational Legend. */
|
||||
export interface UPlotLegendProps {
|
||||
position?: LegendPosition;
|
||||
config: UPlotConfigBuilder;
|
||||
averageLegendWidth?: number;
|
||||
|
||||
@@ -37,7 +37,7 @@ export interface TooltipViewState {
|
||||
isHovering: boolean;
|
||||
isPinned: boolean;
|
||||
dismiss: () => void;
|
||||
clickData: TooltipClickData | null;
|
||||
clickData: ChartClickData | null;
|
||||
contents?: ReactNode;
|
||||
}
|
||||
|
||||
@@ -59,17 +59,17 @@ export interface TooltipPluginProps {
|
||||
/** Key that pins the tooltip while hovering. Defaults to DEFAULT_PIN_TOOLTIP_KEY ('l'). */
|
||||
pinKey?: string;
|
||||
/** Called when the user clicks the uPlot overlay. Receives resolved click data. */
|
||||
onClick?: (clickData: TooltipClickData) => void;
|
||||
onClick?: (clickData: ChartClickData) => void;
|
||||
syncMode?: DashboardCursorSync;
|
||||
syncKey?: string;
|
||||
syncMetadata?: TooltipSyncMetadata;
|
||||
render: (args: TooltipRenderArgs) => ReactNode;
|
||||
pinnedTooltipElement?: (clickData: TooltipClickData) => ReactNode;
|
||||
pinnedTooltipElement?: (clickData: ChartClickData) => ReactNode;
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
||||
export interface TooltipClickData {
|
||||
export interface ChartClickData {
|
||||
xValue: number;
|
||||
yValue: number;
|
||||
focusedSeries: {
|
||||
@@ -101,7 +101,7 @@ export interface TooltipControllerState {
|
||||
hoverActive: boolean;
|
||||
isAnySeriesActive: boolean;
|
||||
pinned: boolean;
|
||||
clickData: TooltipClickData | null;
|
||||
clickData: ChartClickData | null;
|
||||
style: TooltipViewState['style'];
|
||||
horizontalOffset: number;
|
||||
verticalOffset: number;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { getFocusedSeriesAtPosition } from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
|
||||
import {
|
||||
TOOLTIP_OFFSET,
|
||||
TooltipClickData,
|
||||
ChartClickData,
|
||||
TooltipLayoutInfo,
|
||||
TooltipViewState,
|
||||
} from './types';
|
||||
@@ -167,14 +167,11 @@ export function createLayoutObserver(
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a TooltipClickData snapshot from a MouseEvent (real or synthetic)
|
||||
* Resolves a ChartClickData snapshot from a MouseEvent (real or synthetic)
|
||||
* and the current uPlot instance. Shared by the overlay click handler and the
|
||||
* keyboard-pin handler (which synthesises an event from the cursor position).
|
||||
*/
|
||||
export function buildClickData(
|
||||
event: MouseEvent,
|
||||
plot: uPlot,
|
||||
): TooltipClickData {
|
||||
export function buildClickData(event: MouseEvent, plot: uPlot): ChartClickData {
|
||||
const xValue = plot.posToVal(event.offsetX, 'x');
|
||||
const yValue = plot.posToVal(event.offsetY, 'y');
|
||||
const focusedSeries = getFocusedSeriesAtPosition(event, plot);
|
||||
|
||||
1
go.mod
1
go.mod
@@ -18,7 +18,6 @@ require (
|
||||
github.com/dgraph-io/ristretto/v2 v2.3.0
|
||||
github.com/dustin/go-humanize v1.0.1
|
||||
github.com/emersion/go-smtp v0.24.0
|
||||
github.com/evanphx/json-patch/v5 v5.9.11
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/go-co-op/gocron v1.30.1
|
||||
github.com/go-openapi/runtime v0.29.2
|
||||
|
||||
2
go.sum
2
go.sum
@@ -311,8 +311,6 @@ github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQ
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
|
||||
github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU=
|
||||
github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM=
|
||||
github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb h1:IT4JYU7k4ikYg1SCxNI1/Tieq/NFvh6dzLdgi7eu0tM=
|
||||
github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb/go.mod h1:bH6Xx7IW64qjjJq8M2u4dxNaBiDfKK+z/3eGDpXEQhc=
|
||||
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
|
||||
|
||||
@@ -14,93 +14,6 @@ import (
|
||||
)
|
||||
|
||||
func (provider *provider) addDashboardRoutes(router *mux.Router) error {
|
||||
// Saved-view routes are registered before `/api/v2/dashboards/{id}` so the
|
||||
// literal `views` segment isn't swallowed by the `{id}` pattern.
|
||||
if err := router.Handle("/api/v2/dashboards/views", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.ListViews), handler.OpenAPIDef{
|
||||
ID: "ListDashboardViews",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "List dashboard saved views",
|
||||
Description: "Returns every saved view in the calling user's org. Saved views are shared org-wide; any user may read, create, edit, and delete any view.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(dashboardtypes.ListableDashboardView),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards/views", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.CreateView), handler.OpenAPIDef{
|
||||
ID: "CreateDashboardView",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Create dashboard saved view",
|
||||
Description: "Persists the calling user's dashboard listing state (query, sort, order) as a named, reusable view shared across the org.",
|
||||
Request: new(dashboardtypes.PostableDashboardView),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(dashboardtypes.GettableDashboardView),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards/views/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.UpdateView), handler.OpenAPIDef{
|
||||
ID: "UpdateDashboardView",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Update dashboard saved view",
|
||||
Description: "Replaces a saved view's name and data. Saved views are shared org-wide; any user in the org may edit any view.",
|
||||
Request: new(dashboardtypes.UpdateableDashboardView),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(dashboardtypes.GettableDashboardView),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards/views/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.DeleteView), handler.OpenAPIDef{
|
||||
ID: "DeleteDashboardView",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Delete dashboard saved view",
|
||||
Description: "Removes a saved view. Saved views are shared org-wide; any user in the org may delete any view. Idempotent — deleting a non-existent view returns 404.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.ListV2), handler.OpenAPIDef{
|
||||
ID: "ListDashboardsV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "List dashboards (v2)",
|
||||
Description: "Returns a page of v2-shape dashboards for the calling user's org. Supports a filter DSL (`query`), sort (`updated_at`/`created_at`/`name`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`). Pinned dashboards float to the top of each page.",
|
||||
Request: new(dashboardtypes.ListDashboardsV2Params),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(dashboardtypes.ListableDashboardV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.CreateV2), handler.OpenAPIDef{
|
||||
ID: "CreateDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
@@ -111,10 +24,9 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
|
||||
Response: new(dashboardtypes.GettableDashboardV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
// TODO: add http.StatusConflict once the dashboard name unique index is added.
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -129,140 +41,13 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
|
||||
Response: new(dashboardtypes.GettableDashboardV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.UpdateV2), handler.OpenAPIDef{
|
||||
ID: "UpdateDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Update dashboard (v2)",
|
||||
Description: "This endpoint updates a v2-shape dashboard's metadata, data, and tag set. Locked dashboards are rejected.",
|
||||
Request: new(dashboardtypes.UpdatableDashboardV2),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(dashboardtypes.GettableDashboardV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.PatchV2), handler.OpenAPIDef{
|
||||
ID: "PatchDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Patch dashboard (v2)",
|
||||
Description: "This endpoint applies an RFC 6902 JSON Patch to a v2-shape dashboard. The patch is applied against the postable view of the dashboard (metadata, data, tags), so individual panels, queries, variables, layouts, or tags can be updated without re-sending the rest of the dashboard. Apply is lenient: `remove` on a missing path is a no-op (idempotent) and `add` creates any missing parent objects, rather than failing as strict RFC 6902 would. The resulting dashboard is still validated. Locked dashboards are rejected.",
|
||||
Request: new(dashboardtypes.PatchableDashboardV2),
|
||||
// Strictly per RFC 6902 the content type is `application/json-patch+json`,
|
||||
// but our OpenAPI generator only reflects schemas for content types it
|
||||
// understands (application/json, form-urlencoded, multipart) — anything
|
||||
// else degrades to `type: string`. Declaring application/json here keeps
|
||||
// the array-of-ops schema visible to spec consumers; the runtime decoder
|
||||
// parses JSON regardless of the request's actual Content-Type header.
|
||||
RequestContentType: "application/json",
|
||||
Response: new(dashboardtypes.GettableDashboardV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPatch).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.DeleteV2), handler.OpenAPIDef{
|
||||
ID: "DeleteDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Delete dashboard (v2)",
|
||||
Description: "This endpoint deletes a v2-shape dashboard along with its tag relations. Locked dashboards are rejected.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards/{id}/lock", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.LockV2), handler.OpenAPIDef{
|
||||
ID: "LockDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Lock dashboard (v2)",
|
||||
Description: "This endpoint locks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards/{id}/lock", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.UnlockV2), handler.OpenAPIDef{
|
||||
ID: "UnlockDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Unlock dashboard (v2)",
|
||||
Description: "This endpoint unlocks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// ViewAccess: pinning only mutates the calling user's pin list, not the
|
||||
// dashboard itself — anyone who can view a dashboard can bookmark it.
|
||||
if err := router.Handle("/api/v2/dashboards/{id}/pins/me", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.PinV2), handler.OpenAPIDef{
|
||||
ID: "PinDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Pin a dashboard for the current user (v2)",
|
||||
Description: "Pins the dashboard for the calling user. A user can pin at most 10 dashboards; pinning when at the limit returns 409. Re-pinning an already-pinned dashboard is a no-op success.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards/{id}/pins/me", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.UnpinV2), handler.OpenAPIDef{
|
||||
ID: "UnpinDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Unpin a dashboard for the current user (v2)",
|
||||
Description: "Removes the pin for the calling user. Idempotent — unpinning a dashboard that wasn't pinned still returns 204.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/dashboards/{id}/public", handler.New(provider.authzMiddleware.AdminAccess(provider.dashboardHandler.CreatePublic), handler.OpenAPIDef{
|
||||
ID: "CreatePublicDashboard",
|
||||
Tags: []string{"dashboard"},
|
||||
|
||||
@@ -60,28 +60,6 @@ type Module interface {
|
||||
CreateV2(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, source dashboardtypes.Source, postable dashboardtypes.PostableDashboardV2) (*dashboardtypes.DashboardV2, error)
|
||||
|
||||
GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error)
|
||||
|
||||
ListV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, params *dashboardtypes.ListDashboardsV2Params) (*dashboardtypes.ListableDashboardV2, error)
|
||||
|
||||
UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error)
|
||||
|
||||
LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error
|
||||
|
||||
PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, patch dashboardtypes.PatchableDashboardV2) (*dashboardtypes.DashboardV2, error)
|
||||
|
||||
PinV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, id valuer.UUID) error
|
||||
|
||||
UnpinV2(ctx context.Context, userID valuer.UUID, id valuer.UUID) error
|
||||
|
||||
DeleteV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error
|
||||
|
||||
CreateView(ctx context.Context, orgID valuer.UUID, postable dashboardtypes.PostableDashboardView) (*dashboardtypes.DashboardView, error)
|
||||
|
||||
ListViews(ctx context.Context, orgID valuer.UUID) (*dashboardtypes.ListableDashboardView, error)
|
||||
|
||||
UpdateView(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updateable dashboardtypes.UpdateableDashboardView) (*dashboardtypes.DashboardView, error)
|
||||
|
||||
DeleteView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error
|
||||
}
|
||||
|
||||
type Handler interface {
|
||||
@@ -111,28 +89,4 @@ type Handler interface {
|
||||
CreateV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
GetV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
ListV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
UpdateV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
LockV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
UnlockV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
PatchV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
PinV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
UnpinV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
DeleteV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
CreateView(http.ResponseWriter, *http.Request)
|
||||
|
||||
ListViews(http.ResponseWriter, *http.Request)
|
||||
|
||||
UpdateView(http.ResponseWriter, *http.Request)
|
||||
|
||||
DeleteView(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
@@ -2,12 +2,10 @@ package impldashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes/listfilter"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
@@ -65,96 +63,6 @@ func (store *store) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID)
|
||||
return storableDashboard, nil
|
||||
}
|
||||
|
||||
// ListV2 emits the joined dashboard ⨝ pinned_dashboard query the spec calls
|
||||
// for. Aliases:
|
||||
//
|
||||
// dashboard — the visitor expects this
|
||||
// pinned_dashboard AS pin — only used inside this query
|
||||
//
|
||||
// Sort is "is_pinned DESC, <sort> <order>" so pinned dashboards float to the
|
||||
// top inside the requested ordering. Name-sort goes through the same
|
||||
// JSONExtractString path the visitor uses for name/description filtering.
|
||||
func (store *store) ListV2(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
userID valuer.UUID,
|
||||
params *dashboardtypes.ListDashboardsV2Params,
|
||||
) ([]*dashboardtypes.DashboardListRow, int64, error) {
|
||||
compiled, err := listfilter.Compile(params.Query, store.sqlstore.Formatter())
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
type listedRow struct {
|
||||
*dashboardtypes.StorableDashboard `bun:",extend"`
|
||||
|
||||
IsPinned bool `bun:"is_pinned"`
|
||||
Total int64 `bun:"total"`
|
||||
}
|
||||
|
||||
rows := make([]*listedRow, 0)
|
||||
|
||||
q := store.sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(&rows).
|
||||
ColumnExpr("dashboard.id, dashboard.org_id, dashboard.name, dashboard.data, dashboard.locked, dashboard.source, dashboard.created_at, dashboard.created_by, dashboard.updated_at, dashboard.updated_by").
|
||||
ColumnExpr("CASE WHEN pin.user_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned").
|
||||
ColumnExpr("COUNT(*) OVER () AS total").
|
||||
Join("LEFT JOIN pinned_dashboard AS pin ON pin.user_id = ? AND pin.dashboard_id = dashboard.id", userID).
|
||||
Where("dashboard.org_id = ?", orgID).
|
||||
Where("dashboard.source != ?", dashboardtypes.SourceSystem)
|
||||
|
||||
if compiled != nil {
|
||||
q = q.Where(compiled.SQL, compiled.Args...)
|
||||
}
|
||||
|
||||
sortExpr, err := store.sortExprForListV2(params.Sort)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
q = q.
|
||||
OrderExpr("is_pinned DESC").
|
||||
OrderExpr(sortExpr + " " + strings.ToUpper(params.Order.StringValue())).
|
||||
Limit(params.Limit).
|
||||
Offset(params.Offset)
|
||||
|
||||
if err := q.Scan(ctx); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// COUNT(*) OVER () is computed pre-LIMIT, so any returned row carries the
|
||||
// full filter total. Empty result page => zero matches.
|
||||
var total int64
|
||||
if len(rows) > 0 {
|
||||
total = rows[0].Total
|
||||
}
|
||||
|
||||
out := make([]*dashboardtypes.DashboardListRow, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = &dashboardtypes.DashboardListRow{
|
||||
Dashboard: r.StorableDashboard,
|
||||
Pinned: r.IsPinned,
|
||||
}
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
// sortExprForListV2 maps a sort enum to the SQL expression to plug into
|
||||
// ORDER BY. Title-sort routes through the SQLFormatter so it stays
|
||||
// dialect-aware (matches what listfilter/visitor does for the name filter).
|
||||
func (store *store) sortExprForListV2(sort dashboardtypes.ListSort) (string, error) {
|
||||
switch sort {
|
||||
case dashboardtypes.ListSortUpdatedAt:
|
||||
return "dashboard.updated_at", nil
|
||||
case dashboardtypes.ListSortCreatedAt:
|
||||
return "dashboard.created_at", nil
|
||||
case dashboardtypes.ListSortName:
|
||||
return string(store.sqlstore.Formatter().JSONExtractString("dashboard.data", "$.spec.display.name")), nil
|
||||
}
|
||||
return "", errors.Newf(errors.TypeInvalidInput, dashboardtypes.ErrCodeDashboardListInvalid,
|
||||
"unsupported sort field %q", sort)
|
||||
}
|
||||
|
||||
func (store *store) GetPublic(ctx context.Context, dashboardID string) (*dashboardtypes.StorablePublicDashboard, error) {
|
||||
storable := new(dashboardtypes.StorablePublicDashboard)
|
||||
err := store.
|
||||
@@ -245,7 +153,7 @@ func (store *store) ListPublic(ctx context.Context, orgID valuer.UUID) ([]*dashb
|
||||
func (store *store) Update(ctx context.Context, orgID valuer.UUID, storableDashboard *dashboardtypes.StorableDashboard) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
BunDB().
|
||||
NewUpdate().
|
||||
Model(storableDashboard).
|
||||
WherePK().
|
||||
@@ -309,133 +217,3 @@ func (store *store) RunInTx(ctx context.Context, cb func(ctx context.Context) er
|
||||
return cb(ctx)
|
||||
})
|
||||
}
|
||||
|
||||
// PinForUser combines the count check, the existence check, and the upsert in
|
||||
// a single statement so the limit gate and the insert can't drift between two
|
||||
// round-trips.
|
||||
//
|
||||
// pin exists? | count < 10? | WHERE passes? | effect | rows
|
||||
// ------------|-------------|-------------------------|-----------------------------------|-----
|
||||
// no | yes | yes (count branch) | INSERT new row | 1
|
||||
// no | no | no | nothing (limit hit) | 0
|
||||
// yes | yes | yes (count branch) | INSERT → conflict → no-op UPDATE | 1
|
||||
// yes | no | yes (EXISTS OR branch) | INSERT → conflict → no-op UPDATE | 1
|
||||
//
|
||||
// rows = 0 is the only signal of a real limit hit.
|
||||
func (store *store) PinForUser(ctx context.Context, pd *dashboardtypes.PinnedDashboard) error {
|
||||
res, err := store.sqlstore.BunDBCtx(ctx).NewRaw(`
|
||||
INSERT INTO pinned_dashboard (user_id, dashboard_id, org_id, pinned_at)
|
||||
SELECT ?, ?, ?, ?
|
||||
WHERE (SELECT COUNT(*) FROM pinned_dashboard WHERE user_id = ?) < ?
|
||||
OR EXISTS (SELECT 1 FROM pinned_dashboard WHERE user_id = ? AND dashboard_id = ?)
|
||||
ON CONFLICT (user_id, dashboard_id) DO UPDATE SET user_id = EXCLUDED.user_id
|
||||
`,
|
||||
pd.UserID, pd.DashboardID, pd.OrgID, pd.PinnedAt,
|
||||
pd.UserID, dashboardtypes.MaxPinnedDashboardsPerUser,
|
||||
pd.UserID, pd.DashboardID,
|
||||
).Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows == 0 {
|
||||
return errors.Newf(errors.TypeAlreadyExists, dashboardtypes.ErrCodePinnedDashboardLimitHit,
|
||||
"cannot pin more than %d dashboards", dashboardtypes.MaxPinnedDashboardsPerUser)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) UnpinForUser(ctx context.Context, userID valuer.UUID, dashboardID valuer.UUID) error {
|
||||
_, err := store.sqlstore.BunDBCtx(ctx).
|
||||
NewDelete().
|
||||
Model((*dashboardtypes.PinnedDashboard)(nil)).
|
||||
Where("user_id = ?", userID).
|
||||
Where("dashboard_id = ?", dashboardID).
|
||||
Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (store *store) CreateDashboardView(ctx context.Context, view *dashboardtypes.DashboardView) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewInsert().
|
||||
Model(view).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return store.sqlstore.WrapAlreadyExistsErrf(err, errors.CodeAlreadyExists, "dashboard view with id %s already exists", view.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) GetDashboardView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardView, error) {
|
||||
view := new(dashboardtypes.DashboardView)
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(view).
|
||||
Where("id = ?", id).
|
||||
Where("org_id = ?", orgID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, dashboardtypes.ErrCodeDashboardViewNotFound, "dashboard view with id %s doesn't exist", id)
|
||||
}
|
||||
return view, nil
|
||||
}
|
||||
|
||||
func (store *store) ListDashboardViews(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.DashboardView, error) {
|
||||
views := make([]*dashboardtypes.DashboardView, 0)
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(&views).
|
||||
Where("org_id = ?", orgID).
|
||||
OrderExpr("updated_at DESC").
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return views, nil
|
||||
}
|
||||
|
||||
func (store *store) UpdateDashboardView(ctx context.Context, view *dashboardtypes.DashboardView) error {
|
||||
res, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewUpdate().
|
||||
Model(view).
|
||||
WherePK().
|
||||
Where("org_id = ?", view.OrgID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows == 0 {
|
||||
return errors.Newf(errors.TypeNotFound, dashboardtypes.ErrCodeDashboardViewNotFound, "dashboard view with id %s doesn't exist", view.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) DeleteDashboardView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewDelete().
|
||||
Model(new(dashboardtypes.DashboardView)).
|
||||
Where("id = ?", id).
|
||||
Where("org_id = ?", orgID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return store.sqlstore.WrapNotFoundErrf(err, dashboardtypes.ErrCodeDashboardViewNotFound, "dashboard view with id %s doesn't exist", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
@@ -42,43 +41,6 @@ func (handler *handler) CreateV2(rw http.ResponseWriter, r *http.Request) {
|
||||
render.Success(rw, http.StatusCreated, dashboard.ToGettableDashboardV2())
|
||||
}
|
||||
|
||||
func (handler *handler) ListV2(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
userID, err := valuer.NewUUID(claims.IdentityID())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
params := new(dashboardtypes.ListDashboardsV2Params)
|
||||
if err := binding.Query.BindQuery(r.URL.Query(), params); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
if err := params.Validate(); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
out, err := handler.module.ListV2(ctx, orgID, userID, params)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (handler *handler) GetV2(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
@@ -110,216 +72,3 @@ func (handler *handler) GetV2(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
render.Success(rw, http.StatusOK, dashboard.ToGettableDashboardV2())
|
||||
}
|
||||
|
||||
func (handler *handler) LockV2(rw http.ResponseWriter, r *http.Request) {
|
||||
handler.lockUnlockV2(rw, r, true)
|
||||
}
|
||||
|
||||
func (handler *handler) UnlockV2(rw http.ResponseWriter, r *http.Request) {
|
||||
handler.lockUnlockV2(rw, r, false)
|
||||
}
|
||||
|
||||
func (handler *handler) lockUnlockV2(rw http.ResponseWriter, r *http.Request, lock bool) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
id := mux.Vars(r)["id"]
|
||||
if id == "" {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
|
||||
return
|
||||
}
|
||||
dashboardID, err := valuer.NewUUID(id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
isAdmin := false
|
||||
selectors := []coretypes.Selector{
|
||||
coretypes.TypeRole.MustSelector(authtypes.SigNozAdminRoleName),
|
||||
}
|
||||
err = handler.authz.CheckWithTupleCreation(
|
||||
ctx,
|
||||
claims,
|
||||
orgID,
|
||||
authtypes.Relation{Verb: coretypes.VerbAssignee},
|
||||
coretypes.NewResourceRole(),
|
||||
selectors,
|
||||
selectors,
|
||||
)
|
||||
if err == nil {
|
||||
isAdmin = true
|
||||
}
|
||||
|
||||
if err := handler.module.LockUnlockV2(ctx, orgID, dashboardID, claims.Email, isAdmin, lock); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateV2(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
id := mux.Vars(r)["id"]
|
||||
if id == "" {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
|
||||
return
|
||||
}
|
||||
dashboardID, err := valuer.NewUUID(id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
req := dashboardtypes.UpdatableDashboardV2{}
|
||||
if err := binding.JSON.BindBody(r.Body, &req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
dashboard, err := handler.module.UpdateV2(ctx, orgID, dashboardID, claims.Email, req)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, dashboard.ToGettableDashboardV2())
|
||||
}
|
||||
|
||||
func (handler *handler) PatchV2(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
id := mux.Vars(r)["id"]
|
||||
if id == "" {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
|
||||
return
|
||||
}
|
||||
dashboardID, err := valuer.NewUUID(id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
req := dashboardtypes.PatchableDashboardV2{}
|
||||
if err := binding.JSON.BindBody(r.Body, &req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
dashboard, err := handler.module.PatchV2(ctx, orgID, dashboardID, claims.Email, req)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, dashboard.ToGettableDashboardV2())
|
||||
}
|
||||
|
||||
func (handler *handler) PinV2(rw http.ResponseWriter, r *http.Request) {
|
||||
handler.pinUnpinV2(rw, r, true)
|
||||
}
|
||||
|
||||
func (handler *handler) UnpinV2(rw http.ResponseWriter, r *http.Request) {
|
||||
handler.pinUnpinV2(rw, r, false)
|
||||
}
|
||||
|
||||
func (handler *handler) pinUnpinV2(rw http.ResponseWriter, r *http.Request, pin bool) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
userID, err := valuer.NewUUID(claims.IdentityID())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id := mux.Vars(r)["id"]
|
||||
if id == "" {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
|
||||
return
|
||||
}
|
||||
dashboardID, err := valuer.NewUUID(id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
if pin {
|
||||
err = handler.module.PinV2(ctx, orgID, userID, dashboardID)
|
||||
} else {
|
||||
err = handler.module.UnpinV2(ctx, userID, dashboardID)
|
||||
}
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) DeleteV2(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
id := mux.Vars(r)["id"]
|
||||
if id == "" {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
|
||||
return
|
||||
}
|
||||
dashboardID, err := valuer.NewUUID(id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := handler.module.DeleteV2(ctx, orgID, dashboardID); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package impldashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
@@ -43,29 +42,6 @@ func (m *module) CreateV2(ctx context.Context, orgID valuer.UUID, createdBy stri
|
||||
return dashboard, nil
|
||||
}
|
||||
|
||||
func (module *module) ListV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, params *dashboardtypes.ListDashboardsV2Params) (*dashboardtypes.ListableDashboardV2, error) {
|
||||
rows, total, err := module.store.ListV2(ctx, orgID, userID, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dashboardIDs := make([]valuer.UUID, len(rows))
|
||||
for i, r := range rows {
|
||||
dashboardIDs[i] = r.Dashboard.ID
|
||||
}
|
||||
tagsByDashboard, err := module.tagModule.ListForResources(ctx, orgID, coretypes.KindDashboard, dashboardIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allTags, err := module.tagModule.List(ctx, orgID, coretypes.KindDashboard)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dashboardtypes.NewListableDashboardV2(rows, total, tagsByDashboard, allTags)
|
||||
}
|
||||
|
||||
func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
|
||||
storable, err := module.store.Get(ctx, orgID, id)
|
||||
if err != nil {
|
||||
@@ -79,131 +55,3 @@ func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UU
|
||||
|
||||
return storable.ToDashboardV2(tags)
|
||||
}
|
||||
|
||||
func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error) {
|
||||
if err := updatable.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existing, err := module.GetV2(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Locked-dashboard / state gate — independent of tags, so run it before the tx.
|
||||
if err := existing.CanUpdate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
resolvedTags, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, id, updatable.Tags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = existing.Update(updatable, updatedBy, resolvedTags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
storable, err := existing.ToStorableDashboard()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return module.store.Update(ctx, orgID, storable)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
func (module *module) PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, patch dashboardtypes.PatchableDashboardV2) (*dashboardtypes.DashboardV2, error) {
|
||||
existing, err := module.GetV2(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Locked-dashboard / state gate — independent of tags, so run it before the tx.
|
||||
if err := existing.CanUpdate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updateable, err := patch.Apply(existing)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
resolvedTags, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, id, updateable.Tags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = existing.Update(*updateable, updatedBy, resolvedTags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
storable, err := existing.ToStorableDashboard()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return module.store.Update(ctx, orgID, storable)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
func (module *module) DeleteV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
|
||||
existing, err := module.GetV2(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing.Locked {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot delete a locked dashboard, please unlock the dashboard to delete")
|
||||
}
|
||||
|
||||
return module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
// Syncing to an empty tag set drops every tag link for the dashboard.
|
||||
if _, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, id, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
return module.store.Delete(ctx, orgID, id)
|
||||
})
|
||||
}
|
||||
|
||||
func (module *module) LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
|
||||
existing, err := module.GetV2(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := existing.LockUnlock(lock, isAdmin, updatedBy); err != nil {
|
||||
return err
|
||||
}
|
||||
storable, err := existing.ToStorableDashboard()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return module.store.Update(ctx, orgID, storable)
|
||||
}
|
||||
|
||||
func (module *module) PinV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, id valuer.UUID) error {
|
||||
if _, err := module.GetV2(ctx, orgID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return module.store.PinForUser(ctx, &dashboardtypes.PinnedDashboard{
|
||||
UserID: userID,
|
||||
DashboardID: id,
|
||||
OrgID: orgID,
|
||||
PinnedAt: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func (module *module) UnpinV2(ctx context.Context, userID valuer.UUID, id valuer.UUID) error {
|
||||
return module.store.UnpinForUser(ctx, userID, id)
|
||||
}
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
package impldashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func (handler *handler) CreateView(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
var req dashboardtypes.PostableDashboardView
|
||||
if err := binding.JSON.BindBody(r.Body, &req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
view, err := handler.module.CreateView(ctx, orgID, req)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusCreated, view)
|
||||
}
|
||||
|
||||
func (handler *handler) ListViews(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
out, err := handler.module.ListViews(ctx, orgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateView(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
id := mux.Vars(r)["id"]
|
||||
if id == "" {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
|
||||
return
|
||||
}
|
||||
viewID, err := valuer.NewUUID(id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
var req dashboardtypes.UpdateableDashboardView
|
||||
if err := binding.JSON.BindBody(r.Body, &req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
view, err := handler.module.UpdateView(ctx, orgID, viewID, req)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, view)
|
||||
}
|
||||
|
||||
func (handler *handler) DeleteView(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
id := mux.Vars(r)["id"]
|
||||
if id == "" {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
|
||||
return
|
||||
}
|
||||
viewID, err := valuer.NewUUID(id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := handler.module.DeleteView(ctx, orgID, viewID); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package impldashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
func (module *module) CreateView(ctx context.Context, orgID valuer.UUID, postable dashboardtypes.PostableDashboardView) (*dashboardtypes.DashboardView, error) {
|
||||
if err := postable.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
view := postable.NewDashboardView(orgID)
|
||||
if err := module.store.CreateDashboardView(ctx, view); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return view, nil
|
||||
}
|
||||
|
||||
func (module *module) ListViews(ctx context.Context, orgID valuer.UUID) (*dashboardtypes.ListableDashboardView, error) {
|
||||
views, err := module.store.ListDashboardViews(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dashboardtypes.ListableDashboardView{Views: views}, nil
|
||||
}
|
||||
|
||||
func (module *module) UpdateView(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updateable dashboardtypes.UpdateableDashboardView) (*dashboardtypes.DashboardView, error) {
|
||||
if err := updateable.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
view, err := module.store.GetDashboardView(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
view.Update(updateable)
|
||||
if err := module.store.UpdateDashboardView(ctx, view); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return view, nil
|
||||
}
|
||||
|
||||
func (module *module) DeleteView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
|
||||
return module.store.DeleteDashboardView(ctx, orgID, id)
|
||||
}
|
||||
@@ -67,10 +67,6 @@ func (m *module) syncLinksForResource(ctx context.Context, orgID valuer.UUID, ki
|
||||
})
|
||||
}
|
||||
|
||||
func (m *module) List(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind) ([]*tagtypes.Tag, error) {
|
||||
return m.store.List(ctx, orgID, kind)
|
||||
}
|
||||
|
||||
func (m *module) ListForResource(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceID valuer.UUID) ([]*tagtypes.Tag, error) {
|
||||
return m.store.ListByResource(ctx, orgID, kind, resourceID)
|
||||
}
|
||||
|
||||
@@ -13,9 +13,6 @@ type Module interface {
|
||||
// and reconciles the resource's links to exactly that set, all in one transaction.
|
||||
SyncTags(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceID valuer.UUID, postable []tagtypes.PostableTag) ([]*tagtypes.Tag, error)
|
||||
|
||||
// List returns every tag of the given kind in the org.
|
||||
List(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind) ([]*tagtypes.Tag, error)
|
||||
|
||||
ListForResource(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceID valuer.UUID) ([]*tagtypes.Tag, error)
|
||||
|
||||
// Resources with no tags are absent from the returned map.
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
package filterquery
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
grammar "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
|
||||
"github.com/antlr4-go/antlr/v4"
|
||||
)
|
||||
|
||||
func Parse(query string) (antlr.ParseTree, *antlr.CommonTokenStream, *ErrorCollector) {
|
||||
collector := NewErrorCollector()
|
||||
lexer := grammar.NewFilterQueryLexer(antlr.NewInputStream(query))
|
||||
lexer.RemoveErrorListeners()
|
||||
lexer.AddErrorListener(collector)
|
||||
tokens := antlr.NewCommonTokenStream(lexer, 0)
|
||||
parser := grammar.NewFilterQueryParser(tokens)
|
||||
parser.RemoveErrorListeners()
|
||||
parser.AddErrorListener(collector)
|
||||
return parser.Query(), tokens, collector
|
||||
}
|
||||
|
||||
type ErrorCollector struct {
|
||||
*antlr.DefaultErrorListener
|
||||
Errors []string
|
||||
}
|
||||
|
||||
func NewErrorCollector() *ErrorCollector {
|
||||
return &ErrorCollector{}
|
||||
}
|
||||
|
||||
func (c *ErrorCollector) SyntaxError(_ antlr.Recognizer, _ any, line, column int, msg string, _ antlr.RecognitionException) {
|
||||
c.Errors = append(c.Errors, fmt.Sprintf("syntax error at %d:%d — %s", line, column, msg))
|
||||
}
|
||||
@@ -211,8 +211,6 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewAddDashboardNameFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewFixChangelogOperationTypeFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewCloudIntegrationRemoveCascadeDeleteFactory(sqlschema),
|
||||
sqlmigration.NewAddPinnedDashboardFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewAddDashboardViewFactory(sqlstore, sqlschema),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,67 +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 addPinnedDashboard struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
sqlschema sqlschema.SQLSchema
|
||||
}
|
||||
|
||||
func NewAddPinnedDashboardFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("add_pinned_dashboard"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &addPinnedDashboard{
|
||||
sqlstore: sqlstore,
|
||||
sqlschema: sqlschema,
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *addPinnedDashboard) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
func (migration *addPinnedDashboard) 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().CreateTable(&sqlschema.Table{
|
||||
Name: "pinned_dashboard",
|
||||
Columns: []*sqlschema.Column{
|
||||
{Name: "user_id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "dashboard_id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "org_id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "pinned_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false, Default: "current_timestamp"},
|
||||
},
|
||||
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{ColumnNames: []sqlschema.ColumnName{"user_id", "dashboard_id"}},
|
||||
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
|
||||
{
|
||||
ReferencingColumnName: sqlschema.ColumnName("org_id"),
|
||||
ReferencedTableName: sqlschema.TableName("organizations"),
|
||||
ReferencedColumnName: sqlschema.ColumnName("id"),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
for _, sql := range sqls {
|
||||
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *addPinnedDashboard) Down(_ context.Context, _ *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
@@ -1,69 +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 addDashboardView struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
sqlschema sqlschema.SQLSchema
|
||||
}
|
||||
|
||||
func NewAddDashboardViewFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("add_dashboard_view"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &addDashboardView{
|
||||
sqlstore: sqlstore,
|
||||
sqlschema: sqlschema,
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *addDashboardView) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
func (migration *addDashboardView) 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().CreateTable(&sqlschema.Table{
|
||||
Name: "dashboard_view",
|
||||
Columns: []*sqlschema.Column{
|
||||
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "name", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "data", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "org_id", DataType: sqlschema.DataTypeText, Nullable: 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("org_id"),
|
||||
ReferencedTableName: sqlschema.TableName("organizations"),
|
||||
ReferencedColumnName: sqlschema.ColumnName("id"),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
for _, sql := range sqls {
|
||||
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *addDashboardView) Down(_ context.Context, _ *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
@@ -21,7 +21,6 @@ var (
|
||||
ErrCodeDashboardInvalidWidgetQuery = errors.MustNewCode("dashboard_invalid_widget_query")
|
||||
ErrCodeDashboardInvalidSource = errors.MustNewCode("dashboard_invalid_source")
|
||||
ErrCodeDashboardImmutable = errors.MustNewCode("dashboard_immutable")
|
||||
ErrCodeDashboardInvalidPatch = errors.MustNewCode("dashboard_invalid_patch")
|
||||
)
|
||||
|
||||
type StorableDashboard struct {
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
const (
|
||||
DashboardViewSchemaVersion = "v1"
|
||||
MaxDashboardViewNameLen = 32
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCodeDashboardViewInvalidInput = errors.MustNewCode("dashboard_view_invalid_input")
|
||||
ErrCodeDashboardViewNotFound = errors.MustNewCode("dashboard_view_not_found")
|
||||
)
|
||||
|
||||
type DashboardViewData struct {
|
||||
Version string `json:"version" required:"true"`
|
||||
Query string `json:"query"`
|
||||
Sort ListSort `json:"sort"`
|
||||
Order ListOrder `json:"order"`
|
||||
}
|
||||
|
||||
func (d *DashboardViewData) Validate() error {
|
||||
if d.Version != DashboardViewSchemaVersion {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardViewInvalidInput,
|
||||
"version must be %q, got %q", DashboardViewSchemaVersion, d.Version)
|
||||
}
|
||||
if !d.Sort.IsZero() {
|
||||
s := d.Sort
|
||||
switch s {
|
||||
case ListSortUpdatedAt, ListSortCreatedAt, ListSortName:
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardViewInvalidInput,
|
||||
"invalid sort %q — expected one of: `updated_at`, `created_at`, `name`", d.Sort)
|
||||
}
|
||||
d.Sort = s
|
||||
}
|
||||
if !d.Order.IsZero() {
|
||||
o := d.Order
|
||||
switch o {
|
||||
case ListOrderAsc, ListOrderDesc:
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardViewInvalidInput,
|
||||
"invalid order %q — expected `asc` or `desc`", d.Order)
|
||||
}
|
||||
d.Order = o
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type DashboardView struct {
|
||||
bun.BaseModel `bun:"table:dashboard_view,alias:dashboard_view"`
|
||||
|
||||
types.Identifiable
|
||||
types.TimeAuditable
|
||||
|
||||
Name string `bun:"name,type:text,notnull" json:"name" required:"true"`
|
||||
Data DashboardViewData `bun:"data,type:jsonb,notnull" json:"data" required:"true"`
|
||||
OrgID valuer.UUID `bun:"org_id,type:text,notnull" json:"orgId" required:"true"`
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Postable
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
type PostableDashboardView struct {
|
||||
Name string `json:"name" required:"true"`
|
||||
Data DashboardViewData `json:"data" required:"true"`
|
||||
}
|
||||
|
||||
func (p *PostableDashboardView) UnmarshalJSON(data []byte) error {
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.DisallowUnknownFields()
|
||||
type alias PostableDashboardView
|
||||
var tmp alias
|
||||
if err := dec.Decode(&tmp); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardViewInvalidInput, "invalid saved view request body").WithAdditional(err.Error())
|
||||
}
|
||||
*p = PostableDashboardView(tmp)
|
||||
return p.Validate()
|
||||
}
|
||||
|
||||
func (p *PostableDashboardView) Validate() error {
|
||||
if err := validateDashboardViewName(p.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
return p.Data.Validate()
|
||||
}
|
||||
|
||||
func (p PostableDashboardView) NewDashboardView(orgID valuer.UUID) *DashboardView {
|
||||
now := time.Now()
|
||||
return &DashboardView{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
TimeAuditable: types.TimeAuditable{CreatedAt: now, UpdatedAt: now},
|
||||
OrgID: orgID,
|
||||
Name: p.Name,
|
||||
Data: p.Data,
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Updateable
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
type UpdateableDashboardView = PostableDashboardView
|
||||
|
||||
func (v *DashboardView) Update(updateable UpdateableDashboardView) {
|
||||
v.Name = updateable.Name
|
||||
v.Data = updateable.Data
|
||||
v.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Gettable
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
type GettableDashboardView = DashboardView
|
||||
|
||||
type ListableDashboardView struct {
|
||||
Views []*GettableDashboardView `json:"views" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Helpers
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
func validateDashboardViewName(name string) error {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardViewInvalidInput, "name is required")
|
||||
}
|
||||
if len(trimmed) > MaxDashboardViewNameLen {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardViewInvalidInput,
|
||||
"name must be at most %d characters, got %d", MaxDashboardViewNameLen, len(trimmed))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/perses/perses/pkg/model/api/v1/common"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultListLimit = 20
|
||||
MaxListLimit = 200
|
||||
)
|
||||
|
||||
// ListSort is the sort field for the dashboard list endpoint. The value is a
|
||||
// stable enum so callers can't ask for arbitrary columns.
|
||||
type ListSort struct{ valuer.String }
|
||||
|
||||
var (
|
||||
ListSortUpdatedAt = ListSort{valuer.NewString("updated_at")}
|
||||
ListSortCreatedAt = ListSort{valuer.NewString("created_at")}
|
||||
ListSortName = ListSort{valuer.NewString("name")}
|
||||
)
|
||||
|
||||
func (ListSort) Enum() []any {
|
||||
return []any{ListSortUpdatedAt, ListSortCreatedAt, ListSortName}
|
||||
}
|
||||
|
||||
func (s ListSort) IsValid() bool {
|
||||
return slices.ContainsFunc(s.Enum(), func(v any) bool { return v == s })
|
||||
}
|
||||
|
||||
type ListOrder struct{ valuer.String }
|
||||
|
||||
var (
|
||||
ListOrderAsc = ListOrder{valuer.NewString("asc")}
|
||||
ListOrderDesc = ListOrder{valuer.NewString("desc")}
|
||||
)
|
||||
|
||||
func (ListOrder) Enum() []any {
|
||||
return []any{ListOrderAsc, ListOrderDesc}
|
||||
}
|
||||
|
||||
func (o ListOrder) IsValid() bool {
|
||||
return slices.ContainsFunc(o.Enum(), func(v any) bool { return v == o })
|
||||
}
|
||||
|
||||
var ErrCodeDashboardListInvalid = errors.MustNewCode("dashboard_list_invalid")
|
||||
|
||||
type ListDashboardsV2Params struct {
|
||||
Query string `query:"query"`
|
||||
Sort ListSort `query:"sort"`
|
||||
Order ListOrder `query:"order"`
|
||||
Limit int `query:"limit"`
|
||||
Offset int `query:"offset"`
|
||||
}
|
||||
|
||||
// Validate fills in defaults (sort=updated_at, order=desc, limit=20) and
|
||||
// rejects out-of-allowlist sort/order values and bad limit/offset. Limit is
|
||||
// clamped to MaxListLimit on the high side. Sort/order are case-insensitive —
|
||||
// valuer.String lowercases them at bind time.
|
||||
func (p *ListDashboardsV2Params) Validate() error {
|
||||
if p.Sort.IsZero() {
|
||||
p.Sort = ListSortUpdatedAt
|
||||
} else if !p.Sort.IsValid() {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardListInvalid,
|
||||
"invalid sort %q — expected one of: `updated_at`, `created_at`, `name`", p.Sort)
|
||||
}
|
||||
|
||||
if p.Order.IsZero() {
|
||||
p.Order = ListOrderDesc
|
||||
} else if !p.Order.IsValid() {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardListInvalid,
|
||||
"invalid order %q — expected `asc` or `desc`", p.Order)
|
||||
}
|
||||
|
||||
if p.Limit == 0 {
|
||||
p.Limit = DefaultListLimit
|
||||
} else if p.Limit < 0 {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardListInvalid,
|
||||
"invalid limit %d — must be a positive integer", p.Limit)
|
||||
} else if p.Limit > MaxListLimit {
|
||||
p.Limit = MaxListLimit
|
||||
}
|
||||
|
||||
if p.Offset < 0 {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardListInvalid,
|
||||
"invalid offset %d — must be a non-negative integer", p.Offset)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type listedDashboardV2 struct {
|
||||
types.Identifiable
|
||||
types.TimeAuditable
|
||||
types.UserAuditable
|
||||
|
||||
OrgID valuer.UUID `json:"orgId" required:"true"`
|
||||
Locked bool `json:"locked" required:"true"`
|
||||
Source Source `json:"source" required:"true"`
|
||||
SchemaVersion string `json:"schemaVersion" required:"true"`
|
||||
Name string `json:"name" required:"true"`
|
||||
Pinned bool `json:"pinned" required:"true"`
|
||||
Tags []*tagtypes.GettableTag `json:"tags" required:"true" nullable:"false"`
|
||||
Spec listedDashboardV2Spec `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
type listedDashboardV2Spec struct {
|
||||
Display *common.Display `json:"display,omitempty"`
|
||||
}
|
||||
|
||||
type ListableDashboardV2 struct {
|
||||
Dashboards []*listedDashboardV2 `json:"dashboards" required:"true" nullable:"false"`
|
||||
Total int64 `json:"total" required:"true"`
|
||||
Tags []*tagtypes.GettableTag `json:"tags" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
// DashboardListRow is the per-row shape Store.ListV2 returns. Bundles the
|
||||
// joined dashboard / pinned_dashboard data so the module layer can attach
|
||||
// tags and assemble the gettable view.
|
||||
type DashboardListRow struct {
|
||||
Dashboard *StorableDashboard
|
||||
Pinned bool
|
||||
}
|
||||
|
||||
func NewListableDashboardV2(rows []*DashboardListRow, total int64, tagsByEntity map[valuer.UUID][]*tagtypes.Tag, allTags []*tagtypes.Tag) (*ListableDashboardV2, error) {
|
||||
dashboards := make([]*listedDashboardV2, len(rows))
|
||||
for i, r := range rows {
|
||||
v2, err := r.Dashboard.ToDashboardV2(tagsByEntity[r.Dashboard.ID])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dashboards[i] = &listedDashboardV2{
|
||||
Identifiable: v2.Identifiable,
|
||||
TimeAuditable: v2.TimeAuditable,
|
||||
UserAuditable: v2.UserAuditable,
|
||||
OrgID: v2.OrgID,
|
||||
Locked: v2.Locked,
|
||||
Source: v2.Source,
|
||||
SchemaVersion: v2.SchemaVersion,
|
||||
Name: v2.Name,
|
||||
Pinned: r.Pinned,
|
||||
Tags: tagtypes.NewGettableTagsFromTags(v2.Tags),
|
||||
Spec: listedDashboardV2Spec{Display: v2.Spec.Display},
|
||||
}
|
||||
}
|
||||
return &ListableDashboardV2{
|
||||
Dashboards: dashboards,
|
||||
Total: total,
|
||||
Tags: tagtypes.NewGettableTagsFromTags(allTags),
|
||||
}, nil
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
package listfilter
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
qbtypesv5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
)
|
||||
|
||||
var ErrCodeDashboardListFilterInvalid = errors.MustNewCode("dashboard_list_filter_invalid")
|
||||
|
||||
// reservedOps lists the operators each reserved (column-level) DSL key accepts.
|
||||
// Any non-reserved key is treated as a tag key and uses tagKeyOps.
|
||||
var reservedOps = map[dashboardtypes.DSLKey]map[qbtypesv5.FilterOperator]struct{}{
|
||||
dashboardtypes.DSLKeyName: stringSearchOps(),
|
||||
dashboardtypes.DSLKeyDescription: stringSearchOps(),
|
||||
dashboardtypes.DSLKeyCreatedAt: numericRangeOps(),
|
||||
dashboardtypes.DSLKeyUpdatedAt: numericRangeOps(),
|
||||
dashboardtypes.DSLKeyCreatedBy: stringSearchOps(),
|
||||
dashboardtypes.DSLKeyLocked: opsSet(qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual),
|
||||
dashboardtypes.DSLKeyPublic: opsSet(qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual),
|
||||
}
|
||||
|
||||
// tagKeyOps applies to every non-reserved DSL key — the operator targets the
|
||||
// tag's value with an implicit case-insensitive match on the tag's key.
|
||||
var tagKeyOps = opsSet(
|
||||
qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual,
|
||||
qbtypesv5.FilterOperatorLike, qbtypesv5.FilterOperatorNotLike,
|
||||
qbtypesv5.FilterOperatorILike, qbtypesv5.FilterOperatorNotILike,
|
||||
qbtypesv5.FilterOperatorContains, qbtypesv5.FilterOperatorNotContains,
|
||||
qbtypesv5.FilterOperatorRegexp, qbtypesv5.FilterOperatorNotRegexp,
|
||||
qbtypesv5.FilterOperatorIn, qbtypesv5.FilterOperatorNotIn,
|
||||
qbtypesv5.FilterOperatorExists, qbtypesv5.FilterOperatorNotExists,
|
||||
)
|
||||
|
||||
func stringSearchOps() map[qbtypesv5.FilterOperator]struct{} {
|
||||
return opsSet(
|
||||
qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual,
|
||||
qbtypesv5.FilterOperatorLike, qbtypesv5.FilterOperatorNotLike,
|
||||
qbtypesv5.FilterOperatorILike, qbtypesv5.FilterOperatorNotILike,
|
||||
qbtypesv5.FilterOperatorContains, qbtypesv5.FilterOperatorNotContains,
|
||||
qbtypesv5.FilterOperatorRegexp, qbtypesv5.FilterOperatorNotRegexp,
|
||||
qbtypesv5.FilterOperatorIn, qbtypesv5.FilterOperatorNotIn,
|
||||
)
|
||||
}
|
||||
|
||||
func numericRangeOps() map[qbtypesv5.FilterOperator]struct{} {
|
||||
return opsSet(
|
||||
qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual,
|
||||
qbtypesv5.FilterOperatorLessThan, qbtypesv5.FilterOperatorLessThanOrEq,
|
||||
qbtypesv5.FilterOperatorGreaterThan, qbtypesv5.FilterOperatorGreaterThanOrEq,
|
||||
qbtypesv5.FilterOperatorBetween, qbtypesv5.FilterOperatorNotBetween,
|
||||
)
|
||||
}
|
||||
|
||||
func opsSet(ops ...qbtypesv5.FilterOperator) map[qbtypesv5.FilterOperator]struct{} {
|
||||
m := make(map[qbtypesv5.FilterOperator]struct{}, len(ops))
|
||||
for _, op := range ops {
|
||||
m[op] = struct{}{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package listfilter
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
)
|
||||
|
||||
type Compiled struct {
|
||||
SQL string
|
||||
Args []any
|
||||
}
|
||||
|
||||
func Compile(query string, formatter sqlstore.SQLFormatter) (*Compiled, error) {
|
||||
if len(query) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
queryVisitor := newVisitor(formatter)
|
||||
frag, syntaxErrs := queryVisitor.compile(query)
|
||||
|
||||
if len(syntaxErrs) > 0 {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeDashboardListFilterInvalid,
|
||||
"invalid filter query: %s", strings.Join(syntaxErrs, "; "))
|
||||
}
|
||||
if len(queryVisitor.errors) > 0 {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeDashboardListFilterInvalid,
|
||||
"invalid filter query: %s", strings.Join(queryVisitor.errors, "; "))
|
||||
}
|
||||
if frag == nil || frag.sql == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &Compiled{
|
||||
SQL: frag.sql,
|
||||
Args: frag.args,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,507 +0,0 @@
|
||||
package listfilter
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstoretest"
|
||||
)
|
||||
|
||||
type compileCase struct {
|
||||
subtestName string
|
||||
dslQueryToCompile string
|
||||
nilExpected bool
|
||||
expectedSQL string
|
||||
expectedArgs []any
|
||||
expectedErrShouldContain string
|
||||
}
|
||||
|
||||
func runCompileCases(t *testing.T, cases []compileCase) {
|
||||
t.Helper()
|
||||
for _, c := range cases {
|
||||
t.Run(c.subtestName, func(t *testing.T) {
|
||||
out, err := Compile(c.dslQueryToCompile, formatter(t))
|
||||
|
||||
if c.expectedErrShouldContain != "" {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, strings.ToLower(err.Error()), strings.ToLower(c.expectedErrShouldContain))
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
if c.nilExpected {
|
||||
assert.Nil(t, out)
|
||||
return
|
||||
}
|
||||
require.NotNil(t, out)
|
||||
|
||||
if c.expectedSQL != "" {
|
||||
assert.Equal(t, normalizeSQL(c.expectedSQL), normalizeSQL(out.SQL))
|
||||
}
|
||||
if c.expectedArgs != nil {
|
||||
require.Len(t, out.Args, len(c.expectedArgs))
|
||||
for i, want := range c.expectedArgs {
|
||||
// time.Time values can carry semantically-equal instants
|
||||
// in different *Location representations (UTC vs Local vs
|
||||
// FixedZone). Compare via .Equal() instead of DeepEqual.
|
||||
if wantT, ok := want.(time.Time); ok {
|
||||
gotT, ok := out.Args[i].(time.Time)
|
||||
require.True(t, ok, "arg[%d]: want time.Time, got %T", i, out.Args[i])
|
||||
assert.True(t, wantT.Equal(gotT), "arg[%d]: want %s, got %s", i, wantT, gotT)
|
||||
continue
|
||||
}
|
||||
assert.Equal(t, want, out.Args[i], "arg[%d]", i)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompile_Empty(t *testing.T) {
|
||||
runCompileCases(t, []compileCase{
|
||||
{subtestName: "empty query yields nil", dslQueryToCompile: "", nilExpected: true},
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompile_Name(t *testing.T) {
|
||||
runCompileCases(t, []compileCase{
|
||||
{
|
||||
subtestName: "name =",
|
||||
dslQueryToCompile: `name = 'overview'`,
|
||||
expectedSQL: `json_extract("dashboard"."data", '$.spec.display.name') = ?`,
|
||||
expectedArgs: []any{"overview"},
|
||||
},
|
||||
{
|
||||
// QUOTED_TEXT in the grammar covers both '…' and "…" — visitor
|
||||
// strips whichever quote pair surrounds the value.
|
||||
subtestName: "name = with double-quoted value",
|
||||
dslQueryToCompile: `name = "something"`,
|
||||
expectedSQL: `json_extract("dashboard"."data", '$.spec.display.name') = ?`,
|
||||
expectedArgs: []any{"something"},
|
||||
},
|
||||
{
|
||||
subtestName: "name CONTAINS",
|
||||
dslQueryToCompile: `name CONTAINS 'overview'`,
|
||||
expectedSQL: `json_extract("dashboard"."data", '$.spec.display.name') LIKE ?`,
|
||||
expectedArgs: []any{"%overview%"},
|
||||
},
|
||||
{
|
||||
subtestName: "name ILIKE — emitted as LOWER(col) LIKE LOWER(?) for dialect parity",
|
||||
dslQueryToCompile: `name ILIKE 'Prod%'`,
|
||||
expectedSQL: `lower(json_extract("dashboard"."data", '$.spec.display.name')) LIKE LOWER(?)`,
|
||||
expectedArgs: []any{"Prod%"},
|
||||
},
|
||||
{
|
||||
subtestName: "CONTAINS escapes % in user input",
|
||||
dslQueryToCompile: `name CONTAINS '50%'`,
|
||||
expectedSQL: `json_extract("dashboard"."data", '$.spec.display.name') LIKE ?`,
|
||||
expectedArgs: []any{`%50\%%`},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompile_CreatedByLocked(t *testing.T) {
|
||||
runCompileCases(t, []compileCase{
|
||||
{
|
||||
subtestName: "created_by LIKE",
|
||||
dslQueryToCompile: `created_by LIKE '%@signoz.io'`,
|
||||
expectedSQL: `dashboard.created_by LIKE ?`,
|
||||
expectedArgs: []any{"%@signoz.io"},
|
||||
},
|
||||
{
|
||||
subtestName: "locked = true",
|
||||
dslQueryToCompile: `locked = true`,
|
||||
expectedSQL: `dashboard.locked = ?`,
|
||||
expectedArgs: []any{true},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompile_Public(t *testing.T) {
|
||||
runCompileCases(t, []compileCase{
|
||||
{subtestName: "public = true", dslQueryToCompile: `public = true`, expectedSQL: `pd.id IS NOT NULL`},
|
||||
{subtestName: "public = false", dslQueryToCompile: `public = false`, expectedSQL: `pd.id IS NULL`},
|
||||
{subtestName: "public != true", dslQueryToCompile: `public != true`, expectedSQL: `pd.id IS NULL`},
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompile_Timestamps(t *testing.T) {
|
||||
ist := time.FixedZone("+05:30", 5*60*60+30*60)
|
||||
runCompileCases(t, []compileCase{
|
||||
{
|
||||
subtestName: "created_at >= RFC3339",
|
||||
dslQueryToCompile: `created_at >= '2026-03-10T00:00:00Z'`,
|
||||
expectedSQL: `dashboard.created_at >= ?`,
|
||||
expectedArgs: []any{time.Date(2026, 3, 10, 0, 0, 0, 0, time.UTC)},
|
||||
},
|
||||
{
|
||||
subtestName: "updated_at BETWEEN",
|
||||
dslQueryToCompile: `updated_at BETWEEN '2026-03-10T00:00:00Z' AND '2026-03-20T00:00:00Z'`,
|
||||
expectedSQL: `dashboard.updated_at BETWEEN ? AND ?`,
|
||||
expectedArgs: []any{
|
||||
time.Date(2026, 3, 10, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
{
|
||||
subtestName: "created_at >= IST timestamp",
|
||||
dslQueryToCompile: `created_at >= '2026-03-10T05:30:00+05:30'`,
|
||||
expectedSQL: `dashboard.created_at >= ?`,
|
||||
expectedArgs: []any{time.Date(2026, 3, 10, 5, 30, 0, 0, ist)},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Tag operators wrap each predicate in EXISTS / NOT EXISTS. Any non-reserved
|
||||
// key is a tag key — `team = 'pulse'` matches a tag with key=team value=pulse,
|
||||
// `tag = 'prod'` matches a tag with key=tag value=prod, and so on.
|
||||
func TestCompile_Tag(t *testing.T) {
|
||||
runCompileCases(t, []compileCase{
|
||||
{
|
||||
subtestName: "team = wraps in EXISTS",
|
||||
dslQueryToCompile: `team = 'pulse'`,
|
||||
expectedSQL: `
|
||||
EXISTS (
|
||||
SELECT 1 FROM tag_relation tr
|
||||
JOIN tag t ON t.id = tr.tag_id
|
||||
WHERE tr.kind = '"dashboard"' AND tr.resource_id = dashboard.id
|
||||
AND LOWER(t.key) = LOWER(?)
|
||||
AND t.value = ?
|
||||
)`,
|
||||
expectedArgs: []any{"team", "pulse"},
|
||||
},
|
||||
{
|
||||
subtestName: "tag = is just a regular tag-key filter",
|
||||
dslQueryToCompile: `tag = 'database'`,
|
||||
expectedSQL: `
|
||||
EXISTS (
|
||||
SELECT 1 FROM tag_relation tr
|
||||
JOIN tag t ON t.id = tr.tag_id
|
||||
WHERE tr.kind = '"dashboard"' AND tr.resource_id = dashboard.id
|
||||
AND LOWER(t.key) = LOWER(?)
|
||||
AND t.value = ?
|
||||
)`,
|
||||
expectedArgs: []any{"tag", "database"},
|
||||
},
|
||||
{
|
||||
subtestName: "team != wraps in NOT EXISTS with positive inner",
|
||||
dslQueryToCompile: `team != 'pulse'`,
|
||||
expectedSQL: `
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM tag_relation tr
|
||||
JOIN tag t ON t.id = tr.tag_id
|
||||
WHERE tr.kind = '"dashboard"' AND tr.resource_id = dashboard.id
|
||||
AND LOWER(t.key) = LOWER(?)
|
||||
AND t.value = ?
|
||||
)`,
|
||||
expectedArgs: []any{"team", "pulse"},
|
||||
},
|
||||
{
|
||||
subtestName: "team IN — inner is single placeholder list on t.value",
|
||||
dslQueryToCompile: `team IN ['pulse', 'events']`,
|
||||
expectedSQL: `
|
||||
EXISTS (
|
||||
SELECT 1 FROM tag_relation tr
|
||||
JOIN tag t ON t.id = tr.tag_id
|
||||
WHERE tr.kind = '"dashboard"' AND tr.resource_id = dashboard.id
|
||||
AND LOWER(t.key) = LOWER(?)
|
||||
AND t.value IN (?, ?)
|
||||
)`,
|
||||
expectedArgs: []any{"team", "pulse", "events"},
|
||||
},
|
||||
{
|
||||
subtestName: "team NOT IN",
|
||||
dslQueryToCompile: `team NOT IN ['pulse', 'events']`,
|
||||
expectedSQL: `
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM tag_relation tr
|
||||
JOIN tag t ON t.id = tr.tag_id
|
||||
WHERE tr.kind = '"dashboard"' AND tr.resource_id = dashboard.id
|
||||
AND LOWER(t.key) = LOWER(?)
|
||||
AND t.value IN (?, ?)
|
||||
)`,
|
||||
expectedArgs: []any{"team", "pulse", "events"},
|
||||
},
|
||||
{
|
||||
subtestName: "team LIKE — wildcard on value",
|
||||
dslQueryToCompile: `team LIKE 'pulse%'`,
|
||||
expectedSQL: `
|
||||
EXISTS (
|
||||
SELECT 1 FROM tag_relation tr
|
||||
JOIN tag t ON t.id = tr.tag_id
|
||||
WHERE tr.kind = '"dashboard"' AND tr.resource_id = dashboard.id
|
||||
AND LOWER(t.key) = LOWER(?)
|
||||
AND t.value LIKE ?
|
||||
)`,
|
||||
expectedArgs: []any{"team", "pulse%"},
|
||||
},
|
||||
{
|
||||
subtestName: "team NOT LIKE",
|
||||
dslQueryToCompile: `team NOT LIKE 'staging%'`,
|
||||
expectedSQL: `
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM tag_relation tr
|
||||
JOIN tag t ON t.id = tr.tag_id
|
||||
WHERE tr.kind = '"dashboard"' AND tr.resource_id = dashboard.id
|
||||
AND LOWER(t.key) = LOWER(?)
|
||||
AND t.value LIKE ?
|
||||
)`,
|
||||
expectedArgs: []any{"team", "staging%"},
|
||||
},
|
||||
{
|
||||
subtestName: "database EXISTS — asserts a tag with key=database is present",
|
||||
dslQueryToCompile: `database EXISTS`,
|
||||
expectedSQL: `
|
||||
EXISTS (
|
||||
SELECT 1 FROM tag_relation tr
|
||||
JOIN tag t ON t.id = tr.tag_id
|
||||
WHERE tr.kind = '"dashboard"' AND tr.resource_id = dashboard.id
|
||||
AND LOWER(t.key) = LOWER(?)
|
||||
)`,
|
||||
expectedArgs: []any{"database"},
|
||||
},
|
||||
{
|
||||
subtestName: "database NOT EXISTS",
|
||||
dslQueryToCompile: `database NOT EXISTS`,
|
||||
expectedSQL: `
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM tag_relation tr
|
||||
JOIN tag t ON t.id = tr.tag_id
|
||||
WHERE tr.kind = '"dashboard"' AND tr.resource_id = dashboard.id
|
||||
AND LOWER(t.key) = LOWER(?)
|
||||
)`,
|
||||
expectedArgs: []any{"database"},
|
||||
},
|
||||
{
|
||||
subtestName: "tag-key matching is case-insensitive — TEAM lowercased",
|
||||
dslQueryToCompile: `TEAM = 'pulse'`,
|
||||
expectedSQL: `
|
||||
EXISTS (
|
||||
SELECT 1 FROM tag_relation tr
|
||||
JOIN tag t ON t.id = tr.tag_id
|
||||
WHERE tr.kind = '"dashboard"' AND tr.resource_id = dashboard.id
|
||||
AND LOWER(t.key) = LOWER(?)
|
||||
AND t.value = ?
|
||||
)`,
|
||||
expectedArgs: []any{"team", "pulse"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompile_BooleanComposition(t *testing.T) {
|
||||
runCompileCases(t, []compileCase{
|
||||
{
|
||||
subtestName: "AND chain — flat arg list",
|
||||
dslQueryToCompile: `locked = true AND public = true`,
|
||||
expectedSQL: `dashboard.locked = ? AND pd.id IS NOT NULL`,
|
||||
expectedArgs: []any{true},
|
||||
},
|
||||
{
|
||||
subtestName: "OR chain",
|
||||
dslQueryToCompile: `locked = true OR public = true`,
|
||||
expectedSQL: `dashboard.locked = ? OR pd.id IS NOT NULL`,
|
||||
expectedArgs: []any{true},
|
||||
},
|
||||
{
|
||||
subtestName: "parens preserve precedence",
|
||||
dslQueryToCompile: `(locked = true OR public = true) AND created_by = 'a@b.com'`,
|
||||
expectedSQL: `(dashboard.locked = ? OR pd.id IS NOT NULL) AND dashboard.created_by = ?`,
|
||||
expectedArgs: []any{true, "a@b.com"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Distinct from operator-suffix negation (NOT IN / NOT LIKE / NOT EXISTS).
|
||||
// Driven by the unaryExpression rule (`NOT? primary`), so NOT binds to
|
||||
// exactly one primary and only widens via parens.
|
||||
func TestCompile_NOT(t *testing.T) {
|
||||
runCompileCases(t, []compileCase{
|
||||
{
|
||||
subtestName: "NOT on a single comparison",
|
||||
dslQueryToCompile: `NOT name = 'foo'`,
|
||||
expectedSQL: `NOT (json_extract("dashboard"."data", '$.spec.display.name') = ?)`,
|
||||
expectedArgs: []any{"foo"},
|
||||
},
|
||||
{
|
||||
subtestName: "NOT binds tightly to its primary in an AND chain",
|
||||
dslQueryToCompile: `NOT name = 'foo' AND created_by = 'alice'`,
|
||||
expectedSQL: `NOT (json_extract("dashboard"."data", '$.spec.display.name') = ?) AND dashboard.created_by = ?`,
|
||||
expectedArgs: []any{"foo", "alice"},
|
||||
},
|
||||
{
|
||||
subtestName: "NOT applied to the second term in an AND chain",
|
||||
dslQueryToCompile: `locked = true AND NOT name = 'foo'`,
|
||||
expectedSQL: `dashboard.locked = ? AND NOT (json_extract("dashboard"."data", '$.spec.display.name') = ?)`,
|
||||
expectedArgs: []any{true, "foo"},
|
||||
},
|
||||
{
|
||||
subtestName: "NOT around a parenthesized OR",
|
||||
dslQueryToCompile: `NOT (locked = true OR public = true)`,
|
||||
expectedSQL: `NOT ((dashboard.locked = ? OR pd.id IS NOT NULL))`,
|
||||
expectedArgs: []any{true},
|
||||
},
|
||||
{
|
||||
subtestName: "double NOT via parens",
|
||||
dslQueryToCompile: `NOT (NOT name = 'foo')`,
|
||||
expectedSQL: `NOT ((NOT (json_extract("dashboard"."data", '$.spec.display.name') = ?)))`,
|
||||
expectedArgs: []any{"foo"},
|
||||
},
|
||||
{
|
||||
subtestName: "NOT on a tag equality",
|
||||
dslQueryToCompile: `NOT team = 'pulse'`,
|
||||
expectedSQL: `
|
||||
NOT (
|
||||
EXISTS (
|
||||
SELECT 1 FROM tag_relation tr
|
||||
JOIN tag t ON t.id = tr.tag_id
|
||||
WHERE tr.kind = '"dashboard"' AND tr.resource_id = dashboard.id
|
||||
AND LOWER(t.key) = LOWER(?)
|
||||
AND t.value = ?
|
||||
)
|
||||
)`,
|
||||
expectedArgs: []any{"team", "pulse"},
|
||||
},
|
||||
{
|
||||
subtestName: "NOT team = ... AND name = ...",
|
||||
dslQueryToCompile: `NOT team = 'pulse' AND name = 'overview'`,
|
||||
expectedSQL: `
|
||||
NOT (
|
||||
EXISTS (
|
||||
SELECT 1 FROM tag_relation tr
|
||||
JOIN tag t ON t.id = tr.tag_id
|
||||
WHERE tr.kind = '"dashboard"' AND tr.resource_id = dashboard.id
|
||||
AND LOWER(t.key) = LOWER(?)
|
||||
AND t.value = ?
|
||||
)
|
||||
)
|
||||
AND json_extract("dashboard"."data", '$.spec.display.name') = ?`,
|
||||
expectedArgs: []any{"team", "pulse", "overview"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompile_ComplexExamples(t *testing.T) {
|
||||
runCompileCases(t, []compileCase{
|
||||
{
|
||||
subtestName: "name CONTAINS + tag LIKE + created_by + database =",
|
||||
dslQueryToCompile: `name CONTAINS 'overview' AND tag LIKE 'prod%' AND created_by = 'naman.verma@signoz.io' AND database = 'mongo'`,
|
||||
expectedSQL: `
|
||||
json_extract("dashboard"."data", '$.spec.display.name') LIKE ?
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM tag_relation tr
|
||||
JOIN tag t ON t.id = tr.tag_id
|
||||
WHERE tr.kind = '"dashboard"' AND tr.resource_id = dashboard.id
|
||||
AND LOWER(t.key) = LOWER(?)
|
||||
AND t.value LIKE ?
|
||||
)
|
||||
AND dashboard.created_by = ?
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM tag_relation tr
|
||||
JOIN tag t ON t.id = tr.tag_id
|
||||
WHERE tr.kind = '"dashboard"' AND tr.resource_id = dashboard.id
|
||||
AND LOWER(t.key) = LOWER(?)
|
||||
AND t.value = ?
|
||||
)`,
|
||||
expectedArgs: []any{"%overview%", "tag", "prod%", "naman.verma@signoz.io", "database", "mongo"},
|
||||
},
|
||||
{
|
||||
subtestName: "team IN AND database EXISTS",
|
||||
dslQueryToCompile: `team IN ['pulse', 'events'] AND database EXISTS`,
|
||||
expectedSQL: `
|
||||
EXISTS (
|
||||
SELECT 1 FROM tag_relation tr
|
||||
JOIN tag t ON t.id = tr.tag_id
|
||||
WHERE tr.kind = '"dashboard"' AND tr.resource_id = dashboard.id
|
||||
AND LOWER(t.key) = LOWER(?)
|
||||
AND t.value IN (?, ?)
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM tag_relation tr
|
||||
JOIN tag t ON t.id = tr.tag_id
|
||||
WHERE tr.kind = '"dashboard"' AND tr.resource_id = dashboard.id
|
||||
AND LOWER(t.key) = LOWER(?)
|
||||
)`,
|
||||
expectedArgs: []any{"team", "pulse", "events", "database"},
|
||||
},
|
||||
{
|
||||
subtestName: "nested OR / AND with parens",
|
||||
dslQueryToCompile: `(database IN ['sql', 'redis', 'mongo'] OR name LIKE '%database%') AND (team = 'pulse' OR name LIKE '%pulse%')`,
|
||||
expectedSQL: `
|
||||
(
|
||||
EXISTS (
|
||||
SELECT 1 FROM tag_relation tr
|
||||
JOIN tag t ON t.id = tr.tag_id
|
||||
WHERE tr.kind = '"dashboard"' AND tr.resource_id = dashboard.id
|
||||
AND LOWER(t.key) = LOWER(?)
|
||||
AND t.value IN (?, ?, ?)
|
||||
)
|
||||
OR json_extract("dashboard"."data", '$.spec.display.name') LIKE ?
|
||||
)
|
||||
AND (
|
||||
EXISTS (
|
||||
SELECT 1 FROM tag_relation tr
|
||||
JOIN tag t ON t.id = tr.tag_id
|
||||
WHERE tr.kind = '"dashboard"' AND tr.resource_id = dashboard.id
|
||||
AND LOWER(t.key) = LOWER(?)
|
||||
AND t.value = ?
|
||||
)
|
||||
OR json_extract("dashboard"."data", '$.spec.display.name') LIKE ?
|
||||
)`,
|
||||
expectedArgs: []any{"database", "sql", "redis", "mongo", "%database%", "team", "pulse", "%pulse%"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompile_Rejections(t *testing.T) {
|
||||
runCompileCases(t, []compileCase{
|
||||
{
|
||||
subtestName: "rejects op outside per-reserved-key allowlist",
|
||||
dslQueryToCompile: `name BETWEEN 'a' AND 'z'`,
|
||||
expectedErrShouldContain: "operator",
|
||||
},
|
||||
{
|
||||
subtestName: "rejects BETWEEN on a tag key",
|
||||
dslQueryToCompile: `team BETWEEN 'a' AND 'z'`,
|
||||
expectedErrShouldContain: "operator",
|
||||
},
|
||||
{
|
||||
subtestName: "rejects non-bool on locked",
|
||||
dslQueryToCompile: `locked = 'yes'`,
|
||||
expectedErrShouldContain: "boolean",
|
||||
},
|
||||
{
|
||||
subtestName: "rejects non-RFC3339 timestamp",
|
||||
dslQueryToCompile: `created_at >= 'not-a-date'`,
|
||||
expectedErrShouldContain: "RFC3339",
|
||||
},
|
||||
{
|
||||
subtestName: "rejects REGEXP — not yet supported",
|
||||
dslQueryToCompile: `name REGEXP '.*'`,
|
||||
expectedErrShouldContain: "REGEXP",
|
||||
},
|
||||
{
|
||||
subtestName: "rejects syntax error from grammar",
|
||||
dslQueryToCompile: `name = `,
|
||||
expectedErrShouldContain: "syntax",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func formatter(t *testing.T) sqlstore.SQLFormatter {
|
||||
t.Helper()
|
||||
p := sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual)
|
||||
return p.Formatter()
|
||||
}
|
||||
|
||||
func normalizeSQL(s string) string {
|
||||
s = strings.Join(strings.Fields(s), " ")
|
||||
s = strings.ReplaceAll(s, "( ", "(")
|
||||
s = strings.ReplaceAll(s, " )", ")")
|
||||
return s
|
||||
}
|
||||
@@ -1,592 +0,0 @@
|
||||
package listfilter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/parser/filterquery"
|
||||
grammar "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
qbtypesv5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/antlr4-go/antlr/v4"
|
||||
)
|
||||
|
||||
// fragment is one composable WHERE fragment. sql uses `?` placeholders;
|
||||
// args lines up positionally with the placeholders.
|
||||
type fragment struct {
|
||||
sql string
|
||||
args []any
|
||||
}
|
||||
|
||||
func newFragment(sql string, args ...any) *fragment {
|
||||
return &fragment{sql: sql, args: args}
|
||||
}
|
||||
|
||||
type visitor struct {
|
||||
grammar.BaseFilterQueryVisitor
|
||||
formatter sqlstore.SQLFormatter
|
||||
errors []string
|
||||
}
|
||||
|
||||
func newVisitor(formatter sqlstore.SQLFormatter) *visitor {
|
||||
return &visitor{
|
||||
formatter: formatter,
|
||||
}
|
||||
}
|
||||
|
||||
// Emitted WHERE fragment uses aliases `dashboard` and `pd` (public_dashboard).
|
||||
func (v *visitor) compile(query string) (*fragment, []string) {
|
||||
tree, _, collector := filterquery.Parse(query)
|
||||
if len(collector.Errors) > 0 {
|
||||
return nil, collector.Errors
|
||||
}
|
||||
frag, _ := v.visit(tree).(*fragment)
|
||||
return frag, nil
|
||||
}
|
||||
|
||||
func (v *visitor) visit(tree antlr.ParseTree) any {
|
||||
if tree == nil {
|
||||
return nil
|
||||
}
|
||||
return tree.Accept(v)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// methods from grammar.BaseFilterQueryVisitor that are overridden
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
func (v *visitor) VisitQuery(ctx *grammar.QueryContext) any {
|
||||
return v.visit(ctx.Expression())
|
||||
}
|
||||
|
||||
func (v *visitor) VisitExpression(ctx *grammar.ExpressionContext) any {
|
||||
return v.visit(ctx.OrExpression())
|
||||
}
|
||||
|
||||
func (v *visitor) VisitOrExpression(ctx *grammar.OrExpressionContext) any {
|
||||
parts := ctx.AllAndExpression()
|
||||
frags := make([]*fragment, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if f, ok := v.visit(p).(*fragment); ok && f != nil {
|
||||
frags = append(frags, f)
|
||||
}
|
||||
}
|
||||
return joinFragments(frags, "OR")
|
||||
}
|
||||
|
||||
func (v *visitor) VisitAndExpression(ctx *grammar.AndExpressionContext) any {
|
||||
parts := ctx.AllUnaryExpression()
|
||||
frags := make([]*fragment, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if f, ok := v.visit(p).(*fragment); ok && f != nil {
|
||||
frags = append(frags, f)
|
||||
}
|
||||
}
|
||||
return joinFragments(frags, "AND")
|
||||
}
|
||||
|
||||
func (v *visitor) VisitUnaryExpression(ctx *grammar.UnaryExpressionContext) any {
|
||||
f, _ := v.visit(ctx.Primary()).(*fragment)
|
||||
if f == nil {
|
||||
return nil
|
||||
}
|
||||
if ctx.NOT() != nil {
|
||||
return newFragment("NOT ("+f.sql+")", f.args...)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func (v *visitor) VisitPrimary(ctx *grammar.PrimaryContext) any {
|
||||
if ctx.OrExpression() != nil {
|
||||
f, _ := v.visit(ctx.OrExpression()).(*fragment)
|
||||
if f == nil {
|
||||
return nil
|
||||
}
|
||||
return newFragment("("+f.sql+")", f.args...)
|
||||
}
|
||||
if ctx.Comparison() != nil {
|
||||
return v.visit(ctx.Comparison())
|
||||
}
|
||||
// Bare keys, values, full text, and function calls are not part of the
|
||||
// dashboard list DSL.
|
||||
v.addErr("unsupported expression %q — every term must be of the form `key OP value`", ctx.GetText())
|
||||
return nil
|
||||
}
|
||||
|
||||
// VisitComparison dispatches a single `key OP value` term. A key that matches
|
||||
// a reserved DSL key (name, description, etc.) becomes a column-level
|
||||
// predicate; any other identifier is treated as a tag key — the operator
|
||||
// applies to the tag's value, with a case-insensitive match on the tag's key.
|
||||
func (v *visitor) VisitComparison(ctx *grammar.ComparisonContext) any {
|
||||
key, ok := v.parseKey(ctx)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
op, ok := v.opFromContext(ctx)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
if reservedOpSet, isReserved := reservedOps[dashboardtypes.DSLKey(key)]; isReserved {
|
||||
if _, allowed := reservedOpSet[op]; !allowed {
|
||||
v.addErr("operator %s is not allowed for key %q", opName(op), key)
|
||||
return nil
|
||||
}
|
||||
switch dashboardtypes.DSLKey(key) {
|
||||
case dashboardtypes.DSLKeyName:
|
||||
return v.emitJSONStringComparison(ctx, op, "$.spec.display.name")
|
||||
case dashboardtypes.DSLKeyDescription:
|
||||
return v.emitJSONStringComparison(ctx, op, "$.spec.display.description")
|
||||
case dashboardtypes.DSLKeyCreatedAt:
|
||||
return v.emitTimestampComparison(ctx, op, "dashboard.created_at")
|
||||
case dashboardtypes.DSLKeyUpdatedAt:
|
||||
return v.emitTimestampComparison(ctx, op, "dashboard.updated_at")
|
||||
case dashboardtypes.DSLKeyCreatedBy:
|
||||
return v.emitStringComparison(ctx, op, "dashboard.created_by")
|
||||
case dashboardtypes.DSLKeyLocked:
|
||||
return v.emitBoolComparison(ctx, op, "dashboard.locked")
|
||||
case dashboardtypes.DSLKeyPublic:
|
||||
return v.emitPublicComparison(ctx, op)
|
||||
}
|
||||
}
|
||||
|
||||
if _, allowed := tagKeyOps[op]; !allowed {
|
||||
v.addErr("operator %s is not allowed on a tag-key filter", opName(op))
|
||||
return nil
|
||||
}
|
||||
return v.emitTagComparison(ctx, op, key)
|
||||
}
|
||||
|
||||
func (v *visitor) parseKey(ctx *grammar.ComparisonContext) (string, bool) {
|
||||
keyText := strings.ToLower(strings.TrimSpace(ctx.Key().GetText()))
|
||||
if keyText == "" {
|
||||
v.addErr("filter key cannot be empty")
|
||||
return "", false
|
||||
}
|
||||
return keyText, true
|
||||
}
|
||||
|
||||
func (v *visitor) opFromContext(ctx *grammar.ComparisonContext) (qbtypesv5.FilterOperator, bool) {
|
||||
switch {
|
||||
case ctx.EQUALS() != nil:
|
||||
return qbtypesv5.FilterOperatorEqual, true
|
||||
case ctx.NOT_EQUALS() != nil, ctx.NEQ() != nil:
|
||||
return qbtypesv5.FilterOperatorNotEqual, true
|
||||
case ctx.LT() != nil:
|
||||
return qbtypesv5.FilterOperatorLessThan, true
|
||||
case ctx.LE() != nil:
|
||||
return qbtypesv5.FilterOperatorLessThanOrEq, true
|
||||
case ctx.GT() != nil:
|
||||
return qbtypesv5.FilterOperatorGreaterThan, true
|
||||
case ctx.GE() != nil:
|
||||
return qbtypesv5.FilterOperatorGreaterThanOrEq, true
|
||||
case ctx.BETWEEN() != nil:
|
||||
if ctx.NOT() != nil {
|
||||
return qbtypesv5.FilterOperatorNotBetween, true
|
||||
}
|
||||
return qbtypesv5.FilterOperatorBetween, true
|
||||
case ctx.LIKE() != nil:
|
||||
if ctx.NOT() != nil {
|
||||
return qbtypesv5.FilterOperatorNotLike, true
|
||||
}
|
||||
return qbtypesv5.FilterOperatorLike, true
|
||||
case ctx.ILIKE() != nil:
|
||||
if ctx.NOT() != nil {
|
||||
return qbtypesv5.FilterOperatorNotILike, true
|
||||
}
|
||||
return qbtypesv5.FilterOperatorILike, true
|
||||
case ctx.CONTAINS() != nil:
|
||||
if ctx.NOT() != nil {
|
||||
return qbtypesv5.FilterOperatorNotContains, true
|
||||
}
|
||||
return qbtypesv5.FilterOperatorContains, true
|
||||
case ctx.REGEXP() != nil:
|
||||
if ctx.NOT() != nil {
|
||||
return qbtypesv5.FilterOperatorNotRegexp, true
|
||||
}
|
||||
return qbtypesv5.FilterOperatorRegexp, true
|
||||
case ctx.InClause() != nil:
|
||||
return qbtypesv5.FilterOperatorIn, true
|
||||
case ctx.NotInClause() != nil:
|
||||
return qbtypesv5.FilterOperatorNotIn, true
|
||||
case ctx.EXISTS() != nil:
|
||||
if ctx.NOT() != nil {
|
||||
return qbtypesv5.FilterOperatorNotExists, true
|
||||
}
|
||||
return qbtypesv5.FilterOperatorExists, true
|
||||
}
|
||||
v.addErr("could not determine operator in expression %q", ctx.GetText())
|
||||
return qbtypesv5.FilterOperatorUnknown, false
|
||||
}
|
||||
|
||||
// ─── per-key emitters ────────────────────────────────────────────────────────
|
||||
|
||||
func (v *visitor) emitJSONStringComparison(ctx *grammar.ComparisonContext, op qbtypesv5.FilterOperator, jsonPath string) *fragment {
|
||||
colExpr := string(v.formatter.JSONExtractString("dashboard.data", jsonPath))
|
||||
return v.emitStringOp(ctx, op, colExpr, string(dashboardtypes.DSLKeyName))
|
||||
}
|
||||
|
||||
func (v *visitor) emitStringComparison(ctx *grammar.ComparisonContext, op qbtypesv5.FilterOperator, colExpr string) *fragment {
|
||||
return v.emitStringOp(ctx, op, colExpr, string(dashboardtypes.DSLKeyCreatedBy))
|
||||
}
|
||||
|
||||
// emitStringOp covers all the operators the spec allows on text-shaped keys
|
||||
// (name, description, created_by). Tag uses a separate emitter that wraps each
|
||||
// produced fragment in an EXISTS subquery.
|
||||
func (v *visitor) emitStringOp(ctx *grammar.ComparisonContext, op qbtypesv5.FilterOperator, colExpr, keyForErr string) *fragment {
|
||||
switch op {
|
||||
case qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual,
|
||||
qbtypesv5.FilterOperatorLike, qbtypesv5.FilterOperatorNotLike:
|
||||
val, ok := v.singleString(ctx, keyForErr)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return newFragment(colExpr+" "+opName(op)+" ?", val)
|
||||
case qbtypesv5.FilterOperatorILike, qbtypesv5.FilterOperatorNotILike:
|
||||
val, ok := v.singleString(ctx, keyForErr)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
// SQLite has no ILIKE keyword and Postgres LIKE is case-sensitive — emit
|
||||
// LOWER(col) LIKE LOWER(?) so behavior is identical on both dialects.
|
||||
lowerCol := string(v.formatter.LowerExpression(colExpr))
|
||||
return newFragment(lowerCol+" "+opName(iLikeToLike(op))+" LOWER(?)", val)
|
||||
case qbtypesv5.FilterOperatorContains, qbtypesv5.FilterOperatorNotContains:
|
||||
val, ok := v.singleString(ctx, keyForErr)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return newFragment(colExpr+" "+opName(containsToLike(op))+" ?", "%"+escapeLike(val)+"%")
|
||||
case qbtypesv5.FilterOperatorRegexp, qbtypesv5.FilterOperatorNotRegexp:
|
||||
v.addErr("REGEXP filtering on %q is not yet supported", keyForErr)
|
||||
return nil
|
||||
case qbtypesv5.FilterOperatorIn, qbtypesv5.FilterOperatorNotIn:
|
||||
vals, ok := v.stringList(ctx, keyForErr)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return inFragment(colExpr, op, vals)
|
||||
}
|
||||
v.addErr("operator %s on %q is not implemented", opName(op), keyForErr)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *visitor) emitTimestampComparison(ctx *grammar.ComparisonContext, op qbtypesv5.FilterOperator, colExpr string) *fragment {
|
||||
switch op {
|
||||
case qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual,
|
||||
qbtypesv5.FilterOperatorLessThan, qbtypesv5.FilterOperatorLessThanOrEq,
|
||||
qbtypesv5.FilterOperatorGreaterThan, qbtypesv5.FilterOperatorGreaterThanOrEq:
|
||||
t, ok := v.singleTimestamp(ctx)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return newFragment(colExpr+" "+opName(op)+" ?", t)
|
||||
case qbtypesv5.FilterOperatorBetween, qbtypesv5.FilterOperatorNotBetween:
|
||||
ts, ok := v.twoTimestamps(ctx)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return newFragment(colExpr+" "+opName(op)+" ? AND ?", ts[0], ts[1])
|
||||
}
|
||||
v.addErr("operator %s on timestamp is not implemented", opName(op))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *visitor) emitBoolComparison(ctx *grammar.ComparisonContext, op qbtypesv5.FilterOperator, colExpr string) *fragment {
|
||||
b, ok := v.singleBool(ctx)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return newFragment(colExpr+" "+opName(op)+" ?", b)
|
||||
}
|
||||
|
||||
// emitPublicComparison renders `public = true|false` against the LEFT-joined
|
||||
// public_dashboard alias `pd`. The spec says public is a virtual column whose
|
||||
// truthiness is the existence of a row in public_dashboard.
|
||||
func (v *visitor) emitPublicComparison(ctx *grammar.ComparisonContext, op qbtypesv5.FilterOperator) *fragment {
|
||||
b, ok := v.singleBool(ctx)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
want := b
|
||||
if op == qbtypesv5.FilterOperatorNotEqual {
|
||||
want = !b
|
||||
}
|
||||
if want {
|
||||
return newFragment("pd.id IS NOT NULL")
|
||||
}
|
||||
return newFragment("pd.id IS NULL")
|
||||
}
|
||||
|
||||
// TODO: drop the extra quotes once coretypes.Kind stops being double-encoded
|
||||
// in the tag_relation.kind column.
|
||||
const tagSubqueryPrefix = "SELECT 1 FROM tag_relation tr JOIN tag t ON t.id = tr.tag_id " +
|
||||
`WHERE tr.kind = '"dashboard"' AND tr.resource_id = dashboard.id ` +
|
||||
"AND LOWER(t.key) = LOWER(?)"
|
||||
|
||||
// emitTagComparison wraps the inner predicate in EXISTS (or NOT EXISTS for the
|
||||
// negated operators). The inner predicate matches the tag's key
|
||||
// case-insensitively and applies the user's operator to the tag's value.
|
||||
// EXISTS / NOT EXISTS skip the value predicate — they assert the existence
|
||||
// (or absence) of any tag with the given key.
|
||||
func (v *visitor) emitTagComparison(ctx *grammar.ComparisonContext, op qbtypesv5.FilterOperator, key string) *fragment {
|
||||
if op == qbtypesv5.FilterOperatorExists || op == qbtypesv5.FilterOperatorNotExists {
|
||||
wrapper := "EXISTS"
|
||||
if op == qbtypesv5.FilterOperatorNotExists {
|
||||
wrapper = "NOT EXISTS"
|
||||
}
|
||||
return newFragment(wrapper+" ("+tagSubqueryPrefix+")", key)
|
||||
}
|
||||
|
||||
// All other tag operators take the positive form of the value predicate
|
||||
// and toggle the EXISTS wrapper for negation. Inverse() flips Not<X> → <X>.
|
||||
negated := op.IsNegativeOperator()
|
||||
posOp := op
|
||||
if negated {
|
||||
posOp = op.Inverse()
|
||||
}
|
||||
inner := v.emitStringOp(ctx, posOp, "t.value", key)
|
||||
if inner == nil {
|
||||
return nil
|
||||
}
|
||||
wrapper := "EXISTS"
|
||||
if negated {
|
||||
wrapper = "NOT EXISTS"
|
||||
}
|
||||
args := append([]any{key}, inner.args...)
|
||||
return newFragment(wrapper+" ("+tagSubqueryPrefix+" AND "+inner.sql+")", args...)
|
||||
}
|
||||
|
||||
// ─── value extraction helpers ───────────────────────────────────────────────
|
||||
|
||||
func (v *visitor) addErr(format string, args ...any) {
|
||||
v.errors = append(v.errors, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (v *visitor) singleString(ctx *grammar.ComparisonContext, keyForErr string) (string, bool) {
|
||||
values := ctx.AllValue()
|
||||
if len(values) != 1 {
|
||||
v.addErr("expected exactly one value for %q", keyForErr)
|
||||
return "", false
|
||||
}
|
||||
return v.stringValue(values[0], keyForErr)
|
||||
}
|
||||
|
||||
func (v *visitor) singleBool(ctx *grammar.ComparisonContext) (bool, bool) {
|
||||
values := ctx.AllValue()
|
||||
if len(values) != 1 {
|
||||
v.addErr("expected a single boolean (true/false)")
|
||||
return false, false
|
||||
}
|
||||
return v.boolValue(values[0])
|
||||
}
|
||||
|
||||
func (v *visitor) singleTimestamp(ctx *grammar.ComparisonContext) (time.Time, bool) {
|
||||
values := ctx.AllValue()
|
||||
if len(values) != 1 {
|
||||
v.addErr("expected a single RFC3339 timestamp")
|
||||
return time.Time{}, false
|
||||
}
|
||||
return v.timestampValue(values[0])
|
||||
}
|
||||
|
||||
func (v *visitor) twoTimestamps(ctx *grammar.ComparisonContext) ([2]time.Time, bool) {
|
||||
values := ctx.AllValue()
|
||||
if len(values) != 2 {
|
||||
v.addErr("BETWEEN expects two RFC3339 timestamps")
|
||||
return [2]time.Time{}, false
|
||||
}
|
||||
a, ok1 := v.timestampValue(values[0])
|
||||
b, ok2 := v.timestampValue(values[1])
|
||||
if !ok1 || !ok2 {
|
||||
return [2]time.Time{}, false
|
||||
}
|
||||
return [2]time.Time{a, b}, true
|
||||
}
|
||||
|
||||
func (v *visitor) stringList(ctx *grammar.ComparisonContext, keyForErr string) ([]string, bool) {
|
||||
var valuesCtx []grammar.IValueContext
|
||||
switch {
|
||||
case ctx.InClause() != nil:
|
||||
ic := ctx.InClause()
|
||||
if ic.ValueList() != nil {
|
||||
valuesCtx = ic.ValueList().AllValue()
|
||||
} else {
|
||||
valuesCtx = []grammar.IValueContext{ic.Value()}
|
||||
}
|
||||
case ctx.NotInClause() != nil:
|
||||
nc := ctx.NotInClause()
|
||||
if nc.ValueList() != nil {
|
||||
valuesCtx = nc.ValueList().AllValue()
|
||||
} else {
|
||||
valuesCtx = []grammar.IValueContext{nc.Value()}
|
||||
}
|
||||
default:
|
||||
v.addErr("IN clause is missing for %q", keyForErr)
|
||||
return nil, false
|
||||
}
|
||||
if len(valuesCtx) == 0 {
|
||||
v.addErr("IN list for %q is empty", keyForErr)
|
||||
return nil, false
|
||||
}
|
||||
out := make([]string, 0, len(valuesCtx))
|
||||
for _, vc := range valuesCtx {
|
||||
s, ok := v.stringValue(vc, keyForErr)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
func (v *visitor) stringValue(ctx grammar.IValueContext, keyForErr string) (string, bool) {
|
||||
if ctx.QUOTED_TEXT() != nil {
|
||||
return trimQuotes(ctx.QUOTED_TEXT().GetText()), true
|
||||
}
|
||||
if ctx.KEY() != nil {
|
||||
// Bare tokens are accepted as strings, mirroring the FilterQuery lexer's
|
||||
// treatment of unquoted identifiers on the value side.
|
||||
return ctx.KEY().GetText(), true
|
||||
}
|
||||
v.addErr("expected a string value for %q, got %q", keyForErr, ctx.GetText())
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (v *visitor) boolValue(ctx grammar.IValueContext) (bool, bool) {
|
||||
if ctx.BOOL() == nil {
|
||||
v.addErr("expected a boolean (true/false), got %q", ctx.GetText())
|
||||
return false, false
|
||||
}
|
||||
return strings.EqualFold(ctx.BOOL().GetText(), "true"), true
|
||||
}
|
||||
|
||||
func (v *visitor) timestampValue(ctx grammar.IValueContext) (time.Time, bool) {
|
||||
if ctx.QUOTED_TEXT() == nil {
|
||||
v.addErr("expected an RFC3339 timestamp string, got %q", ctx.GetText())
|
||||
return time.Time{}, false
|
||||
}
|
||||
raw := trimQuotes(ctx.QUOTED_TEXT().GetText())
|
||||
t, err := time.Parse(time.RFC3339, raw)
|
||||
if err != nil {
|
||||
v.addErr("invalid RFC3339 timestamp %q: %s", raw, err.Error())
|
||||
return time.Time{}, false
|
||||
}
|
||||
return t, true
|
||||
}
|
||||
|
||||
// ─── fragment helpers ────────────────────────────────────────────────────────
|
||||
|
||||
func joinFragments(frags []*fragment, conn string) *fragment {
|
||||
if len(frags) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(frags) == 1 {
|
||||
return frags[0]
|
||||
}
|
||||
parts := make([]string, len(frags))
|
||||
args := make([]any, 0)
|
||||
for i, f := range frags {
|
||||
parts[i] = f.sql
|
||||
args = append(args, f.args...)
|
||||
}
|
||||
return newFragment(strings.Join(parts, " "+conn+" "), args...)
|
||||
}
|
||||
|
||||
func inFragment(colExpr string, op qbtypesv5.FilterOperator, vals []string) *fragment {
|
||||
placeholders := strings.Repeat("?, ", len(vals))
|
||||
placeholders = placeholders[:len(placeholders)-2]
|
||||
args := make([]any, len(vals))
|
||||
for i, s := range vals {
|
||||
args[i] = s
|
||||
}
|
||||
return newFragment(colExpr+" "+opName(op)+" ("+placeholders+")", args...)
|
||||
}
|
||||
|
||||
// opName returns the user-facing spelling of a FilterOperator. For the
|
||||
// operators we emit directly into SQL (=, !=, <, LIKE, IN, BETWEEN, …) the
|
||||
// spelling doubles as the SQL keyword. For the operators we don't emit
|
||||
// directly (ILIKE, CONTAINS, REGEXP, EXISTS, NOT EXISTS) it's only used in
|
||||
// error messages.
|
||||
func opName(op qbtypesv5.FilterOperator) string {
|
||||
switch op {
|
||||
case qbtypesv5.FilterOperatorEqual:
|
||||
return "="
|
||||
case qbtypesv5.FilterOperatorNotEqual:
|
||||
return "!="
|
||||
case qbtypesv5.FilterOperatorLessThan:
|
||||
return "<"
|
||||
case qbtypesv5.FilterOperatorLessThanOrEq:
|
||||
return "<="
|
||||
case qbtypesv5.FilterOperatorGreaterThan:
|
||||
return ">"
|
||||
case qbtypesv5.FilterOperatorGreaterThanOrEq:
|
||||
return ">="
|
||||
case qbtypesv5.FilterOperatorBetween:
|
||||
return "BETWEEN"
|
||||
case qbtypesv5.FilterOperatorNotBetween:
|
||||
return "NOT BETWEEN"
|
||||
case qbtypesv5.FilterOperatorLike:
|
||||
return "LIKE"
|
||||
case qbtypesv5.FilterOperatorNotLike:
|
||||
return "NOT LIKE"
|
||||
case qbtypesv5.FilterOperatorILike:
|
||||
return "ILIKE"
|
||||
case qbtypesv5.FilterOperatorNotILike:
|
||||
return "NOT ILIKE"
|
||||
case qbtypesv5.FilterOperatorContains:
|
||||
return "CONTAINS"
|
||||
case qbtypesv5.FilterOperatorNotContains:
|
||||
return "NOT CONTAINS"
|
||||
case qbtypesv5.FilterOperatorRegexp:
|
||||
return "REGEXP"
|
||||
case qbtypesv5.FilterOperatorNotRegexp:
|
||||
return "NOT REGEXP"
|
||||
case qbtypesv5.FilterOperatorIn:
|
||||
return "IN"
|
||||
case qbtypesv5.FilterOperatorNotIn:
|
||||
return "NOT IN"
|
||||
case qbtypesv5.FilterOperatorExists:
|
||||
return "EXISTS"
|
||||
case qbtypesv5.FilterOperatorNotExists:
|
||||
return "NOT EXISTS"
|
||||
}
|
||||
return "?"
|
||||
}
|
||||
|
||||
// iLikeToLike maps ILIKE → LIKE for the LOWER(col) LIKE LOWER(?) emission.
|
||||
func iLikeToLike(op qbtypesv5.FilterOperator) qbtypesv5.FilterOperator {
|
||||
if op == qbtypesv5.FilterOperatorNotILike {
|
||||
return qbtypesv5.FilterOperatorNotLike
|
||||
}
|
||||
return qbtypesv5.FilterOperatorLike
|
||||
}
|
||||
|
||||
// containsToLike maps CONTAINS → LIKE for the LIKE '%val%' emission.
|
||||
func containsToLike(op qbtypesv5.FilterOperator) qbtypesv5.FilterOperator {
|
||||
if op == qbtypesv5.FilterOperatorNotContains {
|
||||
return qbtypesv5.FilterOperatorNotLike
|
||||
}
|
||||
return qbtypesv5.FilterOperatorLike
|
||||
}
|
||||
|
||||
// escapeLike escapes the LIKE meta-characters % and _ in user input so that a
|
||||
// CONTAINS query of `50%` doesn't match every value containing `50`.
|
||||
func escapeLike(s string) string {
|
||||
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
func trimQuotes(s string) string {
|
||||
if len(s) >= 2 {
|
||||
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
|
||||
s = s[1 : len(s)-1]
|
||||
}
|
||||
}
|
||||
s = strings.ReplaceAll(s, `\\`, `\`)
|
||||
s = strings.ReplaceAll(s, `\'`, `'`)
|
||||
return s
|
||||
}
|
||||
@@ -32,7 +32,6 @@ const (
|
||||
DSLKeyCreatedBy DSLKey = "created_by"
|
||||
DSLKeyLocked DSLKey = "locked"
|
||||
DSLKeyPublic DSLKey = "public"
|
||||
DSLKeySource DSLKey = "source"
|
||||
)
|
||||
|
||||
// reservedDSLKeys are dashboard column-level filter names in the list-query DSL.
|
||||
@@ -46,7 +45,6 @@ var reservedDSLKeys = map[DSLKey]struct{}{
|
||||
DSLKeyCreatedBy: {},
|
||||
DSLKeyLocked: {},
|
||||
DSLKeyPublic: {},
|
||||
DSLKeySource: {},
|
||||
}
|
||||
|
||||
type DashboardV2 struct {
|
||||
@@ -64,54 +62,6 @@ type DashboardV2 struct {
|
||||
Spec DashboardSpec `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
func (d *DashboardV2) CanUpdate() error {
|
||||
if d.Source == SourceIntegration {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "integration dashboards cannot be modified")
|
||||
}
|
||||
if d.Locked {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot update a locked dashboard, please unlock the dashboard to update")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DashboardV2) Update(updatable UpdatableDashboardV2, updatedBy string, resolvedTags []*tagtypes.Tag) error {
|
||||
if err := d.CanUpdate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if updatable.Name != d.Name {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardImmutable, "name is immutable; cannot change from %q to %q", d.Name, updatable.Name)
|
||||
}
|
||||
d.DashboardV2MetadataBase = updatable.DashboardV2MetadataBase
|
||||
d.Tags = resolvedTags
|
||||
d.Spec = updatable.Spec
|
||||
d.UpdatedBy = updatedBy
|
||||
d.UpdatedAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DashboardV2) CanLockUnlock(isAdmin bool, updatedBy string) error {
|
||||
if d.Source == SourceIntegration {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "integration dashboards cannot be locked or unlocked")
|
||||
}
|
||||
if d.Source == SourceSystem {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "system dashboards cannot be locked or unlocked")
|
||||
}
|
||||
if d.CreatedBy != updatedBy && !isAdmin {
|
||||
return errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "you are not authorized to lock/unlock this dashboard")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DashboardV2) LockUnlock(lock bool, isAdmin bool, updatedBy string) error {
|
||||
if err := d.CanLockUnlock(isAdmin, updatedBy); err != nil {
|
||||
return err
|
||||
}
|
||||
d.Locked = lock
|
||||
d.UpdatedBy = updatedBy
|
||||
d.UpdatedAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
type DashboardV2MetadataBase struct {
|
||||
SchemaVersion string `json:"schemaVersion" required:"true"`
|
||||
Image string `json:"image,omitempty"`
|
||||
@@ -176,7 +126,7 @@ func (p *PostableDashboardV2) Validate() error {
|
||||
if err := p.validateName(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateDashboardTags(p.Tags); err != nil {
|
||||
if err := p.validateTags(); err != nil {
|
||||
return err
|
||||
}
|
||||
return p.Spec.Validate()
|
||||
@@ -243,11 +193,11 @@ func generateDashboardName(displayName string) string {
|
||||
return prefix + "-" + string(suffix)
|
||||
}
|
||||
|
||||
func validateDashboardTags(tags []tagtypes.PostableTag) error {
|
||||
if len(tags) > MaxTagsPerDashboard {
|
||||
func (p *PostableDashboardV2) validateTags() error {
|
||||
if len(p.Tags) > MaxTagsPerDashboard {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "a dashboard can have at most %d tags", MaxTagsPerDashboard)
|
||||
}
|
||||
for _, tag := range tags {
|
||||
for _, tag := range p.Tags {
|
||||
if _, reserved := reservedDSLKeys[DSLKey(strings.ToLower(strings.TrimSpace(tag.Key)))]; reserved {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "tag key %q is reserved", tag.Key)
|
||||
}
|
||||
@@ -313,57 +263,6 @@ func (s StorableDashboardV2Data) toStorableDashboardData() (StorableDashboardDat
|
||||
|
||||
type StorableDashboardV2Metadata = DashboardV2MetadataBase
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Updatable
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
type UpdatableDashboardV2 struct {
|
||||
DashboardV2MetadataBase
|
||||
Name string `json:"name" required:"true"`
|
||||
Tags []tagtypes.PostableTag `json:"tags" required:"true"`
|
||||
Spec DashboardSpec `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
func (u *UpdatableDashboardV2) UnmarshalJSON(data []byte) error {
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.DisallowUnknownFields()
|
||||
type alias UpdatableDashboardV2
|
||||
var tmp alias
|
||||
if err := dec.Decode(&tmp); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s", err.Error())
|
||||
}
|
||||
*u = UpdatableDashboardV2(tmp)
|
||||
if u.Spec.Display == nil {
|
||||
u.Spec.Display = &common.Display{}
|
||||
}
|
||||
if u.Spec.Display.Name == "" {
|
||||
u.Spec.Display.Name = u.Name
|
||||
}
|
||||
return u.Validate()
|
||||
}
|
||||
|
||||
func (u *UpdatableDashboardV2) Validate() error {
|
||||
if u.SchemaVersion != SchemaVersion {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "schemaVersion must be %q, got %q", SchemaVersion, u.SchemaVersion)
|
||||
}
|
||||
if err := validateDashboardName(u.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateDashboardTags(u.Tags); err != nil {
|
||||
return err
|
||||
}
|
||||
return u.Spec.Validate()
|
||||
}
|
||||
|
||||
func (d DashboardV2) toUpdatableDashboardV2() UpdatableDashboardV2 {
|
||||
return UpdatableDashboardV2{
|
||||
DashboardV2MetadataBase: d.DashboardV2MetadataBase,
|
||||
Name: d.Name,
|
||||
Tags: tagtypes.NewPostableTagsFromTags(d.Tags),
|
||||
Spec: d.Spec,
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Convertors
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
jsonpatch "github.com/evanphx/json-patch/v5"
|
||||
"github.com/swaggest/jsonschema-go"
|
||||
)
|
||||
|
||||
// PatchableDashboardV2 is an RFC 6902 patch request.
|
||||
type PatchableDashboardV2 struct {
|
||||
// Ops shapes the OpenAPI schema; tagged so swaggest reflects it.
|
||||
Ops []JSONPatchOperation `json:"ops"`
|
||||
// patch holds the decoded payload, set by UnmarshalJSON.
|
||||
patch jsonpatch.Patch
|
||||
}
|
||||
|
||||
// PrepareJSONSchema collapses the struct's object schema into the bare ops array.
|
||||
func (PatchableDashboardV2) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
// Called on several passes; only the one with built properties carries `ops`.
|
||||
ops, ok := s.Properties["ops"]
|
||||
if !ok || ops.TypeObject == nil {
|
||||
return nil
|
||||
}
|
||||
*s = *ops.TypeObject
|
||||
return nil
|
||||
}
|
||||
|
||||
type JSONPatchOperation struct {
|
||||
Op PatchOp `json:"op" required:"true"`
|
||||
Path string `json:"path" required:"true" description:"JSON Pointer (RFC 6901) into the dashboard's postable shape — e.g. /spec/display/name, /spec/panels/<id>, /spec/panels/<id>/spec/queries/0, /tags/-."`
|
||||
// `value` is required for add/replace/test.
|
||||
Value any `json:"value,omitempty" description:"Value to add/replace/test against. The expected type depends on the path. Common shapes (see referenced schemas for the exact field set): /spec/panels/<id> takes a DashboardtypesPanel; /spec/panels/<id>/spec/queries/N (or /-) takes a DashboardtypesQuery; /spec/variables/N takes a DashboardtypesVariable; /spec/layouts/N takes a DashboardtypesLayout; /tags/N (or /-) takes a TagtypesPostableTag; /spec/display/name and other leaf string fields take a string. Required for add/replace/test; ignored for remove/move/copy."`
|
||||
// `from` is required for move/copy.
|
||||
From string `json:"from,omitempty" description:"Source JSON Pointer for move/copy ops; ignored for other ops."`
|
||||
}
|
||||
|
||||
// PatchOp covers the six RFC 6902 JSON Patch verbs.
|
||||
type PatchOp struct{ valuer.String }
|
||||
|
||||
var (
|
||||
PatchOpAdd = PatchOp{valuer.NewString("add")}
|
||||
PatchOpRemove = PatchOp{valuer.NewString("remove")}
|
||||
PatchOpReplace = PatchOp{valuer.NewString("replace")}
|
||||
PatchOpMove = PatchOp{valuer.NewString("move")}
|
||||
PatchOpCopy = PatchOp{valuer.NewString("copy")}
|
||||
PatchOpTest = PatchOp{valuer.NewString("test")}
|
||||
)
|
||||
|
||||
func (PatchOp) Enum() []any {
|
||||
return []any{PatchOpAdd, PatchOpRemove, PatchOpReplace, PatchOpMove, PatchOpCopy, PatchOpTest}
|
||||
}
|
||||
|
||||
func (p *PatchableDashboardV2) UnmarshalJSON(data []byte) error {
|
||||
patch, err := jsonpatch.DecodePatch(data)
|
||||
if err != nil {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidPatch, "request body is not a valid RFC 6902 JSON Patch document").WithAdditional(err.Error())
|
||||
}
|
||||
if err := json.Unmarshal(data, &p.Ops); err != nil {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidPatch, "request body is not a valid RFC 6902 JSON Patch document").WithAdditional(err.Error())
|
||||
}
|
||||
p.patch = patch
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p PatchableDashboardV2) Apply(existing *DashboardV2) (*UpdatableDashboardV2, error) {
|
||||
existingAsUpdatable := existing.toUpdatableDashboardV2()
|
||||
raw, err := json.Marshal(existingAsUpdatable)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "marshal existing dashboard for patch")
|
||||
}
|
||||
patched, err := p.patch.ApplyWithOptions(raw, &jsonpatch.ApplyOptions{AllowMissingPathOnRemove: true, EnsurePathExistsOnAdd: true})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, errors.TypeInvalidInput, ErrCodeDashboardInvalidPatch, "JSON Patch could not be applied to the target dashboard")
|
||||
}
|
||||
out := &UpdatableDashboardV2{}
|
||||
if err := json.Unmarshal(patched, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -1,569 +0,0 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// basePostableJSON is the postable shape of a small but realistic v2
|
||||
// dashboard used as the base document for patch tests. Each panel carries
|
||||
// one builder query in the same shape production dashboards use
|
||||
// (aggregations, filter, groupBy populated), and the dashboard has one
|
||||
// variable — the variable is not patched in any test here, that's
|
||||
// covered in a separate variable-focused suite.
|
||||
const basePostableJSON = `{
|
||||
"schemaVersion": "v6",
|
||||
"name": "service-overview",
|
||||
"tags": [{"key": "team", "value": "alpha"}, {"key": "env", "value": "prod"}],
|
||||
"spec": {
|
||||
"display": {"name": "Service overview"},
|
||||
"variables": [
|
||||
{
|
||||
"kind": "ListVariable",
|
||||
"spec": {
|
||||
"name": "service",
|
||||
"allowAllValue": true,
|
||||
"allowMultiple": false,
|
||||
"plugin": {
|
||||
"kind": "signoz/DynamicVariable",
|
||||
"spec": {"name": "service.name", "signal": "metrics"}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{
|
||||
"metricName": "signoz_calls_total",
|
||||
"temporality": "cumulative",
|
||||
"timeAggregation": "rate",
|
||||
"spaceAggregation": "sum"
|
||||
}],
|
||||
"filter": {"expression": "service.name IN $service"},
|
||||
"groupBy": [{"name": "service.name", "fieldDataType": "string", "fieldContext": "tag"}]
|
||||
}}}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"p2": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"plugin": {"kind": "signoz/NumberPanel", "spec": {}},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
|
||||
"name": "X",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{
|
||||
"metricName": "signoz_latency_count",
|
||||
"temporality": "cumulative",
|
||||
"timeAggregation": "rate",
|
||||
"spaceAggregation": "sum"
|
||||
}]
|
||||
}}}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"layouts": [
|
||||
{
|
||||
"kind": "Grid",
|
||||
"spec": {
|
||||
"display": {"title": "Row 1"},
|
||||
"items": [
|
||||
{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
|
||||
{"x": 6, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p2"}}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"duration": "1h"
|
||||
}
|
||||
}`
|
||||
|
||||
func TestPatchableDashboardV2_Apply(t *testing.T) {
|
||||
// Apply doesn't mutate the input *DashboardV2 — it marshals it to
|
||||
// JSON, applies the patch, and unmarshals the result into a fresh
|
||||
// struct. Sharing one base across subtests is safe.
|
||||
var p PostableDashboardV2
|
||||
require.NoError(t, json.Unmarshal([]byte(basePostableJSON), &p), "base postable JSON must validate")
|
||||
testOrgID := valuer.GenerateUUID()
|
||||
base := p.NewDashboardV2(testOrgID, "somecreatedthisiguess@signoz.io", SourceUser)
|
||||
base.Tags = []*tagtypes.Tag{
|
||||
{Key: "team", Value: "alpha"},
|
||||
{Key: "env", Value: "prod"},
|
||||
}
|
||||
|
||||
decode := func(t *testing.T, body string) PatchableDashboardV2 {
|
||||
t.Helper()
|
||||
var patch PatchableDashboardV2
|
||||
require.NoError(t, json.Unmarshal([]byte(body), &patch))
|
||||
return patch
|
||||
}
|
||||
|
||||
// jsonOf marshals the patched dashboard back to JSON so subtests can
|
||||
// assert on field values without reaching into the typed plugin specs.
|
||||
jsonOf := func(t *testing.T, out *UpdatableDashboardV2) string {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(out)
|
||||
require.NoError(t, err)
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Successful patches
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
t.Run("no-op preserves all fields", func(t *testing.T) {
|
||||
out, err := decode(t, `[]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, base.DashboardV2MetadataBase, out.DashboardV2MetadataBase)
|
||||
assert.Equal(t, tagtypes.NewPostableTagsFromTags(base.Tags), out.Tags)
|
||||
assert.Equal(t, base.Spec.Display.Name, out.Spec.Display.Name)
|
||||
require.Equal(t, len(base.Spec.Panels), len(out.Spec.Panels))
|
||||
for k, panel := range base.Spec.Panels {
|
||||
require.Contains(t, out.Spec.Panels, k)
|
||||
assert.Equal(t, panel.Spec.Plugin.Kind, out.Spec.Panels[k].Spec.Plugin.Kind)
|
||||
}
|
||||
assert.Len(t, out.Tags, len(base.Tags))
|
||||
assert.Len(t, out.Spec.Variables, len(base.Spec.Variables))
|
||||
assert.Len(t, out.Spec.Layouts, len(base.Spec.Layouts))
|
||||
})
|
||||
|
||||
t.Run("add metadata image", func(t *testing.T) {
|
||||
out, err := decode(t, `[{"op": "add", "path": "/image", "value": "https://example.com/img.png"}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "https://example.com/img.png", out.Image)
|
||||
assert.Equal(t, SchemaVersion, out.SchemaVersion, "schemaVersion preserved")
|
||||
})
|
||||
|
||||
t.Run("replace display name", func(t *testing.T) {
|
||||
out, err := decode(t, `[{"op": "replace", "path": "/spec/display/name", "value": "Renamed"}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Renamed", out.Spec.Display.Name)
|
||||
})
|
||||
|
||||
// Per RFC 6902 § 4.1, `add` on an existing object member replaces the
|
||||
// existing value rather than erroring — same effect as `replace`.
|
||||
t.Run("add overwrites existing display name", func(t *testing.T) {
|
||||
out, err := decode(t, `[{"op": "add", "path": "/spec/display/name", "value": "Overwritten"}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Overwritten", out.Spec.Display.Name)
|
||||
})
|
||||
|
||||
t.Run("add data refreshInterval", func(t *testing.T) {
|
||||
out, err := decode(t, `[{"op": "add", "path": "/spec/refreshInterval", "value": "30s"}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "30s", string(out.Spec.RefreshInterval))
|
||||
})
|
||||
|
||||
t.Run("add panel leaves others untouched", func(t *testing.T) {
|
||||
out, err := decode(t, `[{
|
||||
"op": "add",
|
||||
"path": "/spec/panels/p3",
|
||||
"value": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
|
||||
"queries": [{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
|
||||
"name": "A",
|
||||
"signal": "logs",
|
||||
"aggregations": [{"expression": "count()"}]
|
||||
}}}
|
||||
}]
|
||||
}
|
||||
}
|
||||
}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, out.Spec.Panels, 3)
|
||||
assert.Contains(t, out.Spec.Panels, "p3")
|
||||
// Plugin specs round-trip through MarshalJSON which resolves defaults
|
||||
// (e.g. timePreference → "global_time"), so compare the serialized
|
||||
// shape rather than the in-memory structs to skip that normalization.
|
||||
for _, id := range []string{"p1", "p2"} {
|
||||
wantJSON, err := json.Marshal(base.Spec.Panels[id])
|
||||
require.NoError(t, err)
|
||||
gotJSON, err := json.Marshal(out.Spec.Panels[id])
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, string(wantJSON), string(gotJSON), "panel %s untouched", id)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("replace single panel", func(t *testing.T) {
|
||||
out, err := decode(t, `[{
|
||||
"op": "replace",
|
||||
"path": "/spec/panels/p2",
|
||||
"value": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"plugin": {"kind": "signoz/BarChartPanel", "spec": {}},
|
||||
"queries": [{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{
|
||||
"metricName": "signoz_calls_total",
|
||||
"temporality": "cumulative",
|
||||
"timeAggregation": "rate",
|
||||
"spaceAggregation": "sum"
|
||||
}]
|
||||
}}}
|
||||
}]
|
||||
}
|
||||
}
|
||||
}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, PanelPluginKind("signoz/BarChartPanel"), out.Spec.Panels["p2"].Spec.Plugin.Kind)
|
||||
assert.Equal(t, PanelPluginKind("signoz/TimeSeriesPanel"), out.Spec.Panels["p1"].Spec.Plugin.Kind, "p1 untouched")
|
||||
})
|
||||
|
||||
// Removing a panel realistically also drops its layout item — exercise
|
||||
// the multi-op shape the UI sends.
|
||||
t.Run("remove panel and its layout item", func(t *testing.T) {
|
||||
out, err := decode(t, `[
|
||||
{"op": "remove", "path": "/spec/panels/p2"},
|
||||
{"op": "remove", "path": "/spec/layouts/0/spec/items/1"}
|
||||
]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, out.Spec.Panels, 1)
|
||||
assert.Contains(t, out.Spec.Panels, "p1")
|
||||
assert.NotContains(t, out.Spec.Panels, "p2")
|
||||
raw := jsonOf(t, out)
|
||||
assert.NotContains(t, raw, `"$ref":"#/spec/panels/p2"`)
|
||||
assert.Contains(t, raw, `"$ref":"#/spec/panels/p1"`)
|
||||
})
|
||||
|
||||
// The headline use case: edit a single field of a single query inside
|
||||
// one panel without re-sending any other part of the dashboard.
|
||||
t.Run("rename single query inside panel", func(t *testing.T) {
|
||||
out, err := decode(t, `[{
|
||||
"op": "replace",
|
||||
"path": "/spec/panels/p1/spec/queries/0/spec/plugin/spec/name",
|
||||
"value": "renamed"
|
||||
}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, out.Spec.Panels["p1"].Spec.Queries, 1)
|
||||
assert.Contains(t, jsonOf(t, out), `"name":"renamed"`)
|
||||
})
|
||||
|
||||
// Replace a query at a specific index — swaps query "A" out for "B"
|
||||
// without re-sending the rest of the panel.
|
||||
t.Run("replace query at index", func(t *testing.T) {
|
||||
out, err := decode(t, `[{
|
||||
"op": "replace",
|
||||
"path": "/spec/panels/p1/spec/queries/0",
|
||||
"value": {
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
|
||||
"name": "B",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{
|
||||
"metricName": "signoz_db_calls_total",
|
||||
"temporality": "cumulative",
|
||||
"timeAggregation": "rate",
|
||||
"spaceAggregation": "sum"
|
||||
}]
|
||||
}}}
|
||||
}
|
||||
}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.Spec.Panels["p1"].Spec.Queries, 1)
|
||||
raw := jsonOf(t, out)
|
||||
assert.Contains(t, raw, `"name":"B"`)
|
||||
assert.NotContains(t, raw, `"name":"A"`)
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Layout edits
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
t.Run("move panel by editing layout x coordinate", func(t *testing.T) {
|
||||
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/items/0/x", "value": 6}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
raw := jsonOf(t, out)
|
||||
// The first item used to live at x=0, now lives at x=6.
|
||||
assert.Contains(t, raw, `"x":6,"y":0,"width":6,"height":6,"content":{"$ref":"#/spec/panels/p1"}`)
|
||||
})
|
||||
|
||||
t.Run("resize panel by editing layout width", func(t *testing.T) {
|
||||
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/items/0/width", "value": 12}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
raw := jsonOf(t, out)
|
||||
assert.Contains(t, raw, `"width":12`)
|
||||
})
|
||||
|
||||
t.Run("rename layout row title", func(t *testing.T) {
|
||||
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/display/title", "value": "Latency"}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, jsonOf(t, out), `"title":"Latency"`)
|
||||
})
|
||||
|
||||
t.Run("append layout item", func(t *testing.T) {
|
||||
out, err := decode(t, `[{
|
||||
"op": "add",
|
||||
"path": "/spec/layouts/0/spec/items/-",
|
||||
"value": {"x": 0, "y": 6, "width": 12, "height": 6, "content": {"$ref": "#/spec/panels/p1"}}
|
||||
}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
// Item count went 2 → 3.
|
||||
raw := jsonOf(t, out)
|
||||
assert.Equal(t, 3, strings.Count(raw, `"$ref":"#/spec/panels/`))
|
||||
})
|
||||
|
||||
// Composing add-panel + add-layout-item is the realistic shape of the
|
||||
// "add a new chart to my dashboard" UI flow — exercise it end-to-end.
|
||||
t.Run("add panel and corresponding layout item", func(t *testing.T) {
|
||||
out, err := decode(t, `[
|
||||
{
|
||||
"op": "add",
|
||||
"path": "/spec/panels/p3",
|
||||
"value": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
|
||||
"queries": [{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
|
||||
"name": "A",
|
||||
"signal": "logs",
|
||||
"aggregations": [{"expression": "count()"}]
|
||||
}}}
|
||||
}]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "add",
|
||||
"path": "/spec/layouts/0/spec/items/-",
|
||||
"value": {"x": 0, "y": 6, "width": 12, "height": 6, "content": {"$ref": "#/spec/panels/p3"}}
|
||||
}
|
||||
]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, out.Spec.Panels, 3)
|
||||
raw := jsonOf(t, out)
|
||||
assert.Contains(t, raw, `"$ref":"#/spec/panels/p3"`)
|
||||
})
|
||||
|
||||
t.Run("append tag", func(t *testing.T) {
|
||||
out, err := decode(t, `[{"op": "add", "path": "/tags/-", "value": {"key": "env", "value": "staging"}}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.Tags, 3)
|
||||
assert.Equal(t, "env", out.Tags[2].Key)
|
||||
assert.Equal(t, "staging", out.Tags[2].Value)
|
||||
})
|
||||
|
||||
t.Run("append tag when none exist", func(t *testing.T) {
|
||||
noTagsBase := &DashboardV2{
|
||||
DashboardV2MetadataBase: base.DashboardV2MetadataBase,
|
||||
Name: base.Name,
|
||||
Tags: nil,
|
||||
Spec: base.Spec,
|
||||
}
|
||||
out, err := decode(t, `[{"op": "add", "path": "/tags/-", "value": {"key": "team", "value": "new"}}]`).Apply(noTagsBase)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.Tags, 1)
|
||||
assert.Equal(t, "team", out.Tags[0].Key)
|
||||
assert.Equal(t, "new", out.Tags[0].Value)
|
||||
})
|
||||
|
||||
t.Run("replace tag value", func(t *testing.T) {
|
||||
out, err := decode(t, `[{"op": "replace", "path": "/tags/0/value", "value": "beta"}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.Tags, 2)
|
||||
assert.Equal(t, "team", out.Tags[0].Key)
|
||||
assert.Equal(t, "beta", out.Tags[0].Value)
|
||||
assert.Equal(t, "env", out.Tags[1].Key, "tag at index 1 untouched")
|
||||
assert.Equal(t, "prod", out.Tags[1].Value, "tag at index 1 untouched")
|
||||
for _, tag := range out.Tags {
|
||||
assert.NotEqual(t, "alpha", tag.Value, "old tag value must be gone")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple ops applied in order", func(t *testing.T) {
|
||||
out, err := decode(t, `[
|
||||
{"op": "replace", "path": "/spec/display/name", "value": "Multi-step"},
|
||||
{"op": "remove", "path": "/spec/panels/p2"},
|
||||
{"op": "add", "path": "/tags/-", "value": {"key": "env", "value": "staging"}}
|
||||
]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Multi-step", out.Spec.Display.Name)
|
||||
assert.Len(t, out.Spec.Panels, 1)
|
||||
assert.Len(t, out.Tags, 3)
|
||||
})
|
||||
|
||||
// `test` is an RFC 6902 precondition op: aborts the patch if the value
|
||||
// at the path doesn't equal the supplied value. Used for optimistic
|
||||
// concurrency. Here it matches, so the subsequent ops apply.
|
||||
t.Run("test op passes", func(t *testing.T) {
|
||||
out, err := decode(t, `[
|
||||
{"op": "test", "path": "/spec/display/name", "value": "Service overview"},
|
||||
{"op": "replace", "path": "/spec/display/name", "value": "Confirmed"}
|
||||
]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Confirmed", out.Spec.Display.Name)
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Failure cases
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
t.Run("decode rejects non-array body", func(t *testing.T) {
|
||||
var patch PatchableDashboardV2
|
||||
err := json.Unmarshal([]byte(`{"op": "replace"}`), &patch)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("decode rejects malformed JSON", func(t *testing.T) {
|
||||
var patch PatchableDashboardV2
|
||||
// Outer json.Unmarshal rejects non-JSON before PatchableDashboardV2's
|
||||
// UnmarshalJSON runs, so the error is a stdlib SyntaxError rather
|
||||
// than the InvalidInput-classified wrap.
|
||||
err := json.Unmarshal([]byte(`not json`), &patch)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
// `test` precondition fails — the whole patch is rejected, including
|
||||
// the subsequent replace.
|
||||
t.Run("test op failure rejected", func(t *testing.T) {
|
||||
_, err := decode(t, `[
|
||||
{"op": "test", "path": "/spec/display/name", "value": "Wrong"},
|
||||
{"op": "replace", "path": "/spec/display/name", "value": "Should not apply"}
|
||||
]`).Apply(base)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
// Lenient apply (AllowMissingPathOnRemove): removing a path that doesn't
|
||||
// exist is a no-op rather than an error, so removes are idempotent.
|
||||
t.Run("remove at missing path is a no-op", func(t *testing.T) {
|
||||
out, err := decode(t, `[{"op": "remove", "path": "/spec/panels/does-not-exist"}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, len(base.Spec.Panels), len(out.Spec.Panels), "existing panels untouched")
|
||||
})
|
||||
|
||||
t.Run("remove schemaVersion rejected", func(t *testing.T) {
|
||||
_, err := decode(t, `[{"op": "remove", "path": "/schemaVersion"}]`).Apply(base)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("wrong schemaVersion rejected", func(t *testing.T) {
|
||||
_, err := decode(t, `[{"op": "replace", "path": "/schemaVersion", "value": "v5"}]`).Apply(base)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), SchemaVersion)
|
||||
})
|
||||
|
||||
t.Run("empty display name defaults to dashboard name", func(t *testing.T) {
|
||||
out, err := decode(t, `[{"op": "replace", "path": "/spec/display/name", "value": ""}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, base.Name, out.Spec.Display.Name, "empty display.name should default from name")
|
||||
})
|
||||
|
||||
t.Run("unknown top-level field rejected", func(t *testing.T) {
|
||||
_, err := decode(t, `[{"op": "add", "path": "/bogus", "value": 42}]`).Apply(base)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "bogus")
|
||||
})
|
||||
|
||||
t.Run("invalid panel kind rejected", func(t *testing.T) {
|
||||
_, err := decode(t, `[{
|
||||
"op": "replace",
|
||||
"path": "/spec/panels/p1",
|
||||
"value": {
|
||||
"kind": "Panel",
|
||||
"spec": {"plugin": {"kind": "signoz/NotAPanel", "spec": {}}}
|
||||
}
|
||||
}]`).Apply(base)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "NotAPanel")
|
||||
})
|
||||
|
||||
t.Run("query kind incompatible with panel rejected", func(t *testing.T) {
|
||||
// PromQLQuery is not allowed on ListPanel — verify the cross-check
|
||||
// in Validate still runs after a patch.
|
||||
_, err := decode(t, `[{
|
||||
"op": "replace",
|
||||
"path": "/spec/panels/p2",
|
||||
"value": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"plugin": {"kind": "signoz/ListPanel", "spec": {}},
|
||||
"queries": [{"kind": "TimeSeriesQuery", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
|
||||
}
|
||||
}
|
||||
}]`).Apply(base)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("removing the only query rejected", func(t *testing.T) {
|
||||
// Validate requires exactly one query per panel — leaving zero is rejected.
|
||||
_, err := decode(t, `[{"op": "remove", "path": "/spec/panels/p2/spec/queries/0"}]`).Apply(base)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "panel must have one query")
|
||||
})
|
||||
|
||||
t.Run("two direct queries rejected", func(t *testing.T) {
|
||||
// Validate requires exactly one query per panel. To display multiple
|
||||
// data sources in one panel, wrap them in a CompositeQuery (see the
|
||||
// "replace query with composite" subtest below).
|
||||
_, err := decode(t, `[{
|
||||
"op": "replace",
|
||||
"path": "/spec/panels/p1",
|
||||
"value": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
|
||||
"queries": [
|
||||
{"kind": "TimeSeriesQuery", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
|
||||
"name": "A", "signal": "metrics",
|
||||
"aggregations": [{"metricName": "signoz_calls_total", "temporality": "cumulative", "timeAggregation": "rate", "spaceAggregation": "sum"}]
|
||||
}}}},
|
||||
{"kind": "TimeSeriesQuery", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
|
||||
"name": "B", "signal": "metrics",
|
||||
"aggregations": [{"metricName": "signoz_db_calls_total", "temporality": "cumulative", "timeAggregation": "rate", "spaceAggregation": "sum"}]
|
||||
}}}}
|
||||
]
|
||||
}
|
||||
}
|
||||
}]`).Apply(base)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "panel must have one query")
|
||||
})
|
||||
|
||||
t.Run("too many tags rejected", func(t *testing.T) {
|
||||
// Base already has 2 tags; add 9 more to exceed MaxTagsPerDashboard (10).
|
||||
_, err := decode(t, `[
|
||||
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "1"}},
|
||||
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "2"}},
|
||||
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "3"}},
|
||||
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "4"}},
|
||||
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "5"}},
|
||||
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "6"}},
|
||||
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "7"}},
|
||||
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "8"}},
|
||||
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "9"}}
|
||||
]`).Apply(base)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "at most")
|
||||
})
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
const MaxPinnedDashboardsPerUser = 10
|
||||
|
||||
var ErrCodePinnedDashboardLimitHit = errors.MustNewCode("pinned_dashboard_limit_hit")
|
||||
|
||||
type PinnedDashboard struct {
|
||||
bun.BaseModel `bun:"table:pinned_dashboard,alias:pinned_dashboard"`
|
||||
|
||||
UserID valuer.UUID `bun:"user_id,pk,type:text"`
|
||||
DashboardID valuer.UUID `bun:"dashboard_id,pk,type:text"`
|
||||
OrgID valuer.UUID `bun:"org_id,type:text,notnull"`
|
||||
PinnedAt time.Time `bun:"pinned_at,notnull,default:current_timestamp"`
|
||||
}
|
||||
@@ -32,29 +32,4 @@ type Store interface {
|
||||
DeletePublic(context.Context, string) error
|
||||
|
||||
RunInTx(context.Context, func(context.Context) error) error
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// v2 dashboard methods
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// int64 return is the total row count for the filter (pre-limit/offset),
|
||||
ListV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, params *ListDashboardsV2Params) ([]*DashboardListRow, int64, error)
|
||||
|
||||
// Returns ErrCodePinnedDashboardLimitHit when the user is at MaxPinnedDashboardsPerUser.
|
||||
PinForUser(ctx context.Context, pd *PinnedDashboard) error
|
||||
|
||||
UnpinForUser(ctx context.Context, userID valuer.UUID, dashboardID valuer.UUID) error
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Dashboard saved view methods
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
CreateDashboardView(ctx context.Context, view *DashboardView) error
|
||||
|
||||
GetDashboardView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*DashboardView, error)
|
||||
|
||||
ListDashboardViews(ctx context.Context, orgID valuer.UUID) ([]*DashboardView, error)
|
||||
|
||||
UpdateDashboardView(ctx context.Context, view *DashboardView) error
|
||||
|
||||
DeleteDashboardView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error
|
||||
}
|
||||
|
||||
@@ -1,445 +0,0 @@
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.types import Operation, SigNoz
|
||||
|
||||
# The v2 dashboard API. Request shape (current):
|
||||
# {"schemaVersion": "v6", "name": "<dns-1123-label>",
|
||||
# "spec": {"display": {"name": "<human name>"}},
|
||||
# "tags": [{"key": "...", "value": "..."}]}
|
||||
# `name` is a DNS-1123 label identifier and is immutable after create;
|
||||
# `spec.display.name` is the human-facing title used for name-sort/name-filter.
|
||||
|
||||
_BASE = "/api/v2/dashboards"
|
||||
_TIMEOUT = 5
|
||||
|
||||
|
||||
def _headers(token: str) -> dict:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _url(signoz: SigNoz, path: str = "") -> str:
|
||||
return signoz.self.host_configs["8080"].get(f"{_BASE}{path}")
|
||||
|
||||
|
||||
def _create(signoz: SigNoz, token: str, body: dict) -> requests.Response:
|
||||
return requests.post(
|
||||
_url(signoz), json=body, headers=_headers(token), timeout=_TIMEOUT
|
||||
)
|
||||
|
||||
|
||||
def _get(signoz: SigNoz, token: str, dashboard_id: str) -> requests.Response:
|
||||
return requests.get(
|
||||
_url(signoz, f"/{dashboard_id}"), headers=_headers(token), timeout=_TIMEOUT
|
||||
)
|
||||
|
||||
|
||||
def _list(signoz: SigNoz, token: str, **params: object) -> requests.Response:
|
||||
return requests.get(
|
||||
_url(signoz),
|
||||
params={k: v for k, v in params.items() if v is not None},
|
||||
headers=_headers(token),
|
||||
timeout=_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
def _update(
|
||||
signoz: SigNoz, token: str, dashboard_id: str, body: dict
|
||||
) -> requests.Response:
|
||||
return requests.put(
|
||||
_url(signoz, f"/{dashboard_id}"),
|
||||
json=body,
|
||||
headers=_headers(token),
|
||||
timeout=_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
def _delete(signoz: SigNoz, token: str, dashboard_id: str) -> requests.Response:
|
||||
return requests.delete(
|
||||
_url(signoz, f"/{dashboard_id}"), headers=_headers(token), timeout=_TIMEOUT
|
||||
)
|
||||
|
||||
|
||||
def _lock(
|
||||
signoz: SigNoz, token: str, dashboard_id: str, lock: bool
|
||||
) -> requests.Response:
|
||||
method = requests.put if lock else requests.delete
|
||||
return method(
|
||||
_url(signoz, f"/{dashboard_id}/lock"),
|
||||
headers=_headers(token),
|
||||
timeout=_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
def _pin(signoz: SigNoz, token: str, dashboard_id: str, pin: bool) -> requests.Response:
|
||||
method = requests.put if pin else requests.delete
|
||||
return method(
|
||||
_url(signoz, f"/{dashboard_id}/pins/me"),
|
||||
headers=_headers(token),
|
||||
timeout=_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
def _minimal_body(name: str, display: str, tags: list[dict] | None = None) -> dict:
|
||||
return {
|
||||
"schemaVersion": "v6",
|
||||
"name": name,
|
||||
"spec": {"display": {"name": display}},
|
||||
"tags": tags or [],
|
||||
}
|
||||
|
||||
|
||||
# ─── failure cases (create no dashboards) ────────────────────────────────────
|
||||
|
||||
|
||||
def test_create_rejects_wrong_schema_version(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = _create(signoz, token, {})
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
body = response.json()
|
||||
assert body["status"] == "error"
|
||||
assert body["error"]["code"] == "dashboard_invalid_input"
|
||||
assert body["error"]["message"] == 'schemaVersion must be "v6", got ""'
|
||||
|
||||
|
||||
def test_create_rejects_missing_name(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = _create(signoz, token, {"schemaVersion": "v6"})
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
body = response.json()
|
||||
assert body["error"]["code"] == "dashboard_invalid_input"
|
||||
assert body["error"]["message"] == "name is required"
|
||||
|
||||
|
||||
def test_create_rejects_non_dns_name(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = _create(
|
||||
signoz, token, _minimal_body(name="Not A Label", display="Not A Label")
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response.json()["error"]["code"] == "dashboard_invalid_input"
|
||||
|
||||
|
||||
def test_create_rejects_unknown_field(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
body = _minimal_body("rejects-unknown", "Rejects Unknown")
|
||||
body["unknownfield"] = "boom"
|
||||
response = _create(signoz, token, body)
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response.json()["error"]["code"] == "dashboard_invalid_input"
|
||||
assert "unknown field" in response.json()["error"]["message"]
|
||||
|
||||
|
||||
def test_create_rejects_reserved_tag_key(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
body = _minimal_body(
|
||||
"rejects-reserved", "Rejects Reserved", [{"key": "source", "value": "x"}]
|
||||
)
|
||||
response = _create(signoz, token, body)
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response.json()["error"]["code"] == "dashboard_invalid_input"
|
||||
|
||||
|
||||
def test_create_rejects_too_many_tags(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
tags = [{"key": f"k{i}", "value": "v"} for i in range(11)]
|
||||
response = _create(signoz, token, _minimal_body("too-many-tags", "Too Many", tags))
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response.json()["error"]["code"] == "dashboard_invalid_input"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"params",
|
||||
[
|
||||
{"sort": "bogus"},
|
||||
{"order": "bogus"},
|
||||
{"limit": -1},
|
||||
{"offset": -1},
|
||||
],
|
||||
)
|
||||
def test_list_rejects_invalid_params(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
params: dict,
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = _list(signoz, token, **params)
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response.json()["error"]["code"] == "dashboard_list_invalid"
|
||||
|
||||
|
||||
def test_get_rejects_malformed_id(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = _get(signoz, token, "not-a-uuid")
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
|
||||
def test_get_missing_dashboard_returns_not_found(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = _get(signoz, token, str(uuid.uuid4()))
|
||||
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
|
||||
|
||||
def test_delete_missing_dashboard_returns_not_found(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = _delete(signoz, token, str(uuid.uuid4()))
|
||||
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
|
||||
|
||||
def test_pin_missing_dashboard_returns_not_found(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = _pin(signoz, token, str(uuid.uuid4()), pin=True)
|
||||
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
|
||||
|
||||
# ─── lifecycle ───────────────────────────────────────────────────────────────
|
||||
# A single end-to-end flow through create → get → list/filter/sort → pin →
|
||||
# update → lock → delete. Every fixture dashboard carries a unique suite marker
|
||||
# tag so list queries can be scoped server-side, isolating this test from any
|
||||
# other dashboards sharing the session DB.
|
||||
|
||||
_SUITE_TAG = {"key": "suite", "value": "lifecyclev2"}
|
||||
_SUITE_FILTER = "suite = 'lifecyclev2'"
|
||||
|
||||
|
||||
def _scoped(query: str) -> str:
|
||||
return f"({query}) AND {_SUITE_FILTER}"
|
||||
|
||||
|
||||
def _display_names(body: dict) -> list[str]:
|
||||
return [d["spec"]["display"]["name"] for d in body["data"]["dashboards"]]
|
||||
|
||||
|
||||
def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-statements
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
fixtures = [
|
||||
(
|
||||
"lc-alpha",
|
||||
"Alpha Overview",
|
||||
[{"key": "team", "value": "pulse"}, {"key": "env", "value": "prod"}],
|
||||
),
|
||||
(
|
||||
"lc-beta",
|
||||
"Beta Overview",
|
||||
[{"key": "team", "value": "pulse"}, {"key": "env", "value": "dev"}],
|
||||
),
|
||||
(
|
||||
"lc-gamma",
|
||||
"Gamma Storage",
|
||||
[{"key": "team", "value": "storage"}, {"key": "env", "value": "prod"}],
|
||||
),
|
||||
]
|
||||
|
||||
# ── stage 1: create ──────────────────────────────────────────────────────
|
||||
ids: dict[str, str] = {}
|
||||
for name, display, tags in fixtures:
|
||||
response = _create(
|
||||
signoz, token, _minimal_body(name, display, [_SUITE_TAG, *tags])
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
ids[name] = response.json()["data"]["id"]
|
||||
|
||||
# TODO: re-enable once the dashboard name unique index lands — creating a
|
||||
# second dashboard with an existing name should conflict (409). Until the
|
||||
# index exists, duplicate names are silently allowed.
|
||||
# response = _create(signoz, token, _minimal_body("lc-alpha", "Alpha Dupe"))
|
||||
# assert response.status_code == HTTPStatus.CONFLICT, response.text
|
||||
|
||||
# ── stage 2: get one and verify the round-tripped shape ──────────────────
|
||||
response = _get(signoz, token, ids["lc-alpha"])
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
alpha = response.json()["data"]
|
||||
assert alpha["id"] == ids["lc-alpha"]
|
||||
assert alpha["name"] == "lc-alpha"
|
||||
assert alpha["spec"]["display"]["name"] == "Alpha Overview"
|
||||
assert alpha["schemaVersion"] == "v6"
|
||||
assert alpha["source"] == "user"
|
||||
assert alpha["locked"] is False
|
||||
assert {"key": "team", "value": "pulse"} in alpha["tags"]
|
||||
|
||||
# ── stage 3: list everything in the suite ────────────────────────────────
|
||||
response = _list(signoz, token, query=_SUITE_FILTER, limit=200)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
body = response.json()
|
||||
assert body["data"]["total"] == 3
|
||||
assert set(_display_names(body)) == {
|
||||
"Alpha Overview",
|
||||
"Beta Overview",
|
||||
"Gamma Storage",
|
||||
}
|
||||
|
||||
# ── stage 4: filter DSL (tag-key filters + display-name contains) ────────
|
||||
cases = [
|
||||
("team = 'pulse'", {"Alpha Overview", "Beta Overview"}),
|
||||
("env = 'prod'", {"Alpha Overview", "Gamma Storage"}),
|
||||
("name CONTAINS 'Overview'", {"Alpha Overview", "Beta Overview"}),
|
||||
("env IN ['dev', 'test']", {"Beta Overview"}),
|
||||
]
|
||||
for query, expected in cases:
|
||||
response = _list(signoz, token, query=_scoped(query), limit=200)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert set(_display_names(response.json())) == expected, query
|
||||
|
||||
# ── stage 5: name sort honours order ─────────────────────────────────────
|
||||
response = _list(
|
||||
signoz, token, query=_SUITE_FILTER, sort="name", order="asc", limit=200
|
||||
)
|
||||
assert _display_names(response.json()) == [
|
||||
"Alpha Overview",
|
||||
"Beta Overview",
|
||||
"Gamma Storage",
|
||||
]
|
||||
response = _list(
|
||||
signoz, token, query=_SUITE_FILTER, sort="name", order="desc", limit=200
|
||||
)
|
||||
assert _display_names(response.json()) == [
|
||||
"Gamma Storage",
|
||||
"Beta Overview",
|
||||
"Alpha Overview",
|
||||
]
|
||||
|
||||
# ── stage 6: pinning floats a dashboard to the top of any ordering ───────
|
||||
assert (
|
||||
_pin(signoz, token, ids["lc-gamma"], pin=True).status_code
|
||||
== HTTPStatus.NO_CONTENT
|
||||
)
|
||||
response = _list(
|
||||
signoz, token, query=_SUITE_FILTER, sort="name", order="asc", limit=200
|
||||
)
|
||||
dashboards = response.json()["data"]["dashboards"]
|
||||
assert dashboards[0]["name"] == "lc-gamma"
|
||||
assert dashboards[0]["pinned"] is True
|
||||
assert all(d["pinned"] is False for d in dashboards[1:])
|
||||
|
||||
# ── stage 7: unpinning restores the natural ordering ─────────────────────
|
||||
assert (
|
||||
_pin(signoz, token, ids["lc-gamma"], pin=False).status_code
|
||||
== HTTPStatus.NO_CONTENT
|
||||
)
|
||||
response = _list(
|
||||
signoz, token, query=_SUITE_FILTER, sort="name", order="asc", limit=200
|
||||
)
|
||||
assert _display_names(response.json()) == [
|
||||
"Alpha Overview",
|
||||
"Beta Overview",
|
||||
"Gamma Storage",
|
||||
]
|
||||
|
||||
# ── stage 8: update mutates the spec but keeps the immutable name ────────
|
||||
update_body = _minimal_body(
|
||||
"lc-alpha",
|
||||
"Alpha Overview",
|
||||
[
|
||||
_SUITE_TAG,
|
||||
{"key": "team", "value": "pulse"},
|
||||
{"key": "env", "value": "prod"},
|
||||
],
|
||||
)
|
||||
update_body["spec"]["display"]["description"] = "now with a description"
|
||||
response = _update(signoz, token, ids["lc-alpha"], update_body)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
response = _get(signoz, token, ids["lc-alpha"])
|
||||
assert (
|
||||
response.json()["data"]["spec"]["display"]["description"]
|
||||
== "now with a description"
|
||||
)
|
||||
|
||||
# ── stage 9: a locked dashboard rejects updates until unlocked ───────────
|
||||
assert (
|
||||
_lock(signoz, token, ids["lc-beta"], lock=True).status_code
|
||||
== HTTPStatus.NO_CONTENT
|
||||
)
|
||||
beta_body = _minimal_body(
|
||||
"lc-beta",
|
||||
"Beta Overview",
|
||||
[_SUITE_TAG, {"key": "team", "value": "pulse"}, {"key": "env", "value": "dev"}],
|
||||
)
|
||||
response = _update(signoz, token, ids["lc-beta"], beta_body)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert (
|
||||
_lock(signoz, token, ids["lc-beta"], lock=False).status_code
|
||||
== HTTPStatus.NO_CONTENT
|
||||
)
|
||||
assert (
|
||||
_update(signoz, token, ids["lc-beta"], beta_body).status_code == HTTPStatus.OK
|
||||
)
|
||||
|
||||
# ── stage 10: delete removes the dashboard from get and list ─────────────
|
||||
assert _delete(signoz, token, ids["lc-gamma"]).status_code == HTTPStatus.NO_CONTENT
|
||||
assert _get(signoz, token, ids["lc-gamma"]).status_code == HTTPStatus.NOT_FOUND
|
||||
response = _list(signoz, token, query=_SUITE_FILTER, limit=200)
|
||||
assert response.json()["data"]["total"] == 2
|
||||
assert set(_display_names(response.json())) == {"Alpha Overview", "Beta Overview"}
|
||||
Reference in New Issue
Block a user