Compare commits

..

11 Commits

Author SHA1 Message Date
Naman Verma
3de83c8028 fix: include path in main error message directly 2026-07-02 21:29:41 +05:30
Naman Verma
fb3e6e6a78 fix: add path to error message 2026-07-02 20:50:55 +05:30
Naman Verma
45e40fc7c3 Merge branch 'main' into nv/dashboards-bug-bash-1 2026-07-02 19:48:30 +05:30
Naman Verma
942c2795f4 fix: send user friendly err message on length check fail 2026-07-02 19:42:15 +05:30
Naman Verma
f133cd31b3 fix: increase limit to 64 for dashboard view name 2026-07-02 19:15:32 +05:30
Naman Verma
8857a149d4 chore: add copy suffix on cloning dashboards 2026-07-02 19:06:38 +05:30
Naman Verma
806b6dcedc chore: increase the length limits 2026-07-02 18:36:58 +05:30
Naman Verma
9093147693 test: check error message as well 2026-07-02 18:36:11 +05:30
Naman Verma
1ae3675a25 fix: add length limit to dashboard display name 2026-07-02 18:26:38 +05:30
Naman Verma
777ad3198a chore: return reserved keys in list api response for easy filtering 2026-07-02 18:06:23 +05:30
Naman Verma
e4a07c9a7e fix: return list of all tags sorted alphabetically 2026-07-02 18:04:42 +05:30
15 changed files with 450 additions and 137 deletions

View File

@@ -3071,6 +3071,10 @@ components:
items:
$ref: '#/components/schemas/DashboardtypesListedDashboardForUserV2'
type: array
reservedKeywords:
items:
type: string
type: array
tags:
items:
$ref: '#/components/schemas/TagtypesGettableTag'
@@ -3082,6 +3086,7 @@ components:
- dashboards
- total
- tags
- reservedKeywords
type: object
DashboardtypesListableDashboardV2:
properties:
@@ -3089,6 +3094,10 @@ components:
items:
$ref: '#/components/schemas/DashboardtypesListedDashboardV2'
type: array
reservedKeywords:
items:
type: string
type: array
tags:
items:
$ref: '#/components/schemas/TagtypesGettableTag'
@@ -3100,6 +3109,7 @@ components:
- dashboards
- total
- tags
- reservedKeywords
type: object
DashboardtypesListableDashboardView:
properties:

View File

@@ -1,116 +1,105 @@
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="../docs/readme-assets/signoz-hero-dark.png" width="700">
<source media="(prefers-color-scheme: light)" srcset="../docs/readme-assets/signoz-hero-light.png" width="700">
<img alt="SigNoz - Observability on Your Terms" src="../docs/readme-assets/signoz-hero-light.png" width="700">
</picture>
</p>
# Configuring Over Local
1. Docker
1. Without Docker
<p align="center">
<a href="https://github.com/SigNoz/signoz/issues"><img alt="GitHub issues" src="https://img.shields.io/github/issues/SigNoz/signoz"></a>
<a href="https://signoz.io/slack"><img alt="Slack community" src="https://img.shields.io/badge/slack-community-4A154B?logo=slack&logoColor=white"></a>
</p>
## With Docker
# SigNoz Frontend
**Building image**
React-based web interface for [SigNoz](https://signoz.io), the open-source observability platform.
``docker compose up`
/ This will also run
## Tech Stack
or
`docker build . -t tagname`
- **Framework:** React 18 + TypeScript
- **Build:** Vite
- **State:** React Query, Zustand, Redux Toolkit (legacy)
- **Styling:** CSS Modules, Ant Design (legacy)
- **Charts:** uPlot
- **Testing:** Jest
## Local Development Setup
1. Run SigNoz backend locally — see [Self-Host Docs](https://signoz.io/docs/install/self-host/)
2. Configure environment:
```bash
cp example.env .env
```
Key variables in `.env`:
```bash
# Backend API endpoint (required)
VITE_FRONTEND_API_ENDPOINT="http://localhost:8080"
# Enable bundle analyzer (optional)
BUNDLE_ANALYSER="true"
```
3. Install and run:
```bash
pnpm install
pnpm dev
```
## Development
```bash
pnpm dev
```
Opens [http://localhost:3301](http://localhost:3301).
## Build
```bash
pnpm build
```
Output in `build/` folder.
## Bundle Size Analysis
Set in `.env`:
```bash
BUNDLE_ANALYSER="true"
```
Then run build:
```bash
pnpm build
```
Opens bundle analyzer visualization automatically.
## Testing
```bash
# Unit tests
pnpm test
# Type checking
pnpm tsgo --noEmit
```
## Linting
```bash
# Run all linters (oxlint + stylelint)
pnpm lint
```
## Project Structure
**Tag to remote url- Introduce versioning later on**
```
src/
├── api/ # API clients and react-query hooks
├── components/ # Shared UI components
├── container/ # Page-level containers
├── hooks/ # Custom React hooks
├── pages/ # Route pages
├── providers/ # React context providers
├── store/ # Redux store
└── types/ # TypeScript definitions
docker tag signoz/frontend:latest 7296823551/signoz:latest
```
## Contributing
```
docker compose up
```
See [CONTRIBUTING.md](../CONTRIBUTING.md) in the root repo.
## Without Docker
Follow the steps below
Questions? Join our [Slack community](https://signoz.io/slack).
1. ```git clone https://github.com/SigNoz/signoz.git && cd signoz/frontend```
1. change baseURL to ```<test environment URL>``` in file ```src/constants/env.ts```
1. ```pnpm install```
1. ```pnpm dev```
```Note: Please ping us in #contributing channel in our slack community and we will DM you with <test environment URL>```
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `pnpm start`
Runs the app in the development mode.\
Open [http://localhost:3301](http://localhost:3301) to view it in the browser.
The page will reload if you make edits.\
You will also see any lint errors in the console.
### `pnpm test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `pnpm build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `pnpm eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
### Analyzing the Bundle Size
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
### Making a Progressive Web App
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
### Advanced Configuration
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
### Deployment
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
### `pnpm build` fails to minify
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)

View File

@@ -5031,6 +5031,10 @@ export interface DashboardtypesListableDashboardForUserV2DTO {
* @type array
*/
dashboards: DashboardtypesListedDashboardForUserV2DTO[];
/**
* @type array
*/
reservedKeywords: string[];
/**
* @type array
*/
@@ -5098,6 +5102,10 @@ export interface DashboardtypesListableDashboardV2DTO {
* @type array
*/
dashboards: DashboardtypesListedDashboardV2DTO[];
/**
* @type array
*/
reservedKeywords: string[];
/**
* @type array
*/

View File

@@ -35,6 +35,7 @@ function Explorer(): JSX.Element {
handleRunQuery,
stagedQuery,
updateAllQueriesOperators,
handleSetQueryData,
currentQuery,
} = useQueryBuilder();
const { safeNavigate } = useSafeNavigate();
@@ -66,6 +67,15 @@ function Explorer(): JSX.Element {
[updateAllQueriesOperators],
);
useEffect(() => {
handleSetQueryData(0, {
...initialQueryMeterWithType.builder.queryData[0],
source: 'meter',
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const exportDefaultQuery = useMemo(
() =>
updateAllQueriesOperators(

View File

@@ -26,6 +26,7 @@ func (s *store) List(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind
Model(&tags).
Where("org_id = ?", orgID).
Where("kind = ?", kind).
OrderExpr("lower(key) ASC, lower(value) ASC").
Scan(ctx)
if err != nil {
return nil, err

View File

@@ -15,7 +15,7 @@ import (
const (
DashboardViewSchemaVersion = "v1"
MaxDashboardViewNameLen = 32
MaxDashboardViewNameLen = 64
)
var (

View File

@@ -1,12 +1,29 @@
package dashboardtypes
import (
"slices"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
qbtypesv5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
)
var ErrCodeDashboardListFilterInvalid = errors.MustNewCode("dashboard_list_filter_invalid")
// ReservedFilterKeys returns the reserved (column-level) DSL keys the list
// filter accepts, sorted alphabetically. The list API surfaces these so clients
// can distinguish reserved keywords from tag keys when building filters.
func ReservedFilterKeys() []DSLKey {
keys := make([]DSLKey, 0, len(ReservedOps))
for key := range ReservedOps {
keys = append(keys, key)
}
slices.SortFunc(keys, func(a, b DSLKey) int {
return strings.Compare(string(a), string(b))
})
return keys
}
// 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[DSLKey]map[qbtypesv5.FilterOperator]struct{}{

View File

@@ -145,9 +145,10 @@ func newListedDashboardV2(v2 *DashboardV2) *listedDashboardV2 {
}
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"`
Dashboards []*listedDashboardV2 `json:"dashboards" required:"true" nullable:"false"`
Total int64 `json:"total" required:"true"`
Tags []*tagtypes.GettableTag `json:"tags" required:"true" nullable:"false"`
ReservedKeywords []DSLKey `json:"reservedKeywords" required:"true" nullable:"false"`
}
func NewListableDashboardV2(dashboards []*StorableDashboard, total int64, tagsByEntity map[valuer.UUID][]*tagtypes.Tag, allTags []*tagtypes.Tag) (*ListableDashboardV2, error) {
@@ -160,9 +161,10 @@ func NewListableDashboardV2(dashboards []*StorableDashboard, total int64, tagsBy
items[i] = newListedDashboardV2(v2)
}
return &ListableDashboardV2{
Dashboards: items,
Total: total,
Tags: tagtypes.NewGettableTagsFromTags(allTags),
Dashboards: items,
Total: total,
Tags: tagtypes.NewGettableTagsFromTags(allTags),
ReservedKeywords: ReservedFilterKeys(),
}, nil
}
@@ -174,9 +176,10 @@ type listedDashboardForUserV2 struct {
}
type ListableDashboardForUserV2 struct {
Dashboards []*listedDashboardForUserV2 `json:"dashboards" required:"true" nullable:"false"`
Total int64 `json:"total" required:"true"`
Tags []*tagtypes.GettableTag `json:"tags" required:"true" nullable:"false"`
Dashboards []*listedDashboardForUserV2 `json:"dashboards" required:"true" nullable:"false"`
Total int64 `json:"total" required:"true"`
Tags []*tagtypes.GettableTag `json:"tags" required:"true" nullable:"false"`
ReservedKeywords []DSLKey `json:"reservedKeywords" required:"true" nullable:"false"`
}
// StorableDashboardWithPinInfo is the per-row shape Store.ListForUser returns: the dashboard
@@ -200,8 +203,9 @@ func NewListableDashboardForUserV2(rows []*StorableDashboardWithPinInfo, total i
}
}
return &ListableDashboardForUserV2{
Dashboards: items,
Total: total,
Tags: tagtypes.NewGettableTagsFromTags(allTags),
Dashboards: items,
Total: total,
Tags: tagtypes.NewGettableTagsFromTags(allTags),
ReservedKeywords: ReservedFilterKeys(),
}, nil
}

View File

@@ -4,8 +4,12 @@ import (
"bytes"
"crypto/rand"
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
@@ -134,14 +138,42 @@ func (d *DashboardV2) ErrIfNotClonable() error {
}
func (d DashboardV2) ToPostableForCloning() PostableDashboardV2 {
spec := d.Spec
spec.Display.Name = nextCloneDisplayName(spec.Display.Name)
return PostableDashboardV2{
DashboardV2MetadataBase: d.DashboardV2MetadataBase,
GenerateName: true,
Tags: tagtypes.NewPostableTagsFromTags(d.Tags),
Spec: d.Spec,
Spec: spec,
}
}
// nextCloneDisplayName appends " - Copy" to a clone's display name, bumping an
// existing " - Copy (n)" counter, then truncates the base to fit MaxDisplayNameLen.
func nextCloneDisplayName(name string) string {
cloneCopySuffix := regexp.MustCompile(`^(.*) - Copy(?: \((\d+)\))?$`)
base, count := name, 0
if m := cloneCopySuffix.FindStringSubmatch(name); m != nil {
base = m[1]
count = 1 // bare " - Copy"
if m[2] != "" {
count, _ = strconv.Atoi(m[2])
}
}
suffix := " - Copy"
if count++; count > 1 {
suffix = fmt.Sprintf(" - Copy (%d)", count)
}
limit := max(MaxDisplayNameLen-utf8.RuneCountInString(suffix), 0)
if runes := []rune(base); len(runes) > limit {
base = strings.TrimRight(string(runes[:limit]), " ")
}
return base + suffix
}
type DashboardV2MetadataBase struct {
SchemaVersion string `json:"schemaVersion" required:"true"`
Image string `json:"image,omitempty"`

View File

@@ -5,6 +5,7 @@ import (
"strings"
"testing"
"time"
"unicode/utf8"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/coretypes"
@@ -21,6 +22,7 @@ func newTestDashboardV2(t *testing.T, orgID valuer.UUID, source Source) *Dashboa
updatedAt := time.Date(2026, time.January, 2, 12, 0, 0, 0, time.UTC)
spec := DashboardSpec{
Display: Display{Name: "Test Dashboard"},
Panels: map[string]*Panel{
"p1": {
Kind: "Panel",
@@ -211,7 +213,13 @@ func TestDashboardV2ToPostableForCloning(t *testing.T) {
assert.True(t, postable.GenerateName, "internal name must be regenerated, not copied")
assert.Empty(t, postable.Name, "name must be empty so generateName can derive it")
assert.Equal(t, dashboard.DashboardV2MetadataBase, postable.DashboardV2MetadataBase, "schema version and image are carried over")
assert.Equal(t, dashboard.Spec, postable.Spec, "spec (incl. display name) is preserved verbatim")
assert.Equal(t, "Test Dashboard - Copy", postable.Spec.Display.Name, "clone appends a Copy suffix to the display name")
// The rest of the spec is carried over unchanged.
expectedSpec := dashboard.Spec
expectedSpec.Display.Name = "Test Dashboard - Copy"
assert.Equal(t, expectedSpec, postable.Spec)
assert.Equal(t, "Test Dashboard", dashboard.Spec.Display.Name, "the source dashboard's display name is not mutated")
require.Len(t, postable.Tags, len(dashboard.Tags))
for i, sourceTag := range dashboard.Tags {
@@ -220,6 +228,83 @@ func TestDashboardV2ToPostableForCloning(t *testing.T) {
}
}
// nextCloneDisplayName appends " - Copy", bumps an existing " - Copy (n)"
// counter, and truncates an over-long base back to MaxDisplayNameLen while
// keeping the suffix whole. The long cases are real-ish titles already at the
// limit so the truncated output is legible; their literal expectations assume
// the 128-character limit.
func TestNextCloneDisplayName(t *testing.T) {
require.Equal(t, 128, MaxDisplayNameLen, "the literal expectations below are sized for a 128-character limit")
testCases := []struct {
scenario string
input string
expected string
}{
{
scenario: "plain name gets a Copy suffix",
input: "My Dashboard",
expected: "My Dashboard - Copy",
},
{
scenario: "Copy suffix bumps to (2)",
input: "My Dashboard - Copy",
expected: "My Dashboard - Copy (2)",
},
{
scenario: "numbered suffix increments",
input: "My Dashboard - Copy (2)",
expected: "My Dashboard - Copy (3)",
},
{
scenario: "multi-digit suffix increments",
input: "svc - Copy (41)",
expected: "svc - Copy (42)",
},
{
scenario: "a name that merely contains Copy is not a suffix",
input: "Copy of things",
expected: "Copy of things - Copy",
},
{
scenario: "only the trailing Copy marker is stripped",
input: "Prod - Copy - Copy",
expected: "Prod - Copy - Copy (2)",
},
{
scenario: "empty name",
input: "",
expected: " - Copy",
},
{
scenario: "first copy at the limit truncates the base, keeps the suffix",
input: "Production Kubernetes Cluster Health: CPU, Memory, Disk I/O, and Network Saturation Across Every Namespace and Availability Zone",
expected: "Production Kubernetes Cluster Health: CPU, Memory, Disk I/O, and Network Saturation Across Every Namespace and Availabili - Copy",
},
{
scenario: "numbered copy at the limit increments then truncates",
input: "API Gateway SLOs: p99 Latency, Error Budget Burn Rate, and Requests per Second by Route, Region, and Upstream Service - Copy (9)",
expected: "API Gateway SLOs: p99 Latency, Error Budget Burn Rate, and Requests per Second by Route, Region, and Upstream Servic - Copy (10)",
},
{
scenario: "truncation counts runes, not bytes (é and — are one rune each)",
input: "Café Latency — p99 Response Times, Error Rates, and Saturation Across the Ordering, Kitchen, and Delivery Microservices Fleet",
expected: "Café Latency — p99 Response Times, Error Rates, and Saturation Across the Ordering, Kitchen, and Delivery Microservices F - Copy",
},
}
for _, testCase := range testCases {
t.Run(testCase.scenario, func(t *testing.T) {
require.LessOrEqual(t, utf8.RuneCountInString(testCase.input), MaxDisplayNameLen, "a saved source name never exceeds the limit")
result := nextCloneDisplayName(testCase.input)
assert.Equal(t, testCase.expected, result)
assert.LessOrEqual(t, utf8.RuneCountInString(result), MaxDisplayNameLen, "a clone name must fit the limit")
})
}
}
func TestDashboardV2StorableRoundTrip(t *testing.T) {
orgID := valuer.GenerateUUID()
original := newTestDashboardV2(t, orgID, SourceIntegration)

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"slices"
"unicode/utf8"
"github.com/SigNoz/signoz/pkg/errors"
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
@@ -48,6 +49,9 @@ func (d *DashboardSpec) UnmarshalJSON(data []byte) error {
// ══════════════════════════════════════════════
func (d *DashboardSpec) Validate() error {
if err := d.Display.Validate("dashboard", "spec.display.name"); err != nil {
return err
}
if err := d.validateVariables(); err != nil {
return err
}
@@ -62,15 +66,23 @@ func (d *DashboardSpec) validateVariables() error {
seen := make(map[string]struct{}, len(d.Variables))
for i, v := range d.Variables {
var name string
var err error
// Validated here, not by decodeSpec on decode, so variable errors surface from
// Validate() with clean messages (not buried under the decoder's "invalid
// dashboard spec" wrap) and also run for programmatically built specs (cloning).
path := fmt.Sprintf("spec.variables[%d]", i)
switch s := v.Spec.(type) {
case *ListVariableSpec:
name = s.Name
name, err = s.Name, s.validate(path)
case *TextVariableSpec:
name = s.Name
name, err = s.Name, s.validate(path)
default:
// Unreachable via UnmarshalJSON; reaching here means a Go caller broke the Kind/Spec pairing.
return errors.NewInternalf(errors.CodeInternal, "spec.variables[%d].spec: unexpected variable spec type %T", i, v.Spec)
}
if err != nil {
return err
}
if _, dup := seen[name]; dup {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.variables[%d]: duplicate variable name %q", i, name)
}
@@ -88,6 +100,9 @@ func (d *DashboardSpec) validatePanels() error {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.panels.%s: panel must not be null", key)
}
path := fmt.Sprintf("spec.panels.%s", key)
if err := panel.Spec.Display.Validate("panel", path+".spec.display.name"); err != nil {
return err
}
panelKind := panel.Spec.Plugin.Kind
if len(panel.Spec.Queries) != 1 {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.spec.queries: panel must have one query", path)
@@ -162,6 +177,11 @@ func (d *DashboardSpec) validateLayouts() error {
// Unreachable via UnmarshalJSON; reaching here means a Go caller broke the Kind/Spec pairing.
return errors.NewInternalf(errors.CodeInternal, "spec.layouts[%d].spec: unexpected layout spec type %T", li, layout.Spec)
}
if grid.Display != nil {
if n := utf8.RuneCountInString(grid.Display.Title); n > MaxDisplayNameLen {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.display.title: layout name must be at most %d characters, got %d", li, MaxDisplayNameLen, n)
}
}
if err := validateGridLayoutGeometry(grid, li); err != nil {
return err
}

View File

@@ -2,6 +2,7 @@ package dashboardtypes
import (
"encoding/json"
"fmt"
"os"
"strings"
"testing"
@@ -1563,3 +1564,67 @@ func TestInvalidateDuplicatePanelReference(t *testing.T) {
assert.Contains(t, err.Error(), "spec.layouts[0].spec.items[0].content")
assert.Contains(t, err.Error(), "spec.layouts[0].spec.items[1].content")
}
// Every display name — dashboard, panel, variable — and the grid layout title is
// bounded at MaxDisplayNameLen. The name is one over the limit in each case, and
// the message reads "<json path>: <field> name must be at most ...", pairing the
// locatable path (like the other spec errors) with a human field label.
func TestInvalidateDisplayNameTooLong(t *testing.T) {
tooLong := strings.Repeat("x", MaxDisplayNameLen+1)
lengthMsg := fmt.Sprintf("must be at most %d characters, got %d", MaxDisplayNameLen, MaxDisplayNameLen+1)
testCases := []struct {
scenario string
dashboardJSON string
expectedPath string
expectedLabel string
}{
{
scenario: "dashboard display name",
dashboardJSON: `{"display": {"name": "` + tooLong + `"}, "layouts": []}`,
expectedLabel: "dashboard",
expectedPath: "spec.display.name",
},
{
scenario: "panel display name",
dashboardJSON: `{"panels": {"p1": {"kind": "Panel", "spec": {"display": {"name": "` + tooLong + `"}, "plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": []}}}, "layouts": []}`,
expectedLabel: "panel",
expectedPath: "spec.panels.p1.spec.display.name",
},
{
scenario: "list variable display name",
dashboardJSON: `{"variables": [{"kind": "ListVariable", "spec": {"name": "svc", "display": {"name": "` + tooLong + `"}, "plugin": {"kind": "signoz/DynamicVariable", "spec": {"name": "service.name", "signal": "metrics"}}}}], "layouts": []}`,
expectedLabel: "variable",
expectedPath: "spec.variables[0].spec.display.name",
},
{
scenario: "text variable display name",
dashboardJSON: `{"variables": [{"kind": "TextVariable", "spec": {"name": "mytext", "value": "v", "display": {"name": "` + tooLong + `"}}}], "layouts": []}`,
expectedLabel: "variable",
expectedPath: "spec.variables[0].spec.display.name",
},
{
scenario: "layout title",
dashboardJSON: `{"layouts": [{"kind": "Grid", "spec": {"display": {"title": "` + tooLong + `"}, "items": []}}]}`,
expectedLabel: "layout",
expectedPath: "spec.layouts[0].spec.display.title",
},
}
for _, testCase := range testCases {
t.Run(testCase.scenario, func(t *testing.T) {
_, err := unmarshalDashboard([]byte(testCase.dashboardJSON))
require.Error(t, err)
// Message is "<path>: <label> name must be at most N characters, got M".
want := testCase.expectedPath + ": " + testCase.expectedLabel + " name " + lengthMsg
assert.Equal(t, want, errors.AsJSON(err).Message)
})
}
}
// A display name at exactly the limit is accepted.
func TestValidateDisplayNameAtMaxLength(t *testing.T) {
atLimit := strings.Repeat("x", MaxDisplayNameLen)
_, err := unmarshalDashboard([]byte(`{"display": {"name": "` + atLimit + `"}, "layouts": []}`))
assert.NoError(t, err)
}

View File

@@ -5,6 +5,7 @@ import (
"maps"
"slices"
"strconv"
"unicode/utf8"
"github.com/SigNoz/signoz/pkg/errors"
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
@@ -15,11 +16,22 @@ import (
"github.com/swaggest/jsonschema-go"
)
// MaxDisplayNameLen bounds every human-readable display name — dashboard, panel,
// and variable display names, plus the grid layout title.
const MaxDisplayNameLen = 128
type Display struct {
Name string `json:"name" required:"true"`
Description string `json:"description,omitempty"`
}
func (d Display) Validate(label, path string) error {
if n := utf8.RuneCountInString(d.Name); n > MaxDisplayNameLen {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: %s name must be at most %d characters, got %d", path, label, MaxDisplayNameLen, n)
}
return nil
}
// ══════════════════════════════════════════════
// Datasource
// ══════════════════════════════════════════════
@@ -188,19 +200,25 @@ func (VariableDefaultValue) PrepareJSONSchema(s *jsonschema.Schema) error {
}
// validate mirrors perses ListVariableSpec validation (plus the digits-only name
// check perses only applies to text variables); run by decodeSpec on unmarshal.
func (s *ListVariableSpec) validate() error {
if err := common.ValidateID(s.Name); err != nil {
// check perses only applies to text variables). path is the JSON path to this
// variable (e.g. "spec.variables[0]") and prefixes each message. Taking a param
// keeps it out of decodeSpec's validate() hook, so errors surface from Validate()
// with clean messages and also run for programmatically built specs (cloning).
func (s *ListVariableSpec) validate(path string) error {
if err := s.Display.Validate("variable", path+".spec.display.name"); err != nil {
return err
}
if err := common.ValidateID(s.Name); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s: %s", path, err.Error())
}
if _, err := strconv.Atoi(s.Name); err == nil {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "variable name cannot contain only digits")
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: variable name cannot contain only digits", path)
}
if s.CustomAllValue != "" && !s.AllowAllValue {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "customAllValue cannot be set if allowAllValue is not set to true")
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: customAllValue cannot be set if allowAllValue is not set to true", path)
}
if s.DefaultValue != nil && len(s.DefaultValue.SliceValues) > 0 && !s.AllowMultiple {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "defaultValue cannot be a list if allowMultiple is not set to true")
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: defaultValue cannot be a list if allowMultiple is not set to true", path)
}
return nil
}
@@ -264,16 +282,21 @@ type TextVariableSpec struct {
Name string `json:"name" required:"true" minLength:"1"`
}
// validate mirrors perses TextVariableSpec validation; run by decodeSpec on unmarshal.
func (s *TextVariableSpec) validate() error {
if err := common.ValidateID(s.Name); err != nil {
// validate mirrors perses TextVariableSpec validation. path is the JSON path to
// this variable (e.g. "spec.variables[0]") and prefixes each message. See
// ListVariableSpec.validate for why it takes a param.
func (s *TextVariableSpec) validate(path string) error {
if err := s.Display.Validate("variable", path+".spec.display.name"); err != nil {
return err
}
if err := common.ValidateID(s.Name); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s: %s", path, err.Error())
}
if _, err := strconv.Atoi(s.Name); err == nil {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "variable name cannot contain only digits")
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: variable name cannot contain only digits", path)
}
if s.Value == "" && s.Constant {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "value for a constant text variable cannot be empty")
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: value for a constant text variable cannot be empty", path)
}
return nil
}

View File

@@ -173,6 +173,33 @@ def test_create_rejects_too_many_tags(
assert response.json()["error"]["code"] == "dashboard_invalid_input"
def test_create_rejects_long_display_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)
# Display names are bounded at 128 characters; one over must be rejected.
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"schemaVersion": "v6",
"name": "long-display-name",
"spec": {"display": {"name": "x" * 129}},
},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json()["error"]["code"] == "dashboard_invalid_input"
assert (
"spec.display.name: dashboard name must be at most 128 characters"
in response.json()["error"]["message"]
)
def test_create_rejects_invalid_grid_layout(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
@@ -566,6 +593,28 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
"Epsilon Metrics",
"Zeta Overview",
}
# top-level tags = org-wide distinct tag set, sorted case-insensitively
# by (key, value). Asserting the exact list (not a set) locks in the sort.
assert body["data"]["tags"] == [
{"key": "env", "value": "dev"},
{"key": "env", "value": "prod"},
{"key": "env", "value": "staging"},
{"key": "team", "value": "metrics"},
{"key": "team", "value": "pulse"},
{"key": "team", "value": "storage"},
{"key": "tier", "value": "critical"},
]
# reserved keywords = the filterable column-level DSL keys, sorted
# alphabetically. Static (independent of the dashboards), so this is the
# full expected set.
assert body["data"]["reservedKeywords"] == [
"created_at",
"created_by",
"description",
"locked",
"name",
"updated_at",
]
# ── stage 4: filter DSL ──────────────────────────────────────────────────
cases = [
@@ -866,7 +915,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
"Zeta Overview",
}
# ── stage 11: clone keeps the display name but mints a new, retrievable one ─
# ── stage 11: clone suffixes the display name and mints a new, retrievable one ─
response = requests.post(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{ids['lc-alpha']}/clone"),
headers={"Authorization": f"Bearer {token}"},
@@ -876,7 +925,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
clone = response.json()["data"]
assert clone["id"] != ids["lc-alpha"]
assert clone["name"] != "lc-alpha" # internal name is regenerated
assert clone["spec"]["display"]["name"] == "Alpha Overview" # display name preserved
assert clone["spec"]["display"]["name"] == "Alpha Overview - Copy" # Copy suffix appended
assert clone["source"] == "user"
assert clone["locked"] is False

View File

@@ -82,14 +82,14 @@ def test_create_rejects_name_too_long(
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={"name": "x" * 33, "data": {"version": "v1"}},
json={"name": "x" * 65, "data": {"version": "v1"}},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json()["error"]["code"] == "dashboard_view_invalid_input"
assert response.json()["error"]["message"] == "name must be at most 32 characters, got 33"
assert response.json()["error"]["message"] == "name must be at most 64 characters, got 65"
def test_create_rejects_wrong_schema_version(