Compare commits

...

34 Commits

Author SHA1 Message Date
Abhi Kumar
26bcf05bea chore(openapi): regenerate specs for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-06 09:55:53 +05:30
Abhi Kumar
67b758f7c2 feat(dashboards): add point size and opacity to scatter plot spec
The fixed size and the min/max range are both stored so binding or unbinding
the size dimension restores the other's last setting.

Assisted-by: Claude Opus 5.5
2026-10-06 09:55:53 +05:30
Abhi Kumar
183f9c4c94 chore(openapi): regenerate specs for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-06 09:55:53 +05:30
Abhi Kumar
06014d0e34 feat(dashboards): add axis labels and multi-key colour to scatter plot spec
An empty colour list colours by every group-by label.

Assisted-by: Claude Opus 5.5
2026-10-06 09:55:53 +05:30
Abhi Kumar
b6f6e50024 chore(openapi): regenerate specs for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-06 09:55:53 +05:30
Abhi Kumar
a2aeff9e01 feat(dashboards): add plugin schema for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-06 09:55:53 +05:30
Abhi Kumar
16a7c3a31f feat(dashboard): confirm before leaving the panel editor with unsaved changes
Assisted-by: Claude Opus 5.5
2026-10-05 14:43:18 +05:30
Abhi Kumar
5c9032c8e1 refactor(dashboard): remove ConfigSegmented and segment icons
Assisted-by: Claude Opus 5.5
2026-10-05 14:43:18 +05:30
Abhi Kumar
af99be1629 feat(dashboard): redesign thresholds and context links sections
Assisted-by: Claude Opus 5.5
2026-10-05 14:43:18 +05:30
Abhi Kumar
8c904cbdf3 style(dashboard): draw config tile sketches in the tile's text color
Assisted-by: Claude Opus 5.5
2026-10-05 14:43:18 +05:30
Abhi Kumar
f150e46c8d feat(dashboard): add text position grid and restyle text panel options
Assisted-by: Claude Opus 5.5
2026-10-05 14:43:18 +05:30
Abhi Kumar
b5af0df527 feat(dashboard): redesign buckets section
Assisted-by: Claude Opus 5.5
2026-10-05 14:43:18 +05:30
Abhi Kumar
fb1f6330ce feat(dashboard): redesign chart appearance section
Assisted-by: Claude Opus 5.5
2026-10-05 14:43:18 +05:30
Abhi Kumar
f35a4337c0 feat(dashboard): redesign legend section and match series colors to legend font
Assisted-by: Claude Opus 5.5
2026-10-05 14:43:18 +05:30
Abhi Kumar
069353a0d7 feat(dashboard): redesign axes section
Assisted-by: Claude Opus 5.5
2026-10-05 14:43:18 +05:30
Abhi Kumar
b4d6be553e feat(dashboard): redesign formatting section with decimal tiles and preview
Assisted-by: Claude Opus 5.5
2026-10-05 14:43:18 +05:30
Abhi Kumar
a15ac197c9 feat(dashboard): redesign visualization section
Assisted-by: Claude Opus 5.5
2026-10-05 14:43:18 +05:30
Abhi Kumar
0e79b33c40 feat(dashboard): summarise config sections and mark changed settings
Assisted-by: Claude Opus 5.5
2026-10-05 14:43:18 +05:30
Abhi Kumar
f9dfc3c1b9 feat(dashboard): add change tracking and shared config pane controls
Assisted-by: Claude Opus 5.5
2026-10-05 14:43:18 +05:30
Abhi Kumar
eb23276a69 test(dashboard): wait for picker store effects after starting a new section 2026-10-05 14:43:15 +05:30
Abhi Kumar
4e8915defd test(dashboard): use userEvent in panel type switcher tests
Assisted-by: Claude Opus 5.5
2026-10-05 14:43:15 +05:30
Abhi Kumar
2bb1a6a275 feat(dashboard): add-to-section split button in the new panel drawer
Replaces the antd section select; the CTA names its destination.

Assisted-by: Claude Opus 5.5
2026-10-05 14:43:15 +05:30
Abhi Kumar
9fdd700026 feat(dashboard): switch panel type in the editor via the type browser
A revert button returns to the type the panel was opened with.
2026-10-05 14:43:15 +05:30
Abhi Kumar
427a5aaaf8 fix(dashboard): scroll to a placeholder that grows the dashboard
OverlayScrollbars marks its viewport scrollable only after noticing the
overflow, and the grid animates its height, so the reveal either scrolled
html or stopped short.
2026-10-05 14:43:15 +05:30
Abhi Kumar
0e127e2cc5 feat(dashboard): preview the new panel where the picker will add it 2026-10-05 14:43:15 +05:30
Abhi Kumar
1eba505187 feat(dashboard): preview a new section on the dashboard while naming it 2026-10-05 14:43:15 +05:30
Abhi Kumar
62e8551897 feat(dashboard): pick or create the section from the new-panel drawer footer
The main New Panel button defaults to the dashboard root.
2026-10-05 14:43:15 +05:30
Abhi Kumar
99b9336bed feat(dashboard): let a new panel's save create its section or the root
Placement travels in the editor URL as a NewPanelTarget, so nothing is
written until the panel is saved.
2026-10-05 14:43:15 +05:30
Abhi Kumar
4c8a7abdc7 feat(dashboard): highlight the picker's target section behind the drawer 2026-10-05 14:43:15 +05:30
Abhi Kumar
f67d2f787d feat(dashboard): redesign the new-panel picker as a searchable drawer 2026-10-05 14:43:15 +05:30
Vikrant Gupta
0aeef0dc46 fix(auth): clear stale password errors on the reset password page (#13035)
Some checks failed
build-staging / prepare (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
#### Description

- Submitting a weak password on the reset page returned the backend
`invalid_password` error, and the pane stayed up after the password was
fixed, replaying its shake while typing.
- The mismatch flag was imperative state set on blur, so it went stale
when a field was cleared; toggling it remounted the API error pane.
- Validation is now derived from watched form values, the same way
SignUp does it, and the mutation is reset on edit so the API error
clears as soon as either field changes.
- Added tests for both cases.

#### Issues closed by this PR

Closes SigNoz/keystone-pod#144

#### Screenshots / Screen Recordings



https://github.com/user-attachments/assets/48dcfa17-9fbc-47bc-83c5-75b0bb46ced0
2026-10-02 12:52:30 +00:00
Tushar Vats
7663ab02d4 feat(logparsingpipeline): add json_body_dual_ingestion flag for dual body ingestion (#12831)
#### Description

Server counterpart to SigNoz/signoz-otel-collector#891, which makes the
collector write each log body to both the legacy `body` column and
`body_v2` while a `json_body_dual_ingestion` flag is on.

- Adds the `json_body_dual_ingestion` feature flag (experimental, off by
default).
- Normalize is placed by read mode, so user pipelines always see the
body the explorer shows. With `use_json_body` on it stays ahead of user
pipelines. With dual ingestion alone, reads are still on the legacy
`body`, so it runs after them and only feeds `body_v2`.
- Whenever dual ingestion is on, the operator carries
`json_body_dual_ingestion: true` so it stashes the original body for the
exporter to restore. Running last under dual makes that stash the
post-pipeline body, exactly what legacy ingestion stores today.
- The pipeline preview follows the same rule and normalizes only under
`use_json_body`.

Design notes: [Normalize Operator and
Pipelines](https://app.notion.com/p/signoz/Normalize-Operator-and-Pipelines-3d7fcc6bcd19802396b9e8e817e30381),
[Dual JSON body
ingestion](https://app.notion.com/p/signoz/Dual-JSON-body-ingestion-3c6fcc6bcd198049963bce6ce2648af8)

#### Additional Information

- Collectors must run a build containing
SigNoz/signoz-otel-collector#891 before this flag is turned on. Verified
locally against v0.144.9: an older collector does not reject the unknown
operator key, it silently ignores it (operator configs are decoded with
`confmap.WithIgnoreUnused()`), runs normalize, and writes the normalized
body into the legacy `body` column until it is upgraded. No collector
release includes #891 yet.
- The exporter's own `json_body_dual_ingestion` key is collector deploy
config and is flipped together with this flag; the server does not set
it.
- Toggling either flag does not bump the pipeline config version, so
connected agents need a new pipeline save to pick up the operator or its
position. Same caveat as `use_json_body` today.
- Under dual, normalize is last among the SigNoz pipelines; custom
collector processors placed after them still see the normalized map.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-02 10:56:38 +00:00
Srikanth Chekuri
1643df620b feat: resolve semconv families across logs and metrics (#12870)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

Phase 2 of #6143: semantic-convention families resolve on logs and
metrics, behind the `resolve_semconv_families` flag (default off), on
the storage contract of #12802.

- Registry: each member carries the scope of its own rename edges, so a
fan-out keeps one membership per target and an ambiguous name stays
literal.
- Logs and metrics families need no family code of their own. The gate
applies to every signal, and `LogicalRead` merges members through each
storage's `Read`.
- Metric-name families union the storage names in every `metric_name`
filter, and the querier reads type, temporality, and the reduced flag
across the family.
- Span-metrics labels: the metrics the processor emits, listed by name,
also read each family member with the `resource_` prefix. Requested
names are never rewritten.
- Values suggestions and related values cover every spelling of the
family.
- `deployment.environment.name` resolves on all three signals.
`db.system.name` stays off until a value-mapping reader exists.

#### Additional Information

- `pkg/semconv.Family` fields are now unexported, and `transition.go` is
removed. #12446 reads the old API and needs an update when stacked.
- A target that emits both names of a metric-name family double-counts
in `sum()` during the overlap window. Reading both names is the feature.
Pinned by a test.
- A family of metrics labels keeps the keyless contract of a single
label: no guard, no NULL group.
2026-10-01 20:28:15 +00:00
Aditya Singh
fd21f8b760 feat(bottom-strip): show the page count on the left for noz, alerts and home (#12963)
#### Description

- noz page, alert rules, home, exceptions and services now push their
count to the left of the strip. each page has its own `useXStripInfo`
hook that builds the config and publishes it.. same pattern trace
details already uses, five more times.
- the hook does the whole thing, so the page call is one line. noz and
home fetch or subscribe inside the hook so the page does not re-render
just to keep the strip current.. the rest pass values they already hold.
- alert rules and exceptions show "N of M".. first number is what is on
the page right now. both read the same two values the table hands its
own pagination, so the strip cannot disagree with the table. services,
home and noz are a plain count.
- services renders one of two tables on `use_span_metrics`, so the hook
is called from both.. the count text lives in one place either way.
- dashboards is left out for now, it already shows this on its own
strip.

#### Issues closed by this PR

Part of https://github.com/SigNoz/events-pod/issues/53
Part of https://github.com/SigNoz/events-pod/issues/55


#### Screenshots

Ai Assistant  Bottom strip

<img width="3456" height="1968" alt="image"
src="https://github.com/user-attachments/assets/0042f71b-1b56-417a-8283-af9ba9596351"
/>


Alert rules

<img width="3452" height="1992" alt="image"
src="https://github.com/user-attachments/assets/a674de13-9841-4717-89ce-a656c9df646c"
/>

Exceptions 

<img width="3456" height="1970" alt="image"
src="https://github.com/user-attachments/assets/6333ea6b-2342-46dc-985d-2855bebe4003"
/>



Services

<img width="3456" height="1996" alt="image"
src="https://github.com/user-attachments/assets/abe0c5cb-cfe3-48b6-9591-ee71e677a1a6"
/>




#### Additional Information
2026-10-01 19:30:00 +00:00
252 changed files with 10294 additions and 2963 deletions

View File

@@ -67,7 +67,7 @@ jobs:
with:
go-version: "1.24"
- name: check-semconv-generated-files
run: go run ./scripts/semconv -check
run: make semconv-check
build:
if: |
github.event_name == 'merge_group' ||

View File

@@ -262,6 +262,10 @@ py-clean: ## Clear all pycache and pytest cache from tests directory recursively
semconv-generate: ## Regenerate semantic-convention families for Go and TypeScript
@go run ./scripts/semconv
.PHONY: semconv-check
semconv-check: ## Fail if the generated semantic-convention files are stale
@go run ./scripts/semconv -check
.PHONY: gen-mocks
gen-mocks:
@echo ">> Generating mocks"

View File

@@ -3425,6 +3425,13 @@ components:
nullable: true
type: number
type: object
DashboardtypesAxisScale:
enum:
- auto
- linear
- log
- symlog
type: string
DashboardtypesBarChartPanelSpec:
properties:
axes:
@@ -4088,6 +4095,7 @@ components:
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
signoz/PieChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
signoz/ScatterPlotPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
signoz/TablePanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
signoz/TextPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
signoz/TimeSeriesPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
@@ -4102,6 +4110,7 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
@@ -4114,6 +4123,7 @@ components:
- signoz/HistogramPanel
- signoz/ListPanel
- signoz/TextPanel
- signoz/ScatterPlotPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
properties:
@@ -4187,6 +4197,18 @@ components:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec:
properties:
kind:
enum:
- signoz/ScatterPlotPanel
type: string
spec:
$ref: '#/components/schemas/DashboardtypesScatterPlotPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec:
properties:
kind:
@@ -4266,6 +4288,16 @@ components:
visualization:
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
type: object
DashboardtypesPointDiameter:
maximum: 40
minimum: 2
nullable: true
type: integer
DashboardtypesPointOpacity:
maximum: 1
minimum: 0.1
nullable: true
type: number
DashboardtypesPostableDashboardV2:
properties:
generateName:
@@ -4439,6 +4471,87 @@ components:
required:
- queryValue
type: object
DashboardtypesScatterPlotAxes:
properties:
x:
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
"y":
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
type: object
DashboardtypesScatterPlotAxis:
properties:
label:
description: Axis title. Empty draws none.
type: string
scale:
$ref: '#/components/schemas/DashboardtypesAxisScale'
softMax:
nullable: true
type: number
softMin:
nullable: true
type: number
type: object
DashboardtypesScatterPlotChartAppearance:
properties:
points:
$ref: '#/components/schemas/DashboardtypesScatterPlotPoints'
type: object
DashboardtypesScatterPlotDimensions:
properties:
color:
description: Group-by label names (e.g. k8s.namespace.name) whose combined
values colour dots and drive the legend. Empty colours by every group-by
label.
items:
type: string
nullable: true
type: array
size:
description: Value column key that scales dot size. Empty draws every dot
at the default size.
type: string
x:
description: Value column key (queryName, or queryName.expression for a
multi-aggregation query) plotted on the x axis. Empty uses the first value
column.
type: string
"y":
description: Value column key plotted on the y axis. Empty uses the second
value column.
type: string
type: object
DashboardtypesScatterPlotPanelSpec:
properties:
axes:
$ref: '#/components/schemas/DashboardtypesScatterPlotAxes'
chartAppearance:
$ref: '#/components/schemas/DashboardtypesScatterPlotChartAppearance'
dimensions:
$ref: '#/components/schemas/DashboardtypesScatterPlotDimensions'
formatting:
$ref: '#/components/schemas/DashboardtypesTableFormatting'
legend:
$ref: '#/components/schemas/DashboardtypesLegend'
thresholds:
items:
$ref: '#/components/schemas/DashboardtypesThresholdWithLabel'
nullable: true
type: array
visualization:
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
type: object
DashboardtypesScatterPlotPoints:
properties:
maxSize:
$ref: '#/components/schemas/DashboardtypesPointDiameter'
minSize:
$ref: '#/components/schemas/DashboardtypesPointDiameter'
opacity:
$ref: '#/components/schemas/DashboardtypesPointOpacity'
size:
$ref: '#/components/schemas/DashboardtypesPointDiameter'
type: object
DashboardtypesSource:
enum:
- user

View File

@@ -84,9 +84,9 @@ A storage answers four questions and nothing else:
| WhenAbsent | Absent row reads | Positive filter | Raw select | Multi-candidate column | Field keys |
|---|---|---|---|---|---|
| `AlwaysPresent` | a real value | no guard | no guard | no branch, ends the candidate list | table columns |
| `AbsentIsSentinel` | `''`, 0, false, and that is not a value | exists guard | exists guard | presence branch | map attributes, cast JSON paths, string families |
| `AbsentIsSentinel` | `''`, 0, false, and that is not a value | exists guard | exists guard | presence branch | map attributes, cast JSON paths, string families of such members |
| `AbsentIsNull` | NULL | no guard | no guard | presence branch | multi-era folds, body JSON paths, numeric families |
| `AbsentIsValue` | `''`, and that is the keyless contract | no guard | no guard | no presence branch | metrics labels, rule state history labels |
| `AbsentIsValue` | `''`, and that is the keyless contract | no guard | no guard | no presence branch | metrics labels, rule state history labels, and families of such members |
### The generic layer
@@ -109,7 +109,7 @@ The functions, from the outside in:
| `RejectsBodyFunction(traits, operator)` | Runs before resolution. A storage without body functions (`has`, `hasAny`, `hasAll`, `hasToken`, `search`) errors. The fingerprint side of a split skips the term, because the main query evaluates it. After resolution, `Condition` errors when `has`, `hasAny`, `hasAll`, or `hasToken` lands on a map-backed key (resource, attribute, scope), before the split can drop it. |
| `SharedCondition(...)` | The `Compile` of every storage without its own condition language: `LogicalRead`, the shared data-type collision cast, `OperatorCondition`, then the guard rule. |
| `OperatorCondition(...)` | The operator switch over an already cast read. A storage with its own cast policy composes with it. |
| `LogicalRead(...)` | The only place family expressions are built. A single-member field reads through its member. A family merges the member reads, current member first: `COALESCE(NULLIF(m1, ''), NULLIF(m2, ''), '')` for strings, `multiIf` with a NULL tail for numbers. It ORs the member presence tests. A row without any member reads what the tail of the merge reads. A member with a value map reads through `TransformRead`. `NOT EXISTS` is the read's `Absence`, the storage's own negated form. |
| `LogicalRead(...)` | The only place family expressions are built. A single-member field reads through its member. A family merges the member reads, current member first: `COALESCE(NULLIF(m1, ''), NULLIF(m2, ''), '')` for strings, `multiIf` with a NULL tail for numbers. It ORs the member presence tests. A row without any member reads what the tail of the merge reads. When every member reads its sentinel as a value, so does the family. A member with a value map reads through `TransformRead`. `NOT EXISTS` is the read's `Absence`, the storage's own negated form. |
### A resolved key

View File

@@ -1,32 +1,82 @@
// Code generated by scripts/semconv. DO NOT EDIT.
export type SemconvFamily = {
readonly current: string;
readonly old: readonly string[];
readonly kind: 'attribute' | 'metric';
// An empty contexts/signals/applyToMetrics array places no constraint on
// that axis.
export type SemconvMember = {
readonly name: string;
readonly contexts: readonly string[];
readonly signals: readonly string[];
readonly applyToMetrics: readonly string[];
};
export type SemconvFamily = {
readonly current: string;
readonly kind: 'attribute' | 'metric';
readonly members: readonly SemconvMember[];
readonly contexts: readonly string[];
readonly signals: readonly string[];
readonly valueMap: Readonly<Record<string, string>>;
};
export const SEMCONV_FAMILIES: readonly SemconvFamily[] = [
{
current: 'db.system.name',
old: ['db.system'],
kind: 'attribute',
current: 'container.cpu.usage',
kind: 'metric',
members: [
{
name: 'container.cpu.utilization',
contexts: [],
signals: [],
applyToMetrics: [],
},
],
contexts: [],
signals: [],
applyToMetrics: [],
valueMap: {},
},
{
current: 'deployment.environment.name',
old: ['deployment.environment'],
kind: 'attribute',
members: [
{
name: 'deployment.environment',
contexts: [],
signals: [],
applyToMetrics: [],
},
],
contexts: ['attribute', 'resource'],
signals: ['logs', 'metrics', 'traces'],
valueMap: {},
},
{
current: 'k8s.node.cpu.usage',
kind: 'metric',
members: [
{
name: 'k8s.node.cpu.utilization',
contexts: [],
signals: [],
applyToMetrics: [],
},
],
contexts: [],
signals: [],
valueMap: {},
},
{
current: 'k8s.pod.cpu.usage',
kind: 'metric',
members: [
{
name: 'k8s.pod.cpu.utilization',
contexts: [],
signals: [],
applyToMetrics: [],
},
],
contexts: [],
signals: [],
applyToMetrics: [],
valueMap: {},
},
] as const;

View File

@@ -0,0 +1,53 @@
import { renderHook } from '@testing-library/react';
import { useBottomStripStore } from 'container/BottomStrip/store/useBottomStripStore';
import { StripItemKind } from 'container/BottomStrip/types';
import { useExceptionsStripInfo } from '../useExceptionsStripInfo';
describe('useExceptionsStripInfo', () => {
beforeEach(() => {
useBottomStripStore.setState({ left: null, ownerId: null });
});
it('shows the rows on the page against the total', () => {
renderHook(() => useExceptionsStripInfo({ shownCount: 25, totalCount: 500 }));
expect(useBottomStripStore.getState().left).toMatchObject([
{ kind: StripItemKind.Text, text: '25 of 500 exceptions' },
]);
});
it('still says n of m when the whole list fits on one page', () => {
renderHook(() => useExceptionsStripInfo({ shownCount: 42, totalCount: 42 }));
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
text: '42 of 42 exceptions',
});
});
it('says exception, not exceptions, when there is one', () => {
renderHook(() => useExceptionsStripInfo({ shownCount: 1, totalCount: 1 }));
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
text: '1 of 1 exception',
});
});
it('shows zero before the counts land', () => {
renderHook(() => useExceptionsStripInfo({ shownCount: 0, totalCount: 0 }));
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
text: '0 of 0 exceptions',
});
});
it('clears the strip when the page unmounts', () => {
const { unmount } = renderHook(() =>
useExceptionsStripInfo({ shownCount: 25, totalCount: 500 }),
);
unmount();
expect(useBottomStripStore.getState().left).toBeNull();
});
});

View File

@@ -38,6 +38,7 @@ import { Exception, PayloadProps } from 'types/api/errors/getAll';
import { GlobalReducer } from 'types/reducer/globalTime';
import { FilterDropdownExtendsProps } from './types';
import { useExceptionsStripInfo } from './useExceptionsStripInfo';
import {
extractFilterValues,
getDefaultFilterValue,
@@ -160,6 +161,11 @@ function AllErrors(): JSX.Element {
},
]);
useExceptionsStripInfo({
shownCount: data?.payload?.length ?? 0,
totalCount: errorCountResponse.data?.payload ?? 0,
});
const isFetching = isErrorsFetching || errorCountResponse.isFetching;
useEffect(() => {
setIsFetching(isFetching);

View File

@@ -0,0 +1,26 @@
import { useMemo } from 'react';
import { useBottomStrip } from 'container/BottomStrip/useBottomStrip';
import { type StripItem, StripItemKind } from 'container/BottomStrip/types';
import { pluralize } from 'utils/pluralize';
interface UseExceptionsStripInfoArgs {
shownCount: number;
totalCount: number;
}
export function useExceptionsStripInfo({
shownCount,
totalCount,
}: UseExceptionsStripInfoArgs): void {
const items = useMemo<StripItem[]>(
() => [
{
kind: StripItemKind.Text,
text: `${shownCount} of ${pluralize(totalCount, 'exception')}`,
},
],
[shownCount, totalCount],
);
useBottomStrip(items);
}

View File

@@ -854,7 +854,9 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
<ChangelogModal changelog={changelog} onClose={toggleChangelogModal} />
)}
<Toaster />
<Toaster
offset={{ bottom: 'calc(var(--bottom-strip-height, 0px) + 24px)' }}
/>
</Layout>
</TooltipProvider>
);

View File

@@ -26,6 +26,7 @@ import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ROUTES from 'constants/routes';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import { useHomeStripInfo } from 'container/Home/useHomeStripInfo';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
@@ -64,6 +65,8 @@ const homeInterval = 30 * 60 * 1000;
// eslint-disable-next-line sonarjs/cognitive-complexity
export default function Home(): JSX.Element {
useHomeStripInfo();
const { user } = useAppContext();
const { safeNavigate } = useSafeNavigate();
const isDarkMode = useIsDarkMode();

View File

@@ -0,0 +1,58 @@
import { renderHook } from '@testing-library/react';
import { useGetAlerts } from 'api/generated/services/alerts';
import { useBottomStripStore } from 'container/BottomStrip/store/useBottomStripStore';
import { StripItemKind } from 'container/BottomStrip/types';
import { useHomeStripInfo } from '../useHomeStripInfo';
jest.mock('api/generated/services/alerts', () => ({
useGetAlerts: jest.fn(),
}));
const mockUseGetAlerts = useGetAlerts as jest.Mock;
describe('useHomeStripInfo', () => {
beforeEach(() => {
useBottomStripStore.setState({ left: null, ownerId: null });
});
it('counts the firing alert instances', () => {
mockUseGetAlerts.mockReturnValue({ data: { data: [{}, {}, {}] } });
renderHook(() => useHomeStripInfo());
expect(useBottomStripStore.getState().left).toMatchObject([
{ kind: StripItemKind.Text, text: '3 alerts firing' },
]);
});
it('says alert, not alerts, when only one is firing', () => {
mockUseGetAlerts.mockReturnValue({ data: { data: [{}] } });
renderHook(() => useHomeStripInfo());
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
text: '1 alert firing',
});
});
it('shows zero before the response lands', () => {
mockUseGetAlerts.mockReturnValue({ data: undefined });
renderHook(() => useHomeStripInfo());
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
text: '0 alerts firing',
});
});
it('clears the strip when the page unmounts', () => {
mockUseGetAlerts.mockReturnValue({ data: { data: [{}, {}, {}] } });
const { unmount } = renderHook(() => useHomeStripInfo());
unmount();
expect(useBottomStripStore.getState().left).toBeNull();
});
});

View File

@@ -0,0 +1,21 @@
import { useMemo } from 'react';
import { useGetAlerts } from 'api/generated/services/alerts';
import { useBottomStrip } from 'container/BottomStrip/useBottomStrip';
import { type StripItem, StripItemKind } from 'container/BottomStrip/types';
import { pluralize } from 'utils/pluralize';
export function useHomeStripInfo(): void {
// Firing instances, not rules, matching the triggered alerts page.
const { data } = useGetAlerts();
const count = data?.data?.length ?? 0;
const items = useMemo<StripItem[]>(
() => [
{ kind: StripItemKind.Text, text: `${pluralize(count, 'alert')} firing` },
],
[count],
);
useBottomStrip(items);
}

View File

@@ -0,0 +1,53 @@
import { renderHook } from '@testing-library/react';
import { useBottomStripStore } from 'container/BottomStrip/store/useBottomStripStore';
import { StripItemKind } from 'container/BottomStrip/types';
import { useAlertRulesStripInfo } from '../useAlertRulesStripInfo';
describe('useAlertRulesStripInfo', () => {
beforeEach(() => {
useBottomStripStore.setState({ left: null, ownerId: null });
});
it('shows the rows on the page against the total', () => {
renderHook(() => useAlertRulesStripInfo({ shownCount: 15, totalCount: 17 }));
expect(useBottomStripStore.getState().left).toMatchObject([
{ kind: StripItemKind.Text, text: '15 of 17 rules' },
]);
});
it('still says n of m when the whole list fits on one page', () => {
renderHook(() => useAlertRulesStripInfo({ shownCount: 17, totalCount: 17 }));
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
text: '17 of 17 rules',
});
});
it('says rule, not rules, when there is only one', () => {
renderHook(() => useAlertRulesStripInfo({ shownCount: 1, totalCount: 1 }));
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
text: '1 of 1 rule',
});
});
it('shows zero when nothing matched', () => {
renderHook(() => useAlertRulesStripInfo({ shownCount: 0, totalCount: 12 }));
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
text: '0 of 12 rules',
});
});
it('clears the strip when the page unmounts', () => {
const { unmount } = renderHook(() =>
useAlertRulesStripInfo({ shownCount: 15, totalCount: 17 }),
);
unmount();
expect(useBottomStripStore.getState().left).toBeNull();
});
});

View File

@@ -20,6 +20,7 @@ import { ALERT_RULES_PARAMS, useAlertRulesFilters } from './hooks';
import styles from './ListAlertRules.module.scss';
import { getAlertRuleColumns } from './table.config';
import type { AlertRule } from './types';
import { useAlertRulesStripInfo } from './useAlertRulesStripInfo';
import { useAlertRulesData } from './useAlertRulesData';
import { useAlertRulesHandlers } from './useAlertRulesHandlers';
@@ -87,6 +88,11 @@ function ListAlertRules(): JSX.Element {
return filteredRules.slice(start, start + limit);
}, [filteredRules, page, limit]);
useAlertRulesStripInfo({
shownCount: paginatedRules.length,
totalCount: filteredRules.length,
});
const columnsWithActions = useMemo(() => {
if (!action) {
return columns;

View File

@@ -0,0 +1,27 @@
import { useMemo } from 'react';
import { useBottomStrip } from 'container/BottomStrip/useBottomStrip';
import { type StripItem, StripItemKind } from 'container/BottomStrip/types';
import { pluralize } from 'utils/pluralize';
interface UseAlertRulesStripInfoArgs {
/** Rows on the current page, matching the table's own footer. */
shownCount: number;
totalCount: number;
}
export function useAlertRulesStripInfo({
shownCount,
totalCount,
}: UseAlertRulesStripInfoArgs): void {
const items = useMemo<StripItem[]>(
() => [
{
kind: StripItemKind.Text,
text: `${shownCount} of ${pluralize(totalCount, 'rule')}`,
},
],
[shownCount, totalCount],
);
useBottomStrip(items);
}

View File

@@ -155,6 +155,39 @@ describe('ResetPassword Component', () => {
{ timeout: 200 },
);
});
it('clears password mismatch error when confirm password is emptied', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<ResetPassword version="1.0.0" />, undefined, {
initialRoute: '/password-reset?token=reset-token-123',
});
const passwordInput = screen.getByPlaceholderText(/enter new password/i);
const confirmPasswordInput = screen.getByPlaceholderText(
/confirm your new password/i,
);
const submitButton = screen.getByRole('button', {
name: /reset password/i,
});
await user.type(passwordInput, 'password123');
await user.type(confirmPasswordInput, 'password456');
await user.tab();
await waitFor(() => {
expect(screen.getByText(/passwords don't match/i)).toBeInTheDocument();
});
await user.clear(confirmPasswordInput);
await waitFor(() => {
expect(
screen.queryByText(/passwords don't match/i),
).not.toBeInTheDocument();
expect(submitButton).toBeDisabled();
});
});
});
describe('Successful Password Reset', () => {
@@ -288,6 +321,65 @@ describe('ResetPassword Component', () => {
expect(screen.queryByText(/invalid token/i)).not.toBeInTheDocument();
});
});
it('clears API error when the user edits the password', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.post(RESET_PASSWORD_ENDPOINT, (_req, res, ctx) =>
res(
ctx.status(400),
ctx.json({
error: {
code: 'invalid_password',
message: 'password must be at least 8 characters long',
},
}),
),
),
);
render(<ResetPassword version="1.0.0" />, undefined, {
initialRoute: '/password-reset?token=reset-token-123',
});
const passwordInput = screen.getByPlaceholderText(/enter new password/i);
const confirmPasswordInput = screen.getByPlaceholderText(
/confirm your new password/i,
);
const submitButton = screen.getByRole('button', {
name: /reset password/i,
});
await user.type(passwordInput, 'weak');
await user.type(confirmPasswordInput, 'weak');
await waitFor(() => {
expect(submitButton).not.toBeDisabled();
});
await user.click(submitButton);
await waitFor(() => {
expect(
screen.getByText(/password must be at least 8 characters long/i),
).toBeInTheDocument();
});
await user.type(passwordInput, 'Er1!');
await waitFor(() => {
expect(
screen.queryByText(/password must be at least 8 characters long/i),
).not.toBeInTheDocument();
});
await user.type(confirmPasswordInput, 'Er1!');
await waitFor(() => {
expect(submitButton).not.toBeDisabled();
});
});
});
describe('Loading States', () => {

View File

@@ -10,7 +10,6 @@ import { useResetPassword } from 'api/generated/services/users';
import AuthError from 'components/AuthError/AuthError';
import AuthPageContainer from 'components/AuthPageContainer';
import ROUTES from 'constants/routes';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import { ArrowRight, CircleAlert, KeyRound } from '@signozhq/icons';
@@ -23,10 +22,7 @@ import './ResetPassword.styles.scss';
type FormValues = { password: string; confirmPassword: string };
function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
const [confirmPasswordError, setConfirmPasswordError] =
useState<boolean>(false);
const [isValidPassword, setIsValidPassword] = useState(false);
const [confirmPasswordTouched, setConfirmPasswordTouched] = useState(false);
const { t } = useTranslation(['common']);
const { search } = useLocation();
const params = new URLSearchParams(search);
@@ -37,6 +33,7 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
mutate: resetPassword,
isLoading,
error: mutationError,
reset: resetMutation,
} = useResetPassword();
const errorMessage = useMemo(
@@ -45,11 +42,30 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
);
const [form] = Form.useForm<FormValues>();
const handleFormSubmit = (): void => {
const { password } = form.getFieldsValue();
const password = Form.useWatch('password', form);
const confirmPassword = Form.useWatch('confirmPassword', form);
const isPasswordMismatch =
Boolean(confirmPassword) && password !== confirmPassword;
const showPasswordMismatchError = confirmPasswordTouched && isPasswordMismatch;
const isValidPassword =
Boolean(password?.trim()) &&
Boolean(confirmPassword?.trim()) &&
password === confirmPassword;
const handleValuesChange = (): void => {
if (mutationError) {
resetMutation();
}
};
const handleSubmit = (): void => {
if (!token) {
return;
}
resetPassword(
{ data: { password, token: token || '' } },
{ data: { password: form.getFieldValue('password'), token } },
{
onSuccess: (): void => {
notifications.success({
@@ -63,80 +79,6 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
);
};
const validatePassword = (): boolean => {
const { password, confirmPassword } = form.getFieldsValue();
if (
password &&
confirmPassword &&
password.trim() &&
confirmPassword.trim() &&
password.length > 0 &&
confirmPassword.length > 0
) {
return password === confirmPassword;
}
return false;
};
const handleValuesChange = useDebouncedFn((): void => {
const { password, confirmPassword } = form.getFieldsValue();
if (!password || !confirmPassword) {
setIsValidPassword(false);
}
// Only clear error if passwords match while typing (but don't set error until blur)
if (
password &&
confirmPassword &&
password.trim() &&
confirmPassword.trim()
) {
const isValid = validatePassword();
setIsValidPassword(isValid);
// Only clear error if passwords match, don't set error on mismatch
if (isValid) {
setConfirmPasswordError(false);
}
}
}, 100);
const handlePasswordBlur = (): void => {
const { confirmPassword } = form.getFieldsValue();
// Only validate if confirm password has a value
if (confirmPassword && confirmPassword.trim()) {
const isValid = validatePassword();
setIsValidPassword(isValid);
setConfirmPasswordError(!isValid);
}
};
const handleConfirmPasswordBlur = (): void => {
const { password, confirmPassword } = form.getFieldsValue();
if (
password &&
password.trim() &&
confirmPassword &&
confirmPassword.trim()
) {
const isValid = validatePassword();
setIsValidPassword(isValid);
setConfirmPasswordError(!isValid);
}
};
const handleSubmit = (): void => {
const isValid = validatePassword();
setIsValidPassword(isValid);
if (token) {
handleFormSubmit();
}
};
return (
<AuthPageContainer>
<div className="reset-password-card">
@@ -158,6 +100,7 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
<FormContainer
form={form}
onFinish={handleSubmit}
onValuesChange={handleValuesChange}
className="reset-password-form"
>
<div className="reset-password-form-container">
@@ -171,8 +114,6 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
>
<AntdInput.Password
tabIndex={0}
onChange={handleValuesChange}
onBlur={handlePasswordBlur}
id="password"
data-testid="password"
placeholder="Enter new password"
@@ -189,8 +130,7 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
rules={[{ required: true, message: 'Please enter confirm password!' }]}
>
<AntdInput.Password
onChange={handleValuesChange}
onBlur={handleConfirmPasswordBlur}
onBlur={(): void => setConfirmPasswordTouched(true)}
id="confirmPassword"
data-testid="confirmPassword"
placeholder="Confirm your new password"
@@ -201,7 +141,7 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
</div>
</div>
{confirmPasswordError && (
{showPasswordMismatchError && (
<Callout
type="error"
size="small"
@@ -213,7 +153,7 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
</Callout>
)}
{errorMessage && !confirmPasswordError && (
{errorMessage && !showPasswordMismatchError && (
<AuthError error={errorMessage} />
)}

View File

@@ -21,6 +21,7 @@ import { getTotalRPS } from 'utils/services';
import { getColumns } from '../Columns/ServiceColumn';
import { ServiceMetricsTableProps } from '../types';
import { useServicesStripInfo } from '../useServicesStripInfo';
import { getServiceListFromQuery } from '../utils';
function ServiceMetricTable({
@@ -67,6 +68,8 @@ function ServiceMetricTable({
[isLoading, queries, topLevelOperations],
);
useServicesStripInfo(services.length);
const { search } = useLocation();
const tableColumns = useMemo(() => getColumns(search, true), [search]);
const [RPS, setRPS] = useState(0);

View File

@@ -18,6 +18,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { Tags } from 'hooks/useResourceAttribute/types';
import SkipOnBoardingModal from '../SkipOnBoardModal';
import { useServicesStripInfo } from '../useServicesStripInfo';
import ServiceTraceTable from './ServiceTracesTable';
function ServiceTraces(): JSX.Element {
@@ -42,6 +43,8 @@ function ServiceTraces(): JSX.Element {
const services = data || [];
useServicesStripInfo(services.length);
const [skipOnboarding, setSkipOnboarding] = useState(
localStorageGet(SKIP_ONBOARDING) === 'true',
);

View File

@@ -0,0 +1,43 @@
import { renderHook } from '@testing-library/react';
import { useBottomStripStore } from 'container/BottomStrip/store/useBottomStripStore';
import { StripItemKind } from 'container/BottomStrip/types';
import { useServicesStripInfo } from '../useServicesStripInfo';
describe('useServicesStripInfo', () => {
beforeEach(() => {
useBottomStripStore.setState({ left: null, ownerId: null });
});
it('shows how many services are listed', () => {
renderHook(() => useServicesStripInfo(18));
expect(useBottomStripStore.getState().left).toMatchObject([
{ kind: StripItemKind.Text, text: '18 services' },
]);
});
it('says service, not services, when there is one', () => {
renderHook(() => useServicesStripInfo(1));
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
text: '1 service',
});
});
it('shows zero when there are none', () => {
renderHook(() => useServicesStripInfo(0));
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
text: '0 services',
});
});
it('clears the strip when the page unmounts', () => {
const { unmount } = renderHook(() => useServicesStripInfo(18));
unmount();
expect(useBottomStripStore.getState().left).toBeNull();
});
});

View File

@@ -0,0 +1,13 @@
import { useMemo } from 'react';
import { useBottomStrip } from 'container/BottomStrip/useBottomStrip';
import { type StripItem, StripItemKind } from 'container/BottomStrip/types';
import { pluralize } from 'utils/pluralize';
export function useServicesStripInfo(count: number): void {
const items = useMemo<StripItem[]>(
() => [{ kind: StripItemKind.Text, text: pluralize(count, 'service') }],
[count],
);
useBottomStrip(items);
}

View File

@@ -11,6 +11,8 @@ import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantS
import { VariantContext } from 'container/AIAssistant/VariantContext';
import Noz from 'components/Noz/Noz';
import { useAIAssistantStripInfo } from './useAIAssistantStripInfo';
import styles from './AIAssistantPage.module.scss';
import ConversationsList from 'container/AIAssistant/components/ConversationsList';
@@ -41,6 +43,8 @@ export default function AIAssistantPage(): JSX.Element {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useAIAssistantStripInfo();
const conversations = useAIAssistantStore((s) => s.conversations);
const activeConversationId = useAIAssistantStore(
(s) => s.activeConversationId,

View File

@@ -0,0 +1,60 @@
import { renderHook } from '@testing-library/react';
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
import { useBottomStripStore } from 'container/BottomStrip/store/useBottomStripStore';
import { StripItemKind } from 'container/BottomStrip/types';
import { useAIAssistantStripInfo } from '../useAIAssistantStripInfo';
function seed(conversations: Record<string, unknown>): void {
useAIAssistantStore.setState({ conversations } as never);
}
describe('useAIAssistantStripInfo', () => {
beforeEach(() => {
useBottomStripStore.setState({ left: null, ownerId: null });
});
it('counts only the conversations that are not archived', () => {
seed({
a: { id: 'a' },
b: { id: 'b' },
c: { id: 'c', archived: true },
});
renderHook(() => useAIAssistantStripInfo());
expect(useBottomStripStore.getState().left).toMatchObject([
{ kind: StripItemKind.Text, text: '2 conversations' },
]);
});
it('says one conversation, not 1 conversations', () => {
seed({ a: { id: 'a' } });
renderHook(() => useAIAssistantStripInfo());
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
text: '1 conversation',
});
});
it('shows zero when there are none', () => {
seed({});
renderHook(() => useAIAssistantStripInfo());
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
text: '0 conversations',
});
});
it('clears the strip when the page unmounts', () => {
seed({ a: { id: 'a' } });
const { unmount } = renderHook(() => useAIAssistantStripInfo());
unmount();
expect(useBottomStripStore.getState().left).toBeNull();
});
});

View File

@@ -243,3 +243,11 @@ export const TooltipsInApprovalDiff: Story = {
args: { tooltipsOpen: true, agent: 'awaiting-approval', contents: BRIEF },
play: openApprovalDiff,
};
/**
* The conversation count in the bottom strip, in place of the build version.
* Archived threads are left out of it.
*/
export const BottomStrip: Story = {
args: { bottomStrip: true },
};

View File

@@ -0,0 +1,20 @@
import { useMemo } from 'react';
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
import { useBottomStrip } from 'container/BottomStrip/useBottomStrip';
import { type StripItem, StripItemKind } from 'container/BottomStrip/types';
import { pluralize } from 'utils/pluralize';
export function useAIAssistantStripInfo(): void {
const conversations = useAIAssistantStore((state) => state.conversations);
const count = Object.values(conversations).filter(
(conversation) => !conversation.archived,
).length;
const items = useMemo<StripItem[]>(
() => [{ kind: StripItemKind.Text, text: pluralize(count, 'conversation') }],
[count],
);
useBottomStrip(items);
}

View File

@@ -133,3 +133,19 @@ export const ColumnPicker: Story = {
export const Tooltips: Story = {
args: { tooltipsOpen: true },
};
/**
* The rule count in the bottom strip, in place of the build version: the rows on
* the page against the total, the same pair the table's own footer prints.
*/
export const BottomStrip: Story = {
args: { bottomStrip: true },
};
/** The same count on a second page, where the two numbers come apart. */
export const BottomStripPaginated: Story = {
args: { bottomStrip: true, rules: RULE_MAX },
parameters: {
signoz: { route: '/alerts?tab=AlertRules&page=2&limit=10' },
},
};

View File

@@ -125,3 +125,11 @@ export const QuickFiltersSettingsWithBanner: Story = {
args: { banner: 'trial-expiry' },
play: dirtyQuickFiltersSettings,
};
/**
* The exception count in the bottom strip, in place of the build version: the
* rows on the page against the total the count query returns.
*/
export const BottomStrip: Story = {
args: { bottomStrip: true },
};

View File

@@ -8,6 +8,7 @@
background-color: var(--l1-background);
overflow-y: auto;
overflow-x: hidden;
scrollbar-gutter: stable;
padding-bottom: 44px;
@include custom-scrollbar;
@@ -42,11 +43,12 @@
.eyebrow {
display: block;
font-size: 11px;
font-weight: 600;
padding: 16px;
letter-spacing: 0.06em;
color: var(--l1-foreground);
padding: 18px 20px 12px;
font-family: 'Geist Mono', monospace;
font-size: 12px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--l2-foreground);
}
.group {
@@ -73,9 +75,5 @@
.sections {
display: flex;
flex-direction: column;
& > * {
padding: 0 16px;
border-top: 1px solid var(--l1-border);
}
border-bottom: 1px solid var(--l1-border);
}

View File

@@ -1,3 +1,4 @@
import { useMemo } from 'react';
import { Input } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type {
@@ -7,6 +8,7 @@ import type {
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { SectionKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import { getSupportedSignals } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { buildPluginSpec } from 'pages/DashboardPage/DashboardContainer/Panels/utils/buildPluginSpec';
import { resolveSignal } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
import type { EQueryType } from 'types/common/dashboard';
@@ -25,6 +27,7 @@ interface ConfigPaneProps {
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
/** Switch the panel to another visualization kind. */
onChangePanelKind: (kind: PanelKind) => void;
originalPanelKind?: PanelKind;
/**
* Active query type from the query-builder provider (the selected tab). Drives which
* panel types the visualization switcher disables — read from the provider, not the
@@ -57,6 +60,7 @@ function ConfigPane({
spec,
onChangeSpec,
onChangePanelKind,
originalPanelKind,
queryType,
legendSeries,
tableColumns,
@@ -76,6 +80,11 @@ function ConfigPane({
(config) => config.kind !== SectionKind.PanelHeader,
);
const defaults = useMemo(
() => buildPluginSpec(definition.sections),
[definition],
);
const signal = resolveSignal(spec.queries, getSupportedSignals(panelKind)[0]);
// Title/description are just a slice of the spec — edit them through the same
@@ -120,11 +129,13 @@ function ConfigPane({
config={headerSection}
spec={spec}
onChangeSpec={onChangeSpec}
defaults={defaults}
legendSeries={legendSeries}
tableColumns={tableColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
originalPanelKind={originalPanelKind}
queryType={queryType}
stepInterval={stepInterval}
metricUnit={metricUnit}
@@ -144,11 +155,13 @@ function ConfigPane({
config={config}
spec={spec}
onChangeSpec={onChangeSpec}
defaults={defaults}
legendSeries={legendSeries}
tableColumns={tableColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
originalPanelKind={originalPanelKind}
queryType={queryType}
stepInterval={stepInterval}
metricUnit={metricUnit}

View File

@@ -1,6 +1,56 @@
// Matches ConfigPane's `.field` so the switcher lines up with the title/description fields.
.field {
@use '../../../../../../styles/scrollbar' as *;
.trigger {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 5px 5px 5px 12px;
border: 1px solid var(--l2-border);
border-radius: 4px;
background: var(--l2-background);
font: inherit;
text-align: left;
cursor: pointer;
&:hover {
border-color: var(--l3-border);
}
}
.triggerIcon {
flex-shrink: 0;
color: var(--l2-foreground);
}
.triggerName {
flex: 1;
min-width: 0;
overflow: hidden;
color: var(--l1-foreground);
text-overflow: ellipsis;
white-space: nowrap;
}
.triggerAction {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 3px;
background: var(--l3-background);
color: var(--l2-foreground);
font-size: 12px;
}
.revert {
align-self: flex-start;
}
.drawerBody {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
overflow-y: auto;
@include custom-scrollbar;
}

View File

@@ -1,12 +1,17 @@
import { Typography } from '@signozhq/ui/typography';
import { useCallback, useState } from 'react';
import { ArrowRightLeft, Undo2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { EQueryType } from 'types/common/dashboard';
import PanelTypeBrowser from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeBrowser';
import { getPanelDefinition } from '../../../Panels/registry';
import type { PanelKind } from '../../../Panels/types/panelKind';
import ConfigSelect from '../controls/ConfigSelect/ConfigSelect';
import ConfigField from '../controls/ConfigField/ConfigField';
import styles from './PanelTypeSwitcher.module.scss';
import { usePanelTypeSelectItems } from './usePanelTypeSelectItems';
import { getPanelTypeDisabledReason } from './utils';
interface PanelTypeSwitcherProps {
/** The current panel kind (selected value). */
@@ -15,33 +20,96 @@ interface PanelTypeSwitcherProps {
queryType: EQueryType;
/** Panel's current signal — also gates the disabled rule (List needs logs/traces, not metrics). */
signal?: TelemetrytypesSignalDTO;
/** Kind the panel was opened with; a revert button appears once it differs. */
originalPanelKind?: PanelKind;
onChange: (kind: PanelKind) => void;
}
/**
* Visualization-type selector (rendered inside the Visualization section). A type is
* disabled when the active query type or signal is incompatible with it — resolved
* through the capabilities guard. The signal is unknown for PromQL/ClickHouse, but
* those query types still disable kinds that only support Query Builder (e.g. List).
* Visualization-type selector (rendered inside the Visualization section): opens the
* panel type browser in a drawer. A type is disabled when the active query type or
* signal is incompatible with it — resolved through the capabilities guard.
*/
function PanelTypeSwitcher({
panelKind,
queryType,
signal,
originalPanelKind,
onChange,
}: PanelTypeSwitcherProps): JSX.Element {
const items = usePanelTypeSelectItems({ queryType, signal });
const [isOpen, setIsOpen] = useState(false);
const { displayName, icon: Icon } = getPanelDefinition(panelKind);
const getDisabledReason = useCallback(
(kind: PanelKind): string | undefined =>
getPanelTypeDisabledReason({
kind,
queryType,
signal,
label: getPanelDefinition(kind).displayName,
}),
[queryType, signal],
);
const canRevert = !!originalPanelKind && originalPanelKind !== panelKind;
const revertBlockedReason = canRevert
? getDisabledReason(originalPanelKind)
: undefined;
const handleSelect = (kind: PanelKind): void => {
setIsOpen(false);
if (kind !== panelKind) {
onChange(kind);
}
};
return (
<div className={styles.field}>
<Typography.Text>Panel Type</Typography.Text>
<ConfigSelect
testId="panel-editor-v2-type-switcher"
value={panelKind}
items={items}
onChange={(value): void => onChange(value)}
/>
</div>
<ConfigField label="Panel type">
<button
type="button"
className={styles.trigger}
onClick={(): void => setIsOpen(true)}
data-testid="panel-editor-v2-type-switcher"
>
<Icon size={14} className={styles.triggerIcon} />
<span className={styles.triggerName}>{displayName}</span>
<span className={styles.triggerAction}>
<ArrowRightLeft size={14} />
Change
</span>
</button>
{canRevert && (
<Button
variant="link"
color="primary"
size="sm"
prefix={<Undo2 />}
className={styles.revert}
disabled={!!revertBlockedReason}
title={revertBlockedReason}
onClick={(): void => onChange(originalPanelKind)}
testId="panel-editor-v2-type-revert"
>
Revert to {getPanelDefinition(originalPanelKind).displayName}
</Button>
)}
<DrawerWrapper
open={isOpen}
onOpenChange={setIsOpen}
title="Change panel type"
subTitle="Pick a visualization for this panel."
direction="right"
width="wide"
testId="panel-type-switcher-drawer"
drawerDescriptionProps={{ className: styles.drawerBody }}
>
<PanelTypeBrowser
selectedKind={panelKind}
onSelect={handleSelect}
getDisabledReason={getDisabledReason}
/>
</DrawerWrapper>
</ConfigField>
);
}

View File

@@ -1,22 +1,30 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import PanelTypeSwitcher from '../PanelTypeSwitcher';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
const OPTIONS = [
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },
{ kind: 'signoz/NumberPanel', displayName: 'Number' },
{ kind: 'signoz/TablePanel', displayName: 'Table' },
{ kind: 'signoz/BarChartPanel', displayName: 'Bar Chart' },
{ kind: 'signoz/AreaChartPanel', displayName: 'Area' },
{ kind: 'signoz/PieChartPanel', displayName: 'Pie Chart' },
{ kind: 'signoz/HistogramPanel', displayName: 'Histogram' },
{ kind: 'signoz/ListPanel', displayName: 'List' },
{ kind: 'signoz/TextPanel', displayName: 'Text' },
].map((option) => ({ ...option, icon: (): null => null }));
// Stub the registry so the test doesn't pull in the real renderers and chart libs.
jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => ({
getPanelDefinition: jest.fn(),
PANEL_OPTIONS: [
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },
{ kind: 'signoz/NumberPanel', displayName: 'Number' },
{ kind: 'signoz/TablePanel', displayName: 'Table' },
{ kind: 'signoz/BarChartPanel', displayName: 'Bar Chart' },
{ kind: 'signoz/PieChartPanel', displayName: 'Pie Chart' },
{ kind: 'signoz/HistogramPanel', displayName: 'Histogram' },
{ kind: 'signoz/ListPanel', displayName: 'List' },
].map((option) => ({ ...option, icon: (): null => null })),
get PANEL_OPTIONS(): unknown {
return OPTIONS;
},
}));
const mockGetPanelDefinition = getPanelDefinition as unknown as jest.Mock;
@@ -28,14 +36,34 @@ const SUPPORTED_QUERY_TYPES: Record<string, EQueryType[]> = {
'signoz/PieChartPanel': [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
};
function disabledLabels(): (string | null)[] {
return Array.from(
document.querySelectorAll('.ant-select-item-option-disabled'),
).map((el) => el.textContent);
type User = ReturnType<typeof userEvent.setup>;
async function renderSwitcher(
props: Partial<Parameters<typeof PanelTypeSwitcher>[0]> = {},
): Promise<{ onChange: jest.Mock; user: User }> {
// The open drawer sets `pointer-events: none` on the body.
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onChange = jest.fn();
render(
<TooltipProvider>
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.QUERY_BUILDER}
onChange={onChange}
{...props}
/>
</TooltipProvider>,
);
await user.click(screen.getByTestId('panel-editor-v2-type-switcher'));
return { onChange, user };
}
function openDropdown(): void {
fireEvent.mouseDown(screen.getByRole('combobox'));
function disabledKinds(): (string | undefined)[] {
return Array.from(
document.querySelectorAll('[data-testid^="panel-type-signoz/"]'),
)
.filter((el) => el.getAttribute('aria-disabled') === 'true')
.map((el) => el.getAttribute('data-testid')?.replace('panel-type-', ''));
}
describe('PanelTypeSwitcher', () => {
@@ -44,7 +72,8 @@ describe('PanelTypeSwitcher', () => {
// List supports only logs/traces; every other kind also supports metrics.
// Query-type support comes from SUPPORTED_QUERY_TYPES (all three by default).
mockGetPanelDefinition.mockImplementation((kind: string) => ({
mode: 'query',
...OPTIONS.find((option) => option.kind === kind),
mode: kind === 'signoz/TextPanel' ? 'static' : 'query',
supportedSignals:
kind === 'signoz/ListPanel'
? ['logs', 'traces']
@@ -57,83 +86,94 @@ describe('PanelTypeSwitcher', () => {
}));
});
it('fires onChange with the chosen plugin kind', () => {
const onChange = jest.fn();
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.QUERY_BUILDER}
onChange={onChange}
/>,
);
it('shows the current type and switches to the chosen one', async () => {
const { onChange, user } = await renderSwitcher();
openDropdown();
fireEvent.click(screen.getByText('List'));
expect(screen.getByTestId('panel-editor-v2-type-switcher')).toHaveTextContent(
'Time SeriesChange',
);
await user.click(screen.getByTestId('panel-type-signoz/ListPanel'));
expect(onChange).toHaveBeenCalledWith('signoz/ListPanel');
});
it('disables types whose supported signals exclude the current signal', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.QUERY_BUILDER}
signal={TelemetrytypesSignalDTO.metrics}
onChange={jest.fn()}
/>,
);
it('does not fire onChange when the current type is picked again', async () => {
const { onChange, user } = await renderSwitcher();
openDropdown();
// List can't render a metrics query, so it's disabled; Time Series stays enabled.
expect(disabledLabels()).toContain('List');
expect(disabledLabels()).not.toContain('Time Series');
await user.click(screen.getByTestId('panel-type-signoz/TimeSeriesPanel'));
expect(onChange).not.toHaveBeenCalled();
});
it('does not disable any type when the signal is unknown (builder, no signal)', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.QUERY_BUILDER}
onChange={jest.fn()}
/>,
);
it('disables types whose supported signals exclude the current signal', async () => {
const { onChange, user } = await renderSwitcher({
signal: TelemetrytypesSignalDTO.metrics,
});
openDropdown();
expect(
document.querySelectorAll('.ant-select-item-option-disabled'),
).toHaveLength(0);
expect(disabledKinds()).toStrictEqual(['signoz/ListPanel']);
await user.click(screen.getByTestId('panel-type-signoz/ListPanel'));
expect(onChange).not.toHaveBeenCalled();
});
it('disables Query-Builder-only kinds under PromQL even without a signal', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.PROM}
onChange={jest.fn()}
/>,
);
it('does not disable any type when the signal is unknown (builder, no signal)', async () => {
await renderSwitcher();
openDropdown();
// List/Table/Pie can't be authored in PromQL; Time Series can.
expect(disabledLabels()).toContain('List');
expect(disabledLabels()).toContain('Table');
expect(disabledLabels()).toContain('Pie Chart');
expect(disabledLabels()).not.toContain('Time Series');
expect(disabledKinds()).toHaveLength(0);
});
it('disables List under ClickHouse while Table/Pie stay enabled', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TablePanel"
queryType={EQueryType.CLICKHOUSE}
onChange={jest.fn()}
/>,
);
it('disables Query-Builder-only kinds under PromQL even without a signal', async () => {
await renderSwitcher({ queryType: EQueryType.PROM });
openDropdown();
expect(disabledLabels()).toContain('List');
expect(disabledLabels()).not.toContain('Table');
expect(disabledLabels()).not.toContain('Pie Chart');
expect(disabledLabels()).not.toContain('Time Series');
expect(disabledKinds()).toStrictEqual(
expect.arrayContaining([
'signoz/ListPanel',
'signoz/TablePanel',
'signoz/PieChartPanel',
]),
);
expect(disabledKinds()).not.toContain('signoz/TimeSeriesPanel');
expect(disabledKinds()).not.toContain('signoz/TextPanel');
});
it('disables List under ClickHouse while Table/Pie stay enabled', async () => {
await renderSwitcher({
panelKind: 'signoz/TablePanel',
queryType: EQueryType.CLICKHOUSE,
});
expect(disabledKinds()).toStrictEqual(['signoz/ListPanel']);
});
describe('revert', () => {
it('is hidden while the type is the original one', async () => {
await renderSwitcher({ originalPanelKind: 'signoz/TimeSeriesPanel' });
expect(
screen.queryByTestId('panel-editor-v2-type-revert'),
).not.toBeInTheDocument();
});
it('switches back to the original type', async () => {
const { onChange, user } = await renderSwitcher({
panelKind: 'signoz/TablePanel',
originalPanelKind: 'signoz/TimeSeriesPanel',
});
const revert = screen.getByTestId('panel-editor-v2-type-revert');
expect(revert).toHaveTextContent('Revert to Time Series');
await user.click(revert);
expect(onChange).toHaveBeenCalledWith('signoz/TimeSeriesPanel');
});
it('is disabled when the original type no longer fits the query', async () => {
await renderSwitcher({
panelKind: 'signoz/TimeSeriesPanel',
originalPanelKind: 'signoz/ListPanel',
queryType: EQueryType.PROM,
});
expect(screen.getByTestId('panel-editor-v2-type-revert')).toBeDisabled();
});
});
});

View File

@@ -18,8 +18,7 @@ interface UsePanelTypeSelectItemsArgs {
/**
* Visualization-kind options for a `ConfigSelect`, each disabled (with a reason
* tooltip) when the active query type or signal is incompatible — resolved through
* the capabilities guard. Shared by the editor's `PanelTypeSwitcher` and the View
* modal's header so the two selectors apply the same rule and can't drift.
* the capabilities guard, the same rule the editor's `PanelTypeSwitcher` applies.
*/
export function usePanelTypeSelectItems({
queryType,

View File

@@ -6,16 +6,20 @@ import {
type SectionConfig,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import type { SeededPluginSpec } from 'pages/DashboardPage/DashboardContainer/Panels/utils/buildPluginSpec';
import type { SectionEditorContext } from '../sectionContext';
import { resolveSectionEditor } from '../sectionRegistry';
import { getSectionDefault, resolveSectionEditor } from '../sectionRegistry';
import SettingsSection from '../SettingsSection/SettingsSection';
import { isChanged } from '../utils/changes';
import SectionHeaderQuickAdd from './SectionHeaderQuickAdd';
type SectionSlotProps = {
config: SectionConfig;
spec: DashboardtypesPanelSpecDTO;
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
/** The kind's seeded plugin spec (`buildPluginSpec`). */
defaults: SeededPluginSpec;
/** Renders the editor alone, for a section promoted into the Panel Details fields. */
bare?: boolean;
} & Omit<SectionEditorContext, 'yAxisUnit' | 'registerHeaderAction'>;
@@ -52,12 +56,14 @@ function SectionSlot({
config,
spec,
onChangeSpec,
defaults,
bare,
legendSeries,
tableColumns,
signal,
panelKind,
onChangePanelKind,
originalPanelKind,
queryType,
stepInterval,
metricUnit,
@@ -103,8 +109,10 @@ function SectionSlot({
return null;
}
const { title, icon: Icon } = SECTION_METADATA[config.kind];
const { Component, get, update } = editor;
const { title } = SECTION_METADATA[config.kind];
const { Component, get, update, summarize } = editor;
const value = get(spec);
const defaultValue = getSectionDefault(editor, spec, defaults);
// Atomic sections carry no `controls`; controlled ones do.
const controls = 'controls' in config ? config.controls : undefined;
// Forwarded to editors that scope to the panel's unit (e.g. the thresholds unit picker).
@@ -113,20 +121,26 @@ function SectionSlot({
const headerSlot = SECTION_HEADER_SLOT[config.kind]?.(triggerHeaderAction);
const context: SectionEditorContext = {
legendSeries,
yAxisUnit,
tableColumns,
signal,
panelKind,
onChangePanelKind,
originalPanelKind,
queryType,
stepInterval,
metricUnit,
};
const editorElement = (
<Component
value={get(spec)}
value={value}
defaultValue={defaultValue}
controls={controls}
onChange={(next): void => onChangeSpec(update(spec, next))}
legendSeries={legendSeries}
yAxisUnit={yAxisUnit}
tableColumns={tableColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
queryType={queryType}
stepInterval={stepInterval}
metricUnit={metricUnit}
{...context}
registerHeaderAction={registerHeaderAction}
/>
);
@@ -138,7 +152,8 @@ function SectionSlot({
return (
<SettingsSection
title={title}
icon={<Icon size={15} />}
summary={summarize?.(value, controls, context)}
changed={isChanged(value, defaultValue)}
open={open}
onOpenChange={setOpen}
headerSlot={headerSlot}

View File

@@ -28,7 +28,12 @@ function Harness({ initial = [] }: { initial?: unknown[] } = {}): JSX.Element {
makeSpec(initial),
);
return (
<SectionSlot config={THRESHOLDS_CONFIG} spec={spec} onChangeSpec={setSpec} />
<SectionSlot
config={THRESHOLDS_CONFIG}
spec={spec}
defaults={{}}
onChangeSpec={setSpec}
/>
);
}
@@ -70,3 +75,48 @@ describe('SectionSlot header action', () => {
expect(screen.getByTestId('threshold-value-0')).toBeInTheDocument();
});
});
describe('SectionSlot header summary', () => {
it('summarises a collapsed section and marks it changed', async () => {
const user = userEvent.setup();
render(
<Harness initial={[{ value: 80, color: '#F5B225', label: 'High' }]} />,
);
await user.click(screen.getByTestId('config-section-thresholds'));
expect(screen.getByText('1 threshold')).toBeInTheDocument();
expect(screen.getByTestId('config-changed-dot')).toBeInTheDocument();
});
it('shows no summary or changed marker for an untouched section', () => {
render(<Harness />);
expect(screen.queryByTestId('config-changed-dot')).not.toBeInTheDocument();
expect(screen.queryByText(/threshold$/)).not.toBeInTheDocument();
});
});
describe('SectionSlot rendered defaults', () => {
it('does not mark an explicit value that matches what unset renders as', () => {
const spec = {
display: { name: 'CPU' },
plugin: {
kind: 'signoz/TimeSeriesPanel',
spec: { formatting: { decimalPrecision: '2' } },
},
queries: [],
} as unknown as DashboardtypesPanelSpecDTO;
render(
<SectionSlot
config={{ kind: SectionKind.Formatting, controls: { decimals: true } }}
spec={spec}
defaults={{}}
onChangeSpec={jest.fn()}
/>,
);
expect(screen.queryByTestId('config-changed-dot')).not.toBeInTheDocument();
});
});

View File

@@ -1,51 +1,53 @@
.section {
border-top: 1px solid var(--l1-border);
}
.header {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
height: 44px;
padding: 0 20px 0 0;
&:hover {
background: var(--l2-background);
}
}
// Disclosure control (icon tile + title); fills the row so the action slot and chevron sit right.
.toggle {
display: flex;
flex: 1;
align-items: center;
gap: 11px;
gap: 10px;
min-width: 0;
padding: 0 !important;
padding: 18px 0 18px 20px;
border: none;
background: transparent;
font: inherit;
cursor: pointer;
color: var(--text-vanilla-100);
border-radius: 4px;
}
.iconTile {
display: grid;
place-items: center;
width: 27px;
height: 27px;
flex: none;
border-radius: 3px;
background: var(--l3-background);
color: var(--l3-foreground);
transition: all 0.15s ease;
}
.iconTileOpen {
background: color-mix(in srgb, var(--bg-robin-400) 14%, transparent);
color: var(--bg-robin-400);
}
.title {
font-size: 13px;
font-weight: 400;
white-space: nowrap;
color: var(--l1-foreground);
}
.summary {
flex: 1;
text-align: left;
font-weight: 600;
min-width: 0;
overflow: hidden;
font-size: 12px;
text-align: right;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--l2-foreground);
}
.chevron {
flex: none;
color: var(--l3-foreground);
cursor: pointer;
transition: transform 0.15s ease;
&.open {
@@ -56,6 +58,6 @@
.body {
display: flex;
flex-direction: column;
gap: 16px;
padding: 2px 0 18px;
gap: 26px;
padding: 4px 20px 26px;
}

View File

@@ -1,18 +1,20 @@
import { type ReactNode, useState } from 'react';
import { ChevronDown } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import ChangedDot from '../controls/ChangedDot/ChangedDot';
import styles from './SettingsSection.module.scss';
interface SettingsSectionProps {
title: string;
icon?: ReactNode;
defaultOpen?: boolean;
/** Controlled open state; when set, the section defers to `onOpenChange`. */
open?: boolean;
onOpenChange?: (open: boolean) => void;
/** One-line digest of the section's values, shown while collapsed. */
summary?: string;
changed?: boolean;
/** Rendered between the title and the chevron. */
headerSlot?: ReactNode;
children: ReactNode;
@@ -23,10 +25,11 @@ interface SettingsSectionProps {
*/
function SettingsSection({
title,
icon,
defaultOpen = false,
open,
onOpenChange,
summary,
changed,
headerSlot,
children,
}: SettingsSectionProps): JSX.Element {
@@ -51,30 +54,18 @@ function SettingsSection({
type="button"
className={styles.toggle}
aria-expanded={isOpen}
aria-label={isOpen ? `Collapse ${title}` : `Expand ${title}`}
data-testid={`config-section-${serializedTitle}`}
onClick={toggle}
>
{icon && (
<span className={cx(styles.iconTile, { [styles.iconTileOpen]: isOpen })}>
{icon}
</span>
)}
<Typography.Text className={styles.title}>{title}</Typography.Text>
<span className={styles.title}>{title}</span>
{changed && <ChangedDot title="Has changed settings" />}
<span className={styles.summary}>{isOpen ? '' : summary}</span>
</button>
{headerSlot}
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
prefix={
<ChevronDown
size={15}
className={cx(styles.chevron, { [styles.open]: isOpen })}
/>
}
aria-label={isOpen ? `Collapse ${title}` : `Expand ${title}`}
tabIndex={-1}
<ChevronDown
size={14}
className={cx(styles.chevron, { [styles.open]: isOpen })}
onClick={toggle}
/>
</div>

View File

@@ -108,7 +108,7 @@ describe('ConfigPane', () => {
const toggle = screen.getByTestId('panel-header-hide');
expect(toggle).toBeInTheDocument();
expect(screen.getByText('Hide header')).toBeInTheDocument();
expect(screen.getByText('Hide panel header')).toBeInTheDocument();
// No collapsible wrapper of its own.
expect(screen.queryByText('Panel header')).not.toBeInTheDocument();
});

View File

@@ -0,0 +1,80 @@
.row {
display: flex;
align-items: center;
gap: 16px;
}
.grid {
display: grid;
flex: none;
grid-template-columns: repeat(3, 28px);
gap: 4px;
padding: 6px;
border: 1px solid var(--l1-border);
border-radius: 4px;
background: var(--l2-background);
}
.cell {
position: relative;
display: grid;
place-items: center;
width: 28px;
height: 22px;
padding: 0;
border: 1px solid transparent;
border-radius: 3px;
background: transparent;
cursor: pointer;
&:hover {
background: var(--l3-background);
}
&:has(:focus-visible) {
outline: 1px solid var(--bg-robin-500);
}
}
.input {
position: absolute;
inset: 0;
margin: 0;
opacity: 0;
cursor: pointer;
}
.mark {
width: 10px;
height: 3px;
border-radius: 2px;
background: var(--l3-foreground);
}
.selected {
border-color: var(--bg-robin-500);
background: color-mix(in srgb, var(--bg-robin-500) 12%, transparent);
.mark {
width: 14px;
background: var(--bg-robin-400);
}
}
.text {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.label {
font-size: 14px;
color: var(--l1-foreground);
}
.description {
font-size: 12px;
line-height: 17px;
color: var(--l2-foreground);
}

View File

@@ -0,0 +1,64 @@
import { useId } from 'react';
import cx from 'classnames';
import { type Alignment, ALIGNMENT_CELLS, alignmentLabel } from './alignment';
import styles from './AlignmentGrid.module.scss';
interface AlignmentGridProps {
testId: string;
value: Alignment;
description?: string;
onChange: (next: Alignment) => void;
}
function AlignmentGrid({
testId,
value,
description,
onChange,
}: AlignmentGridProps): JSX.Element {
const name = useId();
return (
<div className={styles.row}>
<div
role="radiogroup"
aria-label="Text position"
data-testid={testId}
className={styles.grid}
>
{ALIGNMENT_CELLS.map((cell) => {
const label = alignmentLabel(cell);
const selected =
cell.textAlign === value.textAlign &&
cell.verticalAlign === value.verticalAlign;
return (
<label
key={label}
title={label}
data-testid={`${testId}-${cell.verticalAlign}-${cell.textAlign}`}
className={cx(styles.cell, { [styles.selected]: selected })}
>
<input
type="radio"
className={styles.input}
name={name}
checked={selected}
aria-label={label}
onChange={(): void => onChange(cell)}
/>
<span className={styles.mark} />
</label>
);
})}
</div>
<div className={styles.text}>
<span className={styles.label}>{alignmentLabel(value)}</span>
{description && <span className={styles.description}>{description}</span>}
</div>
</div>
);
}
export default AlignmentGrid;

View File

@@ -0,0 +1,37 @@
import {
DashboardtypesTextAlignDTO,
DashboardtypesVerticalAlignDTO,
} from 'api/generated/services/sigNoz.schemas';
const VERTICAL_LABELS: Record<DashboardtypesVerticalAlignDTO, string> = {
[DashboardtypesVerticalAlignDTO.top]: 'Top',
[DashboardtypesVerticalAlignDTO.center]: 'Middle',
[DashboardtypesVerticalAlignDTO.bottom]: 'Bottom',
};
const HORIZONTAL_LABELS: Record<DashboardtypesTextAlignDTO, string> = {
[DashboardtypesTextAlignDTO.left]: 'left',
[DashboardtypesTextAlignDTO.center]: 'center',
[DashboardtypesTextAlignDTO.right]: 'right',
};
export interface Alignment {
textAlign: DashboardtypesTextAlignDTO;
verticalAlign: DashboardtypesVerticalAlignDTO;
}
export const ALIGNMENT_CELLS: Alignment[] = Object.values(
DashboardtypesVerticalAlignDTO,
).flatMap((verticalAlign) =>
Object.values(DashboardtypesTextAlignDTO).map((textAlign) => ({
textAlign,
verticalAlign,
})),
);
export function alignmentLabel({
textAlign,
verticalAlign,
}: Alignment): string {
return `${VERTICAL_LABELS[verticalAlign]} ${HORIZONTAL_LABELS[textAlign]}`;
}

View File

@@ -1,119 +0,0 @@
import { Fragment } from 'react';
import { Check } from '@signozhq/icons';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import cx from 'classnames';
import {
TEXT_BACKGROUND_PAIRS,
TEXT_BACKGROUND_PRESETS,
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/presets';
import type {
PanelTheme,
TextBackgroundPreset,
TextBackgroundSelection,
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/types';
import { TextBackgroundKind } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/types';
import styles from './BackgroundSwatches.module.scss';
const PRESET_TITLES: Record<TextBackgroundPreset, string> = {
robin: 'Robin',
purple: 'Purple',
sakura: 'Sakura',
cherry: 'Cherry',
amber: 'Amber',
forest: 'Forest',
sienna: 'Sienna',
slate: 'Slate',
};
type BaseSelection = TextBackgroundKind.None | TextBackgroundKind.Default;
const BASE_TITLES: Record<BaseSelection, string> = {
none: 'Transparent',
default: 'Default panel',
};
/** Neither base swatch shows a colour, so its tooltip says what it does. */
const BASE_TOOLTIPS: Record<BaseSelection, string> = {
none: 'Transparent — no card, border or title bar',
default: 'Default panel colour',
};
const OPTIONS: TextBackgroundSelection[] = [
TextBackgroundKind.None,
TextBackgroundKind.Default,
...TEXT_BACKGROUND_PRESETS,
];
const DIVIDER_AFTER = 1;
interface BackgroundSwatchesProps {
testId: string;
/** Names the group for assistive tech — the row carries no visible label. */
label: string;
/** `undefined` while a custom colour is active: no swatch is selected. */
value: TextBackgroundSelection | undefined;
/** Swatches paint in this theme's pair, so what the user picks is what they see. */
theme: PanelTheme;
onChange: (value: TextBackgroundSelection) => void;
}
/**
* The Text panel's background choices as one radio group. Native radios sharing a
* `name`, so arrow-key movement, the single tab stop and selection-follows-focus
* are the platform's; each input is transparent and fills its swatch.
*/
function BackgroundSwatches({
testId,
label,
value,
theme,
onChange,
}: BackgroundSwatchesProps): JSX.Element {
return (
<div
className={styles.row}
role="radiogroup"
aria-label={label}
data-testid={testId}
>
{OPTIONS.map((option, index) => {
const isBase =
option === TextBackgroundKind.None ||
option === TextBackgroundKind.Default;
const pair = isBase ? undefined : TEXT_BACKGROUND_PAIRS[option][theme];
const title = isBase ? BASE_TITLES[option] : PRESET_TITLES[option];
return (
<Fragment key={option}>
<TooltipSimple title={isBase ? BASE_TOOLTIPS[option] : title} arrow>
<label
className={cx(styles.swatch, {
[styles.checkerboard]: option === TextBackgroundKind.None,
[styles.defaultSurface]: option === TextBackgroundKind.Default,
[styles.selected]: option === value,
})}
style={pair ? { background: pair.surface, color: pair.ink } : undefined}
data-testid={`${testId}-${option}`}
>
<input
type="radio"
className={styles.input}
name={testId}
value={option}
checked={option === value}
aria-label={title}
onChange={(): void => onChange(option)}
/>
{option === value && <Check size={14} />}
</label>
</TooltipSimple>
{index === DIVIDER_AFTER && <span className={styles.divider} />}
</Fragment>
);
})}
</div>
);
}
export default BackgroundSwatches;

View File

@@ -1,67 +0,0 @@
.row {
display: flex;
width: 100%;
align-items: center;
gap: 10px;
padding: 8px 10px;
border: 1px solid var(--l2-border);
border-radius: 4px;
background: transparent;
cursor: pointer;
text-align: left;
}
.active {
border-color: var(--bg-robin-500);
}
.chip {
flex: none;
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--l2-border);
border-radius: 4px;
}
// No colour to show yet, so the chip advertises that it opens a picker.
.chipEmpty {
background: conic-gradient(
from 0deg,
var(--bg-cherry-400),
var(--bg-amber-400),
var(--bg-forest-400),
var(--bg-robin-400),
var(--bg-sakura-400),
var(--bg-cherry-400)
);
}
.label {
flex: 1;
font-size: 12px;
color: var(--l2-foreground);
}
.hex {
font-family: var(--font-family-sf-mono);
font-size: 12px;
color: var(--text-vanilla-400);
letter-spacing: 0.02em;
}
// Appended under the picker's own panel.
.contrast {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 4px 2px;
font-size: 12px;
color: var(--text-vanilla-400);
}
.warning {
color: var(--bg-amber-400);
}

View File

@@ -1,91 +0,0 @@
import type { ReactNode } from 'react';
import { Check, ChevronDown, TriangleAlert } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import { ColorPicker } from 'antd';
import cx from 'classnames';
import {
contrastRatio,
inkForSurface,
MIN_CONTRAST_RATIO,
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/contrast';
import styles from './CustomBackgroundRow.module.scss';
const HEX_PLACEHOLDER = '#______';
/** What the picker opens on before a colour is chosen. */
const INITIAL_COLOR = '#3A2A63';
interface CustomBackgroundRowProps {
testId: string;
/** The stored hex while a custom colour is active; `undefined` otherwise. */
value: string | undefined;
onChange: (hex: string) => void;
}
/**
* The custom colour, as a row rather than a swatch: it opens a picker instead of
* committing a value in one click. The picker warns below the contrast floor but
* never blocks the choice.
*/
function CustomBackgroundRow({
testId,
value,
onChange,
}: CustomBackgroundRowProps): JSX.Element {
const color = value ?? INITIAL_COLOR;
const ratio = contrastRatio(inkForSurface(color), color);
const isLegible = ratio >= MIN_CONTRAST_RATIO;
const contrastMessage = isLegible
? `Contrast ${ratio.toFixed(1)}:1`
: `Contrast ${ratio.toFixed(1)}:1 — below ${MIN_CONTRAST_RATIO}:1`;
function renderPanel(panel: ReactNode): ReactNode {
return (
<>
{panel}
<div
className={cx(styles.contrast, { [styles.warning]: !isLegible })}
data-testid={`${testId}-contrast`}
>
{!isLegible && <TriangleAlert size={12} />}
<span className="translate-safe">{contrastMessage}</span>
</div>
</>
);
}
return (
<ColorPicker
value={color}
size="small"
showText={false}
trigger="click"
panelRender={renderPanel}
onChangeComplete={(next): void => onChange(next.toHexString())}
>
<button
type="button"
className={cx(styles.row, { [styles.active]: value !== undefined })}
data-testid={testId}
>
<span
className={cx(styles.chip, { [styles.chipEmpty]: value === undefined })}
style={
value ? { background: value, color: inkForSurface(value) } : undefined
}
>
{value !== undefined && <Check size={14} />}
</span>
<Typography.Text className={styles.label}>Custom</Typography.Text>
<span className={cx(styles.hex, 'translate-safe')}>
{value ?? HEX_PLACEHOLDER}
</span>
<ChevronDown size={14} />
</button>
</ColorPicker>
);
}
export default CustomBackgroundRow;

View File

@@ -1,118 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { TEXT_BACKGROUND_PAIRS } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/presets';
import {
PanelTheme,
TextBackgroundKind,
TextBackgroundPreset,
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/types';
import BackgroundSwatches from '../BackgroundSwatches';
function renderRow(
props: Partial<React.ComponentProps<typeof BackgroundSwatches>> = {},
): jest.Mock {
const onChange = jest.fn();
render(
<TooltipProvider>
<BackgroundSwatches
testId="background"
label="Panel background"
theme={PanelTheme.Dark}
value={TextBackgroundKind.Default}
onChange={onChange}
{...props}
/>
</TooltipProvider>,
);
return onChange;
}
describe('BackgroundSwatches', () => {
it('offers transparent, the default surface and the eight presets in order', () => {
renderRow();
expect(
screen
.getAllByRole('radio')
.map((swatch) => swatch.getAttribute('aria-label')),
).toStrictEqual([
'Transparent',
'Default panel',
'Robin',
'Purple',
'Sakura',
'Cherry',
'Amber',
'Forest',
'Sienna',
'Slate',
]);
});
it('is one labelled group', () => {
renderRow();
expect(
screen.getByRole('radiogroup', { name: 'Panel background' }),
).toBeInTheDocument();
});
it.each([
['background-none', 'Transparent — no card, border or title bar'],
['background-default', 'Default panel colour'],
['background-sakura', 'Sakura'],
])('explains %s on hover', async (swatchId, copy) => {
renderRow();
fireEvent.focus(screen.getByTestId(swatchId));
await waitFor(() => {
expect(screen.getByRole('tooltip')).toHaveTextContent(copy);
});
});
it('paints each preset in the given theme', () => {
renderRow({ theme: PanelTheme.Light });
expect(screen.getByTestId('background-amber')).toHaveStyle({
background: TEXT_BACKGROUND_PAIRS.amber.light.surface,
color: TEXT_BACKGROUND_PAIRS.amber.light.ink,
});
});
it('marks only the selected swatch, and checks it', () => {
renderRow({ value: TextBackgroundPreset.Forest });
expect(screen.getByRole('radio', { name: 'Forest' })).toBeChecked();
expect(
screen.getByRole('radio', { name: 'Default panel' }),
).not.toBeChecked();
expect(
screen.getByTestId('background-forest').querySelector('svg'),
).toBeInTheDocument();
expect(
screen.getByTestId('background-default').querySelector('svg'),
).not.toBeInTheDocument();
});
it('reports the swatch that was clicked', () => {
const onChange = renderRow();
fireEvent.click(screen.getByRole('radio', { name: 'Sienna' }));
expect(onChange).toHaveBeenCalledWith('sienna');
});
// jsdom does not implement radio arrow navigation, so the shared name — what
// makes them one group — is what there is to assert.
it('groups every swatch under one radio name', () => {
renderRow();
const names = new Set(
screen.getAllByRole('radio').map((swatch) => swatch.getAttribute('name')),
);
expect(names).toStrictEqual(new Set(['background']));
});
});

View File

@@ -1,77 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react';
import CustomBackgroundRow from '../CustomBackgroundRow';
function renderRow(value?: string): jest.Mock {
const onChange = jest.fn();
render(
<CustomBackgroundRow testId="custom" value={value} onChange={onChange} />,
);
return onChange;
}
describe('CustomBackgroundRow', () => {
it('stands in for the hex while no custom colour is set', () => {
renderRow();
expect(screen.getByTestId('custom')).toHaveTextContent('#______');
});
it('shows the stored hex once one is set', () => {
renderRow('#3A2A63');
expect(screen.getByTestId('custom')).toHaveTextContent('#3A2A63');
});
it('checks the chip only while the custom colour is the selection', () => {
renderRow('#3A2A63');
expect(screen.getByTestId('custom').querySelector('svg')).toBeInTheDocument();
});
it('leaves the chip unchecked while no custom colour is set', () => {
renderRow();
expect(screen.getByTestId('custom').querySelectorAll('svg')).toHaveLength(1);
});
describe('the picker', () => {
it('opens on the row', () => {
renderRow('#3A2A63');
fireEvent.click(screen.getByTestId('custom'));
expect(screen.getByTestId('custom-contrast')).toBeInTheDocument();
});
it('reports the contrast the colour achieves', () => {
renderRow('#3A2A63');
fireEvent.click(screen.getByTestId('custom'));
// The derived ink is pure white, not purple's paired ink.
expect(screen.getByTestId('custom-contrast')).toHaveTextContent(
'Contrast 12.4:1',
);
});
it('warns when no ink clears the floor, without disabling anything', () => {
renderRow('#808080');
fireEvent.click(screen.getByTestId('custom'));
expect(screen.getByTestId('custom-contrast')).toHaveTextContent(
'below 4.5:1',
);
expect(screen.getByTestId('custom')).toBeEnabled();
});
it('says nothing about the floor when the colour clears it', () => {
renderRow('#111111');
fireEvent.click(screen.getByTestId('custom'));
expect(screen.getByTestId('custom-contrast')).not.toHaveTextContent('below');
});
});
});

View File

@@ -0,0 +1,7 @@
.dot {
flex: none;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--bg-robin-500);
}

View File

@@ -0,0 +1,18 @@
import styles from './ChangedDot.module.scss';
function ChangedDot({
title = 'Changed from default',
}: {
title?: string;
}): JSX.Element {
return (
<span
className={styles.dot}
title={title}
aria-label={title}
data-testid="config-changed-dot"
/>
);
}
export default ChangedDot;

View File

@@ -1,32 +1,34 @@
.row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
gap: 8px;
}
.group {
display: contents;
}
.swatch {
position: relative;
display: block;
width: 24px;
height: 24px;
flex: none;
width: 26px;
height: 26px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--l2-border);
border-radius: 4px;
background: none;
cursor: pointer;
position: relative;
// The input carries focus, so the ring is drawn on the swatch around it.
&:has(.input:focus-visible) {
&:has(.input:focus-visible),
&:focus-visible {
outline: 2px solid var(--bg-robin-400);
outline-offset: 1px;
outline-offset: 2px;
}
}
// The real control, sized to the swatch and invisible over it: clicks and focus
// land on the radio, the swatch is what the user sees.
// Clicks and focus land on the radio; the swatch is what the user sees.
.input {
position: absolute;
inset: 0;
@@ -35,12 +37,13 @@
cursor: pointer;
}
.selected {
box-shadow: 0 0 0 2px var(--bg-robin-500);
.selected,
.selected:has(.input:focus-visible) {
outline: 2px solid var(--l1-foreground);
outline-offset: 2px;
}
// Transparency has no colour to show, so it reads as the conventional checkerboard.
.checkerboard {
.transparent {
background-color: var(--l2-background);
background-image:
linear-gradient(
@@ -63,14 +66,29 @@
4px 4px;
}
.defaultSurface {
.surface {
background: var(--l2-background);
}
.custom {
background: conic-gradient(
from 0deg,
var(--bg-cherry-400),
var(--bg-amber-400),
var(--bg-forest-400),
var(--bg-robin-400),
var(--bg-sakura-400),
var(--bg-cherry-400)
);
}
.customAnchor {
display: inline-flex;
}
.divider {
flex: none;
width: 1px;
height: 18px;
margin: 0 2px;
background: var(--l2-border);
}

View File

@@ -0,0 +1,80 @@
import { Fragment, type ReactNode } from 'react';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import cx from 'classnames';
import CustomColorSwatch from './CustomColorSwatch';
import styles from './ColorSwatches.module.scss';
export interface ColorSwatchOption<T extends string> {
value: T;
id: string;
label: string;
tooltip?: string;
fill?: string;
pattern?: 'transparent' | 'surface';
}
interface CustomColor {
/** The active custom color; `undefined` while a preset is selected. */
value: string | undefined;
initial: string;
onChange: (hex: string) => void;
pickerFooter?: (hex: string) => ReactNode;
}
interface ColorSwatchesProps<T extends string> {
testId: string;
label: string;
value: T | undefined;
options: ColorSwatchOption<T>[];
dividerAfter?: number;
onChange: (value: T) => void;
custom: CustomColor;
}
function ColorSwatches<T extends string>({
testId,
label,
value,
options,
dividerAfter,
onChange,
custom,
}: ColorSwatchesProps<T>): JSX.Element {
return (
<div className={styles.row} data-testid={testId}>
<div role="radiogroup" aria-label={label} className={styles.group}>
{options.map((option, index) => (
<Fragment key={option.id}>
<TooltipSimple title={option.tooltip ?? option.label} arrow>
<label
className={cx(styles.swatch, {
[styles.transparent]: option.pattern === 'transparent',
[styles.surface]: option.pattern === 'surface',
[styles.selected]: option.value === value,
})}
style={option.fill ? { background: option.fill } : undefined}
data-testid={`${testId}-${option.id}`}
>
<input
type="radio"
className={styles.input}
name={testId}
value={option.value}
checked={option.value === value}
aria-label={option.label}
onChange={(): void => onChange(option.value)}
/>
</label>
</TooltipSimple>
{index === dividerAfter && <span className={styles.divider} />}
</Fragment>
))}
</div>
<CustomColorSwatch testId={`${testId}-custom`} {...custom} />
</div>
);
}
export default ColorSwatches;

View File

@@ -0,0 +1,59 @@
import type { ReactNode } from 'react';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { ColorPicker } from 'antd';
import cx from 'classnames';
import styles from './ColorSwatches.module.scss';
interface CustomColorSwatchProps {
testId: string;
value: string | undefined;
initial: string;
onChange: (hex: string) => void;
pickerFooter?: (hex: string) => ReactNode;
}
function CustomColorSwatch({
testId,
value,
initial,
onChange,
pickerFooter,
}: CustomColorSwatchProps): JSX.Element {
const color = value ?? initial;
const title = value ? `Custom ${value.toUpperCase()}` : 'Custom color';
return (
<ColorPicker
value={color}
trigger="click"
panelRender={
pickerFooter
? (panel): ReactNode => (
<>
{panel}
{pickerFooter(color)}
</>
)
: undefined
}
onChangeComplete={(next): void => onChange(next.toHexString())}
>
<span className={styles.customAnchor}>
<TooltipSimple title={title} arrow>
<button
type="button"
aria-label={title}
aria-pressed={value !== undefined}
className={cx(styles.swatch, styles.custom, {
[styles.selected]: value !== undefined,
})}
data-testid={testId}
/>
</TooltipSimple>
</span>
</ColorPicker>
);
}
export default CustomColorSwatch;

View File

@@ -0,0 +1,135 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import ColorSwatches, { type ColorSwatchOption } from '../ColorSwatches';
const OPTIONS: ColorSwatchOption<string>[] = [
{ value: 'none', id: 'none', label: 'Transparent', pattern: 'transparent' },
{
value: 'default',
id: 'default',
label: 'Default panel',
tooltip: 'Default panel colour',
pattern: 'surface',
},
{ value: '#ff0000', id: 'red', label: 'Red', fill: '#ff0000' },
{ value: '#00ff00', id: 'green', label: 'Green', fill: '#00ff00' },
];
function renderSwatches(
props: Partial<React.ComponentProps<typeof ColorSwatches<string>>> = {},
): { onChange: jest.Mock; onCustom: jest.Mock } {
const onChange = jest.fn();
const onCustom = jest.fn();
render(
<TooltipProvider>
<ColorSwatches
testId="color"
label="Color"
value="default"
options={OPTIONS}
dividerAfter={1}
onChange={onChange}
custom={{
value: undefined,
initial: '#3A2A63',
onChange: onCustom,
pickerFooter: (hex) => <span data-testid="footer">{hex}</span>,
}}
{...props}
/>
</TooltipProvider>,
);
return { onChange, onCustom };
}
describe('ColorSwatches', () => {
it('is one labelled radio group, in option order', () => {
renderSwatches();
expect(screen.getByRole('radiogroup', { name: 'Color' })).toBeInTheDocument();
expect(
screen.getAllByRole('radio').map((r) => r.getAttribute('aria-label')),
).toStrictEqual(['Transparent', 'Default panel', 'Red', 'Green']);
});
it('paints each swatch its fill', () => {
renderSwatches();
expect(screen.getByTestId('color-red')).toHaveStyle({
background: '#ff0000',
});
});
it('checks only the selected swatch', () => {
renderSwatches({ value: '#00ff00' });
expect(screen.getByRole('radio', { name: 'Green' })).toBeChecked();
expect(screen.getByRole('radio', { name: 'Red' })).not.toBeChecked();
});
it('reports the swatch that was clicked', async () => {
const user = userEvent.setup();
const { onChange } = renderSwatches();
await user.click(screen.getByRole('radio', { name: 'Red' }));
expect(onChange).toHaveBeenCalledWith('#ff0000');
});
it.each([
['color-default', 'Default panel colour'],
['color-red', 'Red'],
])('explains %s on hover', async (swatchId, copy) => {
const user = userEvent.setup();
renderSwatches();
await user.hover(screen.getByTestId(swatchId));
await waitFor(() => {
expect(screen.getByRole('tooltip')).toHaveTextContent(copy);
});
});
describe('the custom swatch', () => {
it('is pressed only while a custom color is active', () => {
renderSwatches();
expect(screen.getByTestId('color-custom')).toHaveAttribute(
'aria-pressed',
'false',
);
});
it('names the active custom color', () => {
renderSwatches({
value: undefined,
custom: { value: '#3a2a64', initial: '#000000', onChange: jest.fn() },
});
expect(
screen.getByRole('button', { name: 'Custom #3A2A64' }),
).toHaveAttribute('aria-pressed', 'true');
});
it('opens a picker with the footer, starting from the initial color', async () => {
const user = userEvent.setup();
renderSwatches();
await user.click(screen.getByTestId('color-custom'));
expect(screen.getByTestId('footer')).toHaveTextContent('#3A2A63');
});
it('reports the color picked', async () => {
const user = userEvent.setup();
const { onCustom } = renderSwatches();
await user.click(screen.getByTestId('color-custom'));
await user.clear(screen.getByRole('textbox'));
await user.type(screen.getByRole('textbox'), '3A2A64');
expect(onCustom).toHaveBeenCalledWith('#3a2a64');
});
});
});

View File

@@ -0,0 +1,48 @@
.field {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 18px;
}
.label {
font-family: 'Geist Mono', monospace;
font-size: 12px;
letter-spacing: 0.1em;
text-transform: uppercase;
white-space: nowrap;
color: var(--l2-foreground);
}
.plainLabel {
font-size: 13px;
color: var(--l1-foreground);
}
.reset {
padding: 0;
border: 0;
background: transparent;
font: inherit;
font-size: 12px;
color: var(--bg-robin-400);
cursor: pointer;
&:hover {
color: var(--bg-robin-300);
}
}
.help {
font-size: 12px;
line-height: 17px;
color: var(--l2-foreground);
}

View File

@@ -0,0 +1,48 @@
import type { ReactNode } from 'react';
import cx from 'classnames';
import styles from './ConfigField.module.scss';
interface ConfigFieldProps {
label: ReactNode;
/** Shown under the control. */
help?: ReactNode;
changed?: boolean;
onReset?: () => void;
/** Plain body label instead of the uppercase field heading. */
plain?: boolean;
className?: string;
children: ReactNode;
}
function ConfigField({
label,
help,
changed,
onReset,
plain,
className,
children,
}: ConfigFieldProps): JSX.Element {
return (
<div className={cx(styles.field, className)}>
<div className={styles.header}>
<span className={plain ? styles.plainLabel : styles.label}>{label}</span>
{changed && onReset && (
<button
type="button"
className={styles.reset}
data-testid="config-field-reset"
onClick={onReset}
>
Reset
</button>
)}
</div>
{children}
{help && <span className={styles.help}>{help}</span>}
</div>
);
}
export default ConfigField;

View File

@@ -0,0 +1,5 @@
.row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
gap: 10px;
}

View File

@@ -0,0 +1,10 @@
import type { ReactNode } from 'react';
import styles from './ConfigFieldRow.module.scss';
/** Side-by-side fields that wrap when the pane is narrow. */
function ConfigFieldRow({ children }: { children: ReactNode }): JSX.Element {
return <div className={styles.row}>{children}</div>;
}
export default ConfigFieldRow;

View File

@@ -0,0 +1,24 @@
.row {
display: grid;
grid-template-columns: 76px minmax(0, 1fr);
align-items: center;
gap: 12px;
}
// Matches ConfigField's heading.
.label {
font-family: 'Geist Mono', monospace;
font-size: 12px;
letter-spacing: 0.1em;
text-transform: uppercase;
white-space: nowrap;
color: var(--l2-foreground);
}
.help {
grid-column: 2;
margin-top: -4px;
font-size: 12px;
line-height: 17px;
color: var(--l2-foreground);
}

View File

@@ -0,0 +1,45 @@
import type { ReactNode } from 'react';
import { Callout } from '@signozhq/ui/callout';
import styles from './ConfigInlineField.module.scss';
interface ConfigInlineFieldProps {
label: string;
/** Shown under the control. */
help?: ReactNode;
helpTestId?: string;
/** Shown in place of the help. */
error?: ReactNode;
children: ReactNode;
}
/** A field whose heading sits beside the control rather than above it. */
function ConfigInlineField({
label,
help,
helpTestId,
error,
children,
}: ConfigInlineFieldProps): JSX.Element {
return (
<div className={styles.row}>
<span className={styles.label}>{label}</span>
{children}
{error ? (
<div className={styles.help} data-testid={helpTestId}>
<Callout type="error" size="small" showIcon>
{error}
</Callout>
</div>
) : (
help && (
<span className={styles.help} data-testid={helpTestId}>
{help}
</span>
)
)}
</div>
);
}
export default ConfigInlineField;

View File

@@ -0,0 +1,50 @@
import type { ChangeEvent } from 'react';
import { Input } from 'antd';
import ConfigField from '../ConfigField/ConfigField';
interface ConfigNumberInputProps {
testId: string;
/** Omitted for an input whose row already names it. */
label?: string;
placeholder?: string;
invalid?: boolean;
value: number | null | undefined;
onChange: (next: number | null) => void;
}
/** Numeric input where empty means "auto"; transient non-numeric input (e.g. "-") clears. */
function ConfigNumberInput({
testId,
label,
placeholder = 'Auto',
invalid,
value,
onChange,
}: ConfigNumberInputProps): JSX.Element {
const handleChange = (e: ChangeEvent<HTMLInputElement>): void => {
const raw = e.target.value;
onChange(raw === '' || Number.isNaN(Number(raw)) ? null : Number(raw));
};
const input = (
<Input
data-testid={testId}
type="number"
placeholder={placeholder}
status={invalid ? 'error' : undefined}
value={value ?? ''}
onChange={handleChange}
/>
);
return label ? (
<ConfigField label={label} plain>
{input}
</ConfigField>
) : (
input
);
}
export default ConfigNumberInput;

View File

@@ -0,0 +1,10 @@
.range {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: 10px;
}
.dash {
color: var(--l3-foreground);
}

View File

@@ -0,0 +1,46 @@
import ConfigNumberInput from '../ConfigNumberInput/ConfigNumberInput';
import { isRangeInverted } from './utils';
import styles from './ConfigRangeInput.module.scss';
interface ConfigRangeInputProps {
/** Prefix for the `-soft-min` / `-soft-max` test ids. */
testIdPrefix: string;
min: number | null | undefined;
max: number | null | undefined;
onChangeMin: (next: number | null) => void;
onChangeMax: (next: number | null) => void;
}
function ConfigRangeInput({
testIdPrefix,
min,
max,
onChangeMin,
onChangeMax,
}: ConfigRangeInputProps): JSX.Element {
const inverted = isRangeInverted(min, max);
return (
<div className={styles.range}>
<ConfigNumberInput
testId={`${testIdPrefix}-soft-min`}
placeholder="Min"
invalid={inverted}
value={min}
onChange={onChangeMin}
/>
<span className={styles.dash}>–</span>
<ConfigNumberInput
testId={`${testIdPrefix}-soft-max`}
placeholder="Max"
invalid={inverted}
value={max}
onChange={onChangeMax}
/>
</div>
);
}
export default ConfigRangeInput;

View File

@@ -0,0 +1,14 @@
import { isRangeInverted } from '../utils';
describe('isRangeInverted', () => {
it.each([
[23, 12, true],
[12, 23, false],
[5, 5, false],
[-10, -20, true],
[null, 12, false],
[23, undefined, false],
])('min %p, max %p → %p', (min, max, expected) => {
expect(isRangeInverted(min, max)).toBe(expected);
});
});

View File

@@ -0,0 +1,6 @@
export function isRangeInverted(
min: number | null | undefined,
max: number | null | undefined,
): boolean {
return min != null && max != null && min > max;
}

View File

@@ -1,10 +0,0 @@
.group {
width: 100%;
}
.segment {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}

View File

@@ -1,59 +0,0 @@
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { SegmentIcon, type SegmentIconName } from '../segmentIcons';
import styles from './ConfigSegmented.module.scss';
export interface ConfigSegmentedItem {
value: string;
label: string;
icon?: SegmentIconName;
}
interface ConfigSegmentedProps<T extends string = string> {
testId: string;
value: T | undefined;
items: ConfigSegmentedItem[];
onChange: (value: T) => void;
}
/**
* Inline segmented control for short option sets in the config pane (line style, fill
* mode, axis scale, legend position). Each segment carries an optional muted glyph that
* brightens with the selected state (it inherits the toggle's `currentColor`). Built on
* the Periscope ToggleGroup so it stays theme-faithful.
*/
function ConfigSegmented<T extends string = string>({
testId,
value,
items,
onChange,
}: ConfigSegmentedProps<T>): JSX.Element {
return (
<ToggleGroupSimple
type="single"
testId={testId}
className={styles.group}
value={value}
items={items.map((item) => ({
value: item.value,
'aria-label': item.label,
label: (
<span className={styles.segment}>
{item.icon && <SegmentIcon name={item.icon} />}
{item.label}
</span>
),
}))}
// Single toggle-groups emit '' when the active segment is re-clicked; ignore that
// so a required choice (e.g. scale, position) can't be cleared to an empty value.
onChange={(next: T): void => {
if (next) {
onChange(next);
}
}}
/>
);
}
export default ConfigSegmented;

View File

@@ -1,30 +1,31 @@
.card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border: 1px solid var(--l2-border);
border-radius: 2px;
background: var(--l2-background-60);
gap: 14px;
padding: 14px 16px;
border: 1px solid var(--l1-border);
border-radius: 4px;
background: var(--l2-background);
}
.text {
display: flex;
flex: 1;
flex-direction: column;
gap: 2px;
gap: 3px;
min-width: 0;
}
.title {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--l2-foreground);
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--l1-foreground);
}
.description {
font-size: 12px;
color: var(--l3-foreground);
line-height: 17px;
color: var(--l2-foreground);
}

View File

@@ -1,39 +1,38 @@
import type { ReactNode } from 'react';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import ChangedDot from '../ChangedDot/ChangedDot';
import styles from './ConfigSwitch.module.scss';
interface ConfigSwitchProps {
testId: string;
/** Shown uppercased as the card title. */
title: string;
/** Optional helper line under the title. */
description?: string;
sketch?: ReactNode;
changed?: boolean;
value: boolean;
onChange: (checked: boolean) => void;
}
/**
* Boolean toggle rendered as a bordered card: an uppercase title with an optional
* description on the left and a Switch on the right. The standard presentation for
* on/off panel-config controls (e.g. "Show points").
*/
function ConfigSwitch({
testId,
title,
description,
sketch,
changed,
value,
onChange,
}: ConfigSwitchProps): JSX.Element {
return (
<div className={styles.card}>
{sketch}
<div className={styles.text}>
<span className={styles.title}>{title}</span>
{description && (
<Typography.Text className={styles.description}>
{description}
</Typography.Text>
)}
<span className={styles.title}>
{title}
{changed && <ChangedDot />}
</span>
{description && <span className={styles.description}>{description}</span>}
</div>
<Switch testId={testId} value={value} onChange={onChange} />
</div>

View File

@@ -0,0 +1,110 @@
.container {
container-type: inline-size;
}
.grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 8px;
}
.tile {
position: relative;
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
height: 52px;
padding: 0 14px;
border: 1px solid var(--l1-border);
border-radius: 4px;
background: var(--l2-background);
color: var(--l2-foreground);
font: inherit;
font-size: 13px;
cursor: pointer;
transition:
border-color 150ms,
background 150ms;
&:hover {
border-color: var(--l2-border);
}
&:has(:focus-visible) {
outline: 1px solid var(--bg-robin-500);
outline-offset: 1px;
}
}
.input {
position: absolute;
inset: 0;
margin: 0;
opacity: 0;
cursor: pointer;
}
.selected {
border-color: var(--bg-robin-500);
background: color-mix(in srgb, var(--bg-robin-500) 12%, var(--l1-background));
color: var(--l1-foreground);
&:hover {
border-color: var(--bg-robin-500);
}
}
.label {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
@mixin stacked-tiles($columns) {
grid-template-columns: repeat($columns, minmax(0, 1fr));
.tile {
flex-direction: column;
justify-content: center;
gap: 10px;
height: 88px;
padding: 0 4px;
}
}
@container (min-width: 200px) {
.grid[data-count='2'] {
@include stacked-tiles(2);
}
}
@container (min-width: 216px) {
.grid[data-count='3'] {
@include stacked-tiles(3);
}
}
@container (min-width: 220px) {
.grid[data-count='4'] {
@include stacked-tiles(2);
}
}
@container (min-width: 420px) {
.grid[data-count='4'] {
@include stacked-tiles(4);
}
}
.grid.compact {
grid-auto-flow: column;
grid-auto-columns: minmax(0, 1fr);
gap: 6px;
.tile {
justify-content: center;
height: 36px;
padding: 0 4px;
}
}

View File

@@ -0,0 +1,74 @@
import { type ReactNode, useId } from 'react';
import cx from 'classnames';
import styles from './ConfigTiles.module.scss';
export interface ConfigTileItem<T extends string = string> {
value: T;
label: string;
/** Accessible name when `label` alone is ambiguous (e.g. a bare symbol). */
ariaLabel?: string;
drawing?: ReactNode;
}
interface ConfigTilesProps<T extends string> {
testId: string;
value: T | undefined;
items: ConfigTileItem<T>[];
onChange: (value: T) => void;
/** Compact text-only tiles on one row (e.g. decimal places). */
compact?: boolean;
'aria-label'?: string;
}
/**
* Single-choice tiles. Columns follow the container width: rows of 4 fold to 2×2 and
* any row too narrow for its tiles becomes a full-width list.
*/
function ConfigTiles<T extends string>({
testId,
value,
items,
onChange,
compact,
'aria-label': ariaLabel,
}: ConfigTilesProps<T>): JSX.Element {
const name = useId();
return (
<div className={styles.container}>
<div
role="radiogroup"
aria-label={ariaLabel}
data-testid={testId}
data-count={items.length}
className={cx(styles.grid, { [styles.compact]: compact })}
>
{items.map((item) => {
const selected = item.value === value;
return (
<label
key={item.value}
data-testid={`${testId}-${item.value}`}
className={cx(styles.tile, { [styles.selected]: selected })}
>
<input
type="radio"
className={styles.input}
name={name}
value={item.value}
checked={selected}
aria-label={item.ariaLabel ?? item.label}
onChange={(): void => onChange(item.value)}
/>
{item.drawing}
<span className={styles.label}>{item.label}</span>
</label>
);
})}
</div>
</div>
);
}
export default ConfigTiles;

View File

@@ -1,6 +1,5 @@
import { ColorPicker } from 'antd';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import styles from './LegendColors.module.scss';
@@ -41,9 +40,9 @@ function LegendColorRow({
data-testid={`legend-color-${label}`}
>
<span className={styles.swatch} style={{ backgroundColor: color }} />
<Typography.Text className={styles.label} title={label}>
<span className={styles.label} title={label}>
{label}
</Typography.Text>
</span>
</button>
</ColorPicker>
{isOverridden && (

View File

@@ -42,7 +42,9 @@
.label {
overflow: hidden;
font-size: 12px;
font-family: var(--font-mono);
font-size: var(--font-size-xs);
letter-spacing: -0.01em;
color: var(--l2-foreground);
white-space: nowrap;
text-overflow: ellipsis;

View File

@@ -40,7 +40,7 @@ function LegendColors({
if (series.length === 0) {
return (
<Typography.Text className={styles.empty}>
Run the panel to customise series colors.
Run the query to set a color for each series.
</Typography.Text>
);
}

View File

@@ -1,4 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
import LegendColors from '../LegendColors';
@@ -15,7 +16,7 @@ describe('LegendColors', () => {
expect(
screen.queryByTestId('panel-editor-v2-legend-colors'),
).not.toBeInTheDocument();
expect(screen.getByText(/run the panel/i)).toBeInTheDocument();
expect(screen.getByText(/run the query/i)).toBeInTheDocument();
});
it('renders the search box once series are present', () => {
@@ -28,14 +29,14 @@ describe('LegendColors', () => {
).toBeInTheDocument();
});
it('shows a no-match message when the search filters everything out', () => {
it('shows a no-match message when the search filters everything out', async () => {
const user = userEvent.setup();
render(
<LegendColors series={SERIES} value={undefined} onChange={jest.fn()} />,
);
fireEvent.change(screen.getByTestId('panel-editor-v2-legend-search'), {
target: { value: 'zzz' },
});
await user.clear(screen.getByTestId('panel-editor-v2-legend-search'));
await user.type(screen.getByTestId('panel-editor-v2-legend-search'), 'zzz');
expect(screen.getByText(/no series match/i)).toBeInTheDocument();
});

View File

@@ -0,0 +1,28 @@
import type { ReactNode } from 'react';
function LegendFrame({ children }: { children: ReactNode }): JSX.Element {
return (
<svg
width={56}
height={30}
viewBox="0 0 56 30"
fill="none"
strokeLinecap="round"
style={{ flex: 'none' }}
aria-hidden
>
<rect
x={1}
y={1}
width={54}
height={28}
rx={2}
stroke="var(--l3-border)"
strokeWidth={1.4}
/>
{children}
</svg>
);
}
export default LegendFrame;

View File

@@ -0,0 +1,22 @@
import type { ReactNode } from 'react';
function SketchSvg({ children }: { children: ReactNode }): JSX.Element {
return (
<svg
width={40}
height={22}
viewBox="0 0 40 22"
fill="none"
stroke="var(--l3-foreground)"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
style={{ flex: 'none' }}
aria-hidden
>
{children}
</svg>
);
}
export default SketchSvg;

View File

@@ -0,0 +1,22 @@
import type { ReactNode } from 'react';
function TileSvg({ children }: { children: ReactNode }): JSX.Element {
return (
<svg
width={64}
height={24}
viewBox="0 0 64 24"
fill="none"
stroke="currentColor"
strokeWidth={2.4}
strokeLinecap="round"
strokeLinejoin="round"
style={{ flex: 'none' }}
aria-hidden
>
{children}
</svg>
);
}
export default TileSvg;

View File

@@ -0,0 +1,37 @@
import SketchSvg from './SketchSvg';
/** Muted sketches for on/off cards. */
export const SWITCH_SKETCHES = {
hideHeader: (
<SketchSvg>
<rect x={3} y={3} width={34} height={16} rx={2} />
<path d="M3 8 H37" strokeDasharray="3 3" />
</SketchSvg>
),
fillGaps: (
<SketchSvg>
<path d="M3 9 L11 7 L16 19 L26 19 L30 8 L37 6" />
</SketchSvg>
),
points: (
<SketchSvg>
<path d="M4 17 L14 9 L24 13 L36 6" />
{[
[4, 17],
[14, 9],
[24, 13],
[36, 6],
].map(([cx, cy]) => (
<circle key={cx} cx={cx} cy={cy} r={2} fill="var(--l3-foreground)" />
))}
</SketchSvg>
),
combine: (
<SketchSvg>
<rect x={4} y={10} width={6} height={10} />
<rect x={13} y={4} width={6} height={16} />
<rect x={22} y={8} width={6} height={12} />
<rect x={31} y={13} width={6} height={7} />
</SketchSvg>
),
} satisfies Record<string, JSX.Element>;

View File

@@ -0,0 +1,174 @@
import LegendFrame from './LegendFrame';
import TileSvg from './TileSvg';
const FILLED = { fill: 'currentColor', stroke: 'none' } as const;
const LINE = 'M4 17 L18 9 L30 14 L44 5 L60 11';
const POINTS = [
[4, 18],
[22, 8],
[40, 14],
[58, 6],
];
function interpDrawing(path: string): JSX.Element {
return (
<TileSvg>
<path d={path} />
{POINTS.map(([cx, cy]) => (
<circle key={cx} cx={cx} cy={cy} r={2.2} {...FILLED} />
))}
</TileSvg>
);
}
export const TILE_DRAWINGS = {
lineSolid: (
<TileSvg>
<path d={LINE} />
</TileSvg>
),
lineDashed: (
<TileSvg>
<path d={LINE} strokeDasharray="5 5" />
</TileSvg>
),
interpLinear: interpDrawing('M4 18 L22 8 L40 14 L58 6'),
interpSpline: interpDrawing(
'M4 18 C 12 18, 14 8, 22 8 S 34 14, 40 14 S 52 6, 58 6',
),
interpStepBefore: interpDrawing('M4 18 V8 H22 V14 H40 V6 H58'),
interpStepAfter: interpDrawing('M4 18 H22 V8 H40 V14 H58 V6'),
fillNone: (
<TileSvg>
<path d={LINE} />
</TileSvg>
),
fillSolid: (
<TileSvg>
<path d={`${LINE} L60 22 L4 22Z`} {...FILLED} opacity={0.4} />
<path d={LINE} />
</TileSvg>
),
fillGradient: (
<TileSvg>
<defs>
<linearGradient id="config-tile-fill-gradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="currentColor" stopOpacity={0.55} />
<stop offset="1" stopColor="currentColor" stopOpacity={0} />
</linearGradient>
</defs>
<path
d={`${LINE} L60 22 L4 22Z`}
fill="url(#config-tile-fill-gradient)"
stroke="none"
/>
<path d={LINE} />
</TileSvg>
),
gapsConnect: (
<TileSvg>
<path d="M4 16 L14 11 L22 14" />
<path d="M22 14 L42 8" strokeDasharray="3 4" />
<path d="M42 8 L50 11 L60 6" />
</TileSvg>
),
gapsBreak: (
<TileSvg>
<path d="M4 16 L14 11 L22 14" />
<path d="M42 8 L50 11 L60 6" />
</TileSvg>
),
scaleLinear: (
<TileSvg>
<path d="M4 20 L60 4" />
</TileSvg>
),
scaleLog: (
<TileSvg>
<path d="M4 20 C 8 8, 20 5, 60 4" />
</TileSvg>
),
scaleAuto: (
<TileSvg>
<path d="M4 20 L60 4" strokeDasharray="3 3" />
</TileSvg>
),
scaleSymlog: (
<TileSvg>
<path d="M4 20 C 20 20, 22 12, 32 12 S 44 4, 60 4" />
</TileSvg>
),
barsSideBySide: (
<TileSvg>
<rect x={10} y={10} width={7} height={12} {...FILLED} opacity={0.5} />
<rect x={18} y={4} width={7} height={18} {...FILLED} />
<rect x={36} y={12} width={7} height={10} {...FILLED} opacity={0.5} />
<rect x={44} y={7} width={7} height={15} {...FILLED} />
</TileSvg>
),
barsStacked: (
<TileSvg>
<rect x={14} y={13} width={11} height={9} {...FILLED} opacity={0.5} />
<rect x={14} y={4} width={11} height={8} {...FILLED} />
<rect x={38} y={15} width={11} height={7} {...FILLED} opacity={0.5} />
<rect x={38} y={8} width={11} height={6} {...FILLED} />
</TileSvg>
),
areaOverlap: (
<TileSvg>
<path d="M4 15 L18 9 L32 13 L46 6 L60 9" />
<path d="M4 20 L18 15 L32 18 L46 13 L60 15" opacity={0.5} />
</TileSvg>
),
areaStacked: (
<TileSvg>
<path
d="M4 22 L4 17 L18 15 L32 16 L46 14 L60 15 L60 22Z"
{...FILLED}
opacity={0.45}
/>
<path
d="M4 17 L18 15 L32 16 L46 14 L60 15 L60 8 L46 5 L32 9 L18 6 L4 10Z"
{...FILLED}
/>
</TileSvg>
),
areaPercent: (
<TileSvg>
<path
d="M4 22 L4 12 L18 10 L32 13 L46 9 L60 11 L60 22Z"
{...FILLED}
opacity={0.45}
/>
<path d="M4 12 L18 10 L32 13 L46 9 L60 11 L60 2 L4 2Z" {...FILLED} />
</TileSvg>
),
legendBottom: (
<LegendFrame>
<path
d="M8 15 L18 10 L28 13 L38 7 L48 9"
stroke="var(--bg-robin-300)"
strokeWidth={2}
/>
<path
d="M8 23 H16 M22 23 H30 M36 23 H44"
stroke="var(--l2-foreground)"
strokeWidth={2.4}
/>
</LegendFrame>
),
legendRight: (
<LegendFrame>
<path
d="M6 20 L14 12 L22 16 L32 8"
stroke="var(--bg-robin-300)"
strokeWidth={2}
/>
<path
d="M40 9 H48 M40 15 H48 M40 21 H48"
stroke="var(--l2-foreground)"
strokeWidth={2.4}
/>
</LegendFrame>
),
} satisfies Record<string, JSX.Element>;

View File

@@ -1,145 +0,0 @@
/**
* Small glyph icons for the panel-editor segmented/select controls, ported from the
* Configure-panel design. They render at 14px and inherit `currentColor` so the
* surrounding control can dim them when unselected and brighten them when active.
*/
export type SegmentIconName =
| 'solid-line'
| 'dashed-line'
| 'fill-none'
| 'fill-solid'
| 'fill-gradient'
| 'pos-bottom'
| 'pos-right'
| 'scale-linear'
| 'scale-log'
| 'interp-linear'
| 'interp-spline'
| 'interp-step-before'
| 'interp-step-after';
function Svg({ children }: { children: React.ReactNode }): JSX.Element {
return (
<svg
width={14}
height={14}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
style={{ flex: 'none' }}
aria-hidden
>
{children}
</svg>
);
}
const FILLED = { fill: 'currentColor', stroke: 'none' } as const;
export function SegmentIcon({
name,
}: {
name: SegmentIconName;
}): JSX.Element | null {
switch (name) {
case 'solid-line':
return (
<Svg>
<path d="M2 8 H14" />
</Svg>
);
case 'dashed-line':
return (
<Svg>
<path d="M2 8 H4.5" />
<path d="M6.75 8 H9.25" />
<path d="M11.5 8 H14" />
</Svg>
);
case 'fill-none':
return (
<Svg>
<path d="M2 11 L6 6 L10 9 L14 5" />
</Svg>
);
case 'fill-solid':
return (
<Svg>
<path
d="M2 10.5 L6 5.5 L10 8.5 L14 4.5 V13.5 H2 Z"
fill="currentColor"
fillOpacity={0.85}
stroke="none"
/>
<path d="M2 10.5 L6 5.5 L10 8.5 L14 4.5" />
</Svg>
);
case 'fill-gradient':
return (
<Svg>
<path
d="M2 10.5 L6 5.5 L10 8.5 L14 4.5 V13.5 H2 Z"
fill="currentColor"
fillOpacity={0.3}
stroke="none"
/>
<path d="M2 10.5 L6 5.5 L10 8.5 L14 4.5" />
</Svg>
);
case 'pos-bottom':
return (
<Svg>
<rect x={2} y={2.5} width={12} height={9} rx={1.2} />
<rect x={2} y={9} width={12} height={2.5} {...FILLED} />
</Svg>
);
case 'pos-right':
return (
<Svg>
<rect x={2} y={2.5} width={12} height={9} rx={1.2} />
<rect x={10.5} y={2.5} width={3.5} height={9} {...FILLED} />
</Svg>
);
case 'scale-linear':
return (
<Svg>
<path d="M2.5 13 L13.5 3" />
</Svg>
);
case 'scale-log':
return (
<Svg>
<path d="M2.5 13 C5 13, 8 4.5, 13.5 3" />
</Svg>
);
case 'interp-linear':
return (
<Svg>
<path d="M2 12 L6 5 L10 9 L14 4" />
</Svg>
);
case 'interp-spline':
return (
<Svg>
<path d="M2 12 C5 3, 9 3, 14 8" />
</Svg>
);
case 'interp-step-before':
return (
<Svg>
<path d="M2 6 H6 V10 H10 V4.5 H14" />
</Svg>
);
case 'interp-step-after':
return (
<Svg>
<path d="M2 10 H6 V5 H10 V9.5 H14" />
</Svg>
);
default:
return null;
}
}

View File

@@ -16,6 +16,8 @@ export interface SectionEditorContext {
signal?: TelemetrytypesSignalDTO;
panelKind?: PanelKind;
onChangePanelKind?: (kind: PanelKind) => void;
/** Kind the panel was opened with, offered as a revert target. */
originalPanelKind?: PanelKind;
yAxisUnit?: string;
queryType?: EQueryType;
stepInterval?: number;

View File

@@ -12,21 +12,32 @@ import {
type PanelChartAppearanceSlice,
type PanelFormattingSlice,
type PanelVisualizationSlice,
type SectionControlsOf,
type SectionEditorProps,
type SectionSpecMap,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import type { SeededPluginSpec } from 'pages/DashboardPage/DashboardContainer/Panels/utils/buildPluginSpec';
import type { SectionEditorContext } from './sectionContext';
import AxesSection from './sections/AxesSection/AxesSection';
import { summarizeAxes } from './sections/AxesSection/summary';
import BucketsSection from './sections/BucketsSection/BucketsSection';
import { summarizeBuckets } from './sections/BucketsSection/summary';
import ChartAppearanceSection from './sections/ChartAppearanceSection/ChartAppearanceSection';
import { summarizeChartAppearance } from './sections/ChartAppearanceSection/summary';
import ContextLinksSection from './sections/ContextLinksSection/ContextLinksSection';
import FormattingSection from './sections/FormattingSection/FormattingSection';
import { DEFAULT_DECIMAL_PRECISION } from './sections/FormattingSection/options';
import { summarizeFormatting } from './sections/FormattingSection/summary';
import LegendSection from './sections/LegendSection/LegendSection';
import { summarizeLegend } from './sections/LegendSection/summary';
import PanelHeaderSection from './sections/PanelHeaderSection/PanelHeaderSection';
import { summarizeTextLayout } from './sections/TextLayoutSection/summary';
import TextLayoutSection from './sections/TextLayoutSection/TextLayoutSection';
import ThresholdsSection from './sections/ThresholdsSection/ThresholdsSection';
import { summarizeVisualization } from './sections/VisualizationSection/summary';
import VisualizationSection from './sections/VisualizationSection/VisualizationSection';
import { countSummary } from './utils/summary';
type PanelSpec = DashboardtypesPanelSpecDTO;
@@ -39,6 +50,14 @@ export interface SectionDescriptor<K extends SectionKind> {
Component: ComponentType<SectionEditorProps<K>>;
get: (spec: PanelSpec) => SectionSpecMap[K] | undefined;
update: (spec: PanelSpec, value: SectionSpecMap[K]) => PanelSpec;
/** Values an unset field renders with, when the kind's seed leaves it unset. */
renderedDefaults?: Partial<SectionSpecMap[K]>;
/** Collapsed-header digest of the slice. */
summarize?: (
value: SectionSpecMap[K] | undefined,
controls: SectionControlsOf<K>,
ctx: SectionEditorContext,
) => string;
}
// The plugin spec is a discriminated union over panel kinds; reading/writing a shared
@@ -78,12 +97,15 @@ export const SECTION_REGISTRY: {
getPluginSlice<PanelFormattingSlice>(spec, 'formatting'),
update: (spec, formatting): PanelSpec =>
updatePluginSlice(spec, 'formatting', formatting),
renderedDefaults: { decimalPrecision: DEFAULT_DECIMAL_PRECISION },
summarize: summarizeFormatting,
},
[SectionKind.Axes]: {
Component: AxesSection,
get: (spec): DashboardtypesAxesDTO | undefined =>
getPluginSlice<DashboardtypesAxesDTO>(spec, 'axes'),
update: (spec, axes): PanelSpec => updatePluginSlice(spec, 'axes', axes),
summarize: summarizeAxes,
},
[SectionKind.Legend]: {
Component: LegendSection,
@@ -91,6 +113,7 @@ export const SECTION_REGISTRY: {
getPluginSlice<DashboardtypesLegendDTO>(spec, 'legend'),
update: (spec, legend): PanelSpec =>
updatePluginSlice(spec, 'legend', legend),
summarize: summarizeLegend,
},
[SectionKind.ChartAppearance]: {
Component: ChartAppearanceSection,
@@ -98,6 +121,7 @@ export const SECTION_REGISTRY: {
getPluginSlice<PanelChartAppearanceSlice>(spec, 'chartAppearance'),
update: (spec, chartAppearance): PanelSpec =>
updatePluginSlice(spec, 'chartAppearance', chartAppearance),
summarize: summarizeChartAppearance,
},
[SectionKind.Visualization]: {
Component: VisualizationSection,
@@ -105,6 +129,7 @@ export const SECTION_REGISTRY: {
getPluginSlice<PanelVisualizationSlice>(spec, 'visualization'),
update: (spec, visualization): PanelSpec =>
updatePluginSlice(spec, 'visualization', visualization),
summarize: summarizeVisualization,
},
[SectionKind.Buckets]: {
Component: BucketsSection,
@@ -112,6 +137,7 @@ export const SECTION_REGISTRY: {
getPluginSlice<DashboardtypesHistogramBucketsDTO>(spec, 'histogramBuckets'),
update: (spec, buckets): PanelSpec =>
updatePluginSlice(spec, 'histogramBuckets', buckets),
summarize: summarizeBuckets,
},
[SectionKind.TextLayout]: {
Component: TextLayoutSection,
@@ -119,6 +145,7 @@ export const SECTION_REGISTRY: {
getPluginSlice<SectionSpecMap[SectionKind.TextLayout]>(spec, 'presentation'),
update: (spec, presentation): PanelSpec =>
updatePluginSlice(spec, 'presentation', presentation),
summarize: summarizeTextLayout,
},
[SectionKind.PanelHeader]: {
Component: PanelHeaderSection,
@@ -135,6 +162,7 @@ export const SECTION_REGISTRY: {
// Panel-level slice (spec.links), not under the plugin spec — no cast needed.
get: (spec): DashboardtypesLinkDTO[] => spec.links || [],
update: (spec, links): PanelSpec => ({ ...spec, links }),
summarize: (links): string => countSummary(links, 'link'),
},
// One editor for every threshold variant (label / comparison / table); the kind's
// `controls.variant` picks the row editor + element shape. All persist to the same
@@ -145,6 +173,7 @@ export const SECTION_REGISTRY: {
getPluginSlice<AnyThreshold[]>(spec, 'thresholds'),
update: (spec, thresholds): PanelSpec =>
updatePluginSlice(spec, 'thresholds', thresholds),
summarize: (thresholds): string => countSummary(thresholds, 'threshold'),
},
};
@@ -159,12 +188,19 @@ export interface ErasedSectionDescriptor {
Component: ComponentType<
{
value: unknown;
defaultValue?: unknown;
controls?: unknown;
onChange: (next: unknown) => void;
} & SectionEditorContext
>;
get: (spec: PanelSpec) => unknown;
update: (spec: PanelSpec, value: unknown) => PanelSpec;
renderedDefaults?: object;
summarize?: (
value: unknown,
controls: unknown,
ctx: SectionEditorContext,
) => string;
}
export function resolveSectionEditor(
@@ -174,3 +210,20 @@ export function resolveSectionEditor(
| ErasedSectionDescriptor
| undefined;
}
/** A slice's default, read through the same lens from the kind's seeded plugin spec. */
export function getSectionDefault(
editor: ErasedSectionDescriptor,
spec: PanelSpec,
defaults: SeededPluginSpec,
): unknown {
const seeded = editor.get({
...spec,
links: [],
plugin: { ...spec.plugin, spec: defaults },
} as PanelSpec);
if (!editor.renderedDefaults) {
return seeded;
}
return { ...editor.renderedDefaults, ...(seeded as object | undefined) };
}

View File

@@ -1,11 +1,5 @@
.bounds {
.fields {
display: flex;
gap: 8px;
}
.field {
display: flex;
flex: 1;
flex-direction: column;
gap: 8px;
gap: 14px;
}

View File

@@ -1,21 +1,14 @@
import type { ChangeEvent } from 'react';
import { Typography } from '@signozhq/ui/typography';
import { Input } from 'antd';
import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
import styles from './AxesSection.module.scss';
type SoftBound = 'softMin' | 'softMax';
const SCALE_OPTIONS = [
{ value: 'linear', label: 'Linear', icon: 'scale-linear' as const },
{ value: 'log', label: 'Log', icon: 'scale-log' as const },
];
import ConfigField from '../../controls/ConfigField/ConfigField';
import { createFieldResetter } from '../../utils/changes';
import AxisFields from './AxisFields';
import AxisRangeField from './AxisRangeField';
import AxisScaleField from './AxisScaleField';
import { LOG_SCALE_OPTIONS, SCALE_HELP } from './options';
/**
* Edits the `axes` slice of a panel spec: soft Y-axis min/max bounds and the
@@ -23,60 +16,37 @@ const SCALE_OPTIONS = [
*/
function AxesSection({
value,
defaultValue,
controls,
onChange,
}: SectionEditorProps<SectionKind.Axes>): JSX.Element {
// An empty field clears the bound (null); otherwise parse to a number, ignoring
// transient non-numeric input (e.g. a lone "-") by leaving the bound unset.
const handleBound =
(bound: SoftBound) =>
(e: ChangeEvent<HTMLInputElement>): void => {
const raw = e.target.value;
const next = raw === '' || Number.isNaN(Number(raw)) ? null : Number(raw);
onChange({ ...value, [bound]: next });
};
const reset = createFieldResetter(value, defaultValue, onChange);
return (
<>
{controls.minMax && (
<div className={styles.bounds}>
<div className={styles.field}>
<Typography.Text>Soft min</Typography.Text>
<Input
data-testid="panel-editor-v2-soft-min"
type="number"
placeholder="Auto"
value={value?.softMin ?? ''}
onChange={handleBound('softMin')}
/>
</div>
<div className={styles.field}>
<Typography.Text>Soft max</Typography.Text>
<Input
data-testid="panel-editor-v2-soft-max"
type="number"
placeholder="Auto"
value={value?.softMax ?? ''}
onChange={handleBound('softMax')}
/>
</div>
</div>
)}
<ConfigField label="Y axis" {...reset('softMin', 'softMax', 'isLogScale')}>
<AxisFields>
{controls.minMax && (
<AxisRangeField
testIdPrefix="panel-editor-v2"
value={value}
onChange={(bounds): void => onChange({ ...value, ...bounds })}
/>
)}
{controls.logScale && (
<div className={styles.field}>
<Typography.Text>Y-axis scale</Typography.Text>
<ConfigSegmented
{controls.logScale && (
<AxisScaleField
testId="panel-editor-v2-log-scale"
aria-label="Y-axis scale"
value={value?.isLogScale ? 'log' : 'linear'}
items={SCALE_OPTIONS}
items={LOG_SCALE_OPTIONS}
help={SCALE_HELP}
onChange={(next): void =>
onChange({ ...value, isLogScale: next === 'log' })
}
/>
</div>
)}
</>
)}
</AxisFields>
</ConfigField>
);
}

View File

@@ -0,0 +1,10 @@
import type { ReactNode } from 'react';
import styles from './AxesSection.module.scss';
/** One axis's fields, stacked under its heading. */
function AxisFields({ children }: { children: ReactNode }): JSX.Element {
return <div className={styles.fields}>{children}</div>;
}
export default AxisFields;

View File

@@ -0,0 +1,52 @@
import type { ReactNode } from 'react';
import ConfigInlineField from '../../controls/ConfigInlineField/ConfigInlineField';
import ConfigRangeInput from '../../controls/ConfigRangeInput/ConfigRangeInput';
import { isRangeInverted } from '../../controls/ConfigRangeInput/utils';
import { RANGE_HELP, RANGE_INVERTED_ERROR } from './options';
export interface AxisBounds {
softMin?: number | null;
softMax?: number | null;
}
interface AxisRangeFieldProps {
/** Prefix for the `-soft-min` / `-soft-max` test ids. */
testIdPrefix: string;
value: AxisBounds | undefined;
onChange: (bounds: AxisBounds) => void;
help?: ReactNode;
helpTestId?: string;
}
/** Soft min/max bounds, flagged when min is above max. */
function AxisRangeField({
testIdPrefix,
value,
onChange,
help = RANGE_HELP,
helpTestId,
}: AxisRangeFieldProps): JSX.Element {
return (
<ConfigInlineField
label="Range"
help={help}
helpTestId={helpTestId}
error={
isRangeInverted(value?.softMin, value?.softMax)
? RANGE_INVERTED_ERROR
: undefined
}
>
<ConfigRangeInput
testIdPrefix={testIdPrefix}
min={value?.softMin}
max={value?.softMax}
onChangeMin={(softMin): void => onChange({ softMin })}
onChangeMax={(softMax): void => onChange({ softMax })}
/>
</ConfigInlineField>
);
}
export default AxisRangeField;

View File

@@ -0,0 +1,37 @@
import ConfigField from '../../controls/ConfigField/ConfigField';
import ConfigTiles, {
type ConfigTileItem,
} from '../../controls/ConfigTiles/ConfigTiles';
interface AxisScaleFieldProps<T extends string> {
testId: string;
'aria-label': string;
value: T;
items: ConfigTileItem<T>[];
/** Help for each scale, shown for the selected one. */
help: Record<T, string>;
onChange: (next: T) => void;
}
function AxisScaleField<T extends string>({
testId,
'aria-label': ariaLabel,
value,
items,
help,
onChange,
}: AxisScaleFieldProps<T>): JSX.Element {
return (
<ConfigField label="Scale" help={help[value]}>
<ConfigTiles
testId={testId}
aria-label={ariaLabel}
value={value}
items={items}
onChange={onChange}
/>
</ConfigField>
);
}
export default AxisScaleField;

View File

@@ -75,8 +75,25 @@ describe('AxesSection', () => {
/>,
);
await user.click(screen.getByText('Log'));
await user.click(screen.getByTestId('panel-editor-v2-log-scale-log'));
expect(onChange).toHaveBeenCalledWith({ isLogScale: true });
});
it('flags a soft min above the soft max', () => {
render(
<AxesSection
value={{ softMin: 23, softMax: 12 }}
controls={{ minMax: true }}
onChange={jest.fn()}
/>,
);
expect(
screen.getByText("Min can't be greater than Max."),
).toBeInTheDocument();
expect(
screen.queryByText(/The axis always shows at least this range/),
).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,15 @@
import { pickScaleOptions } from '../options';
describe('pickScaleOptions', () => {
it('returns the tiles for the given scales, in that order', () => {
expect(
pickScaleOptions(['symlog', 'linear'] as const).map(({ value, label }) => ({
value,
label,
})),
).toStrictEqual([
{ value: 'symlog', label: 'Symlog' },
{ value: 'linear', label: 'Linear' },
]);
});
});

View File

@@ -0,0 +1,34 @@
import type { ConfigTileItem } from '../../controls/ConfigTiles/ConfigTiles';
import { TILE_DRAWINGS } from '../../controls/drawings/tileDrawings';
/** Every axis scale a panel can offer; each section picks the ones its spec stores. */
export type AxisScale = 'auto' | 'linear' | 'log' | 'symlog';
const SCALE_TILES: Record<AxisScale, Omit<ConfigTileItem, 'value'>> = {
auto: { label: 'Auto', drawing: TILE_DRAWINGS.scaleAuto },
linear: { label: 'Linear', drawing: TILE_DRAWINGS.scaleLinear },
log: { label: 'Logarithmic', drawing: TILE_DRAWINGS.scaleLog },
symlog: { label: 'Symlog', drawing: TILE_DRAWINGS.scaleSymlog },
};
/** Tiles for `scales`, in the order given; `T` keeps the caller's own scale type. */
export function pickScaleOptions<T extends AxisScale>(
scales: readonly T[],
): ConfigTileItem<T>[] {
return scales.map((scale) => ({ ...SCALE_TILES[scale], value: scale }));
}
export const SCALE_HELP: Record<AxisScale, string> = {
auto:
'Logarithmic when every value is positive and they span three or more orders of magnitude, otherwise linear.',
linear: 'Evenly spaced values.',
log: 'Spreads out values that span several orders of magnitude.',
symlog: 'Logarithmic, but places zero and negative values too.',
};
export const LOG_SCALE_OPTIONS = pickScaleOptions(['linear', 'log'] as const);
export const RANGE_HELP =
'The axis always shows at least this range. Data outside it still stretches the axis.';
export const RANGE_INVERTED_ERROR = "Min can't be greater than Max.";

View File

@@ -0,0 +1,26 @@
import type { DashboardtypesAxesDTO } from 'api/generated/services/sigNoz.schemas';
import type {
SectionControlsOf,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import { joinSummary } from '../../utils/summary';
function describeRange({ softMin, softMax }: DashboardtypesAxesDTO): string {
const hasMin = typeof softMin === 'number';
const hasMax = typeof softMax === 'number';
if (!hasMin && !hasMax) {
return 'auto range';
}
return `${hasMin ? softMin : 'auto'} to ${hasMax ? softMax : 'auto'}`;
}
export function summarizeAxes(
value: DashboardtypesAxesDTO | undefined,
controls: SectionControlsOf<SectionKind.Axes>,
): string {
return joinSummary([
controls.logScale && (value?.isLogScale ? 'Log' : 'Linear'),
controls.minMax && describeRange(value ?? {}),
]);
}

View File

@@ -1,5 +0,0 @@
.field {
display: flex;
flex-direction: column;
gap: 8px;
}

View File

@@ -1,21 +1,14 @@
import type { ChangeEvent } from 'react';
import { Typography } from '@signozhq/ui/typography';
import { Input } from 'antd';
import type { DashboardtypesHistogramBucketsDTO } from 'api/generated/services/sigNoz.schemas';
import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import ConfigField from '../../controls/ConfigField/ConfigField';
import ConfigFieldRow from '../../controls/ConfigFieldRow/ConfigFieldRow';
import ConfigNumberInput from '../../controls/ConfigNumberInput/ConfigNumberInput';
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
import styles from './BucketsSection.module.scss';
// The two numeric bounds of the histogram-buckets spec (derived from the BE DTO).
type NumericBound = keyof Pick<
DashboardtypesHistogramBucketsDTO,
'bucketCount' | 'bucketWidth'
>;
import { SWITCH_SKETCHES } from '../../controls/drawings/switchSketches';
import { createFieldResetter } from '../../utils/changes';
/**
* Edits the `histogramBuckets` slice of a Histogram panel spec: bucket count / width
@@ -24,55 +17,51 @@ type NumericBound = keyof Pick<
*/
function BucketsSection({
value,
defaultValue,
controls,
onChange,
}: SectionEditorProps<SectionKind.Buckets>): JSX.Element {
// Empty clears the bound to null (chart auto-sizes); otherwise parse to a number,
// ignoring transient non-numeric input by leaving it unset.
const handleNumber =
(bound: NumericBound) =>
(e: ChangeEvent<HTMLInputElement>): void => {
const raw = e.target.value;
const next = raw === '' || Number.isNaN(Number(raw)) ? null : Number(raw);
onChange({ ...value, [bound]: next });
};
const reset = createFieldResetter(value, defaultValue, onChange);
return (
<>
{controls.count && (
<div className={styles.field}>
<Typography.Text>Bucket count</Typography.Text>
<Input
data-testid="panel-editor-v2-bucket-count"
type="number"
placeholder="Auto"
value={value?.bucketCount ?? ''}
onChange={handleNumber('bucketCount')}
/>
</div>
)}
{controls.width && (
<div className={styles.field}>
<Typography.Text>Bucket width</Typography.Text>
<Input
data-testid="panel-editor-v2-bucket-width"
type="number"
placeholder="Auto"
value={value?.bucketWidth ?? ''}
onChange={handleNumber('bucketWidth')}
/>
</div>
{(controls.count || controls.width) && (
<ConfigField
label="Buckets"
help="How values are grouped into bars. Leave on Auto to size buckets from the data."
{...reset('bucketCount', 'bucketWidth')}
>
<ConfigFieldRow>
{controls.count && (
<ConfigNumberInput
testId="panel-editor-v2-bucket-count"
label="Count"
value={value?.bucketCount}
onChange={(bucketCount): void => onChange({ ...value, bucketCount })}
/>
)}
{controls.width && (
<ConfigNumberInput
testId="panel-editor-v2-bucket-width"
label="Width"
value={value?.bucketWidth}
onChange={(bucketWidth): void => onChange({ ...value, bucketWidth })}
/>
)}
</ConfigFieldRow>
</ConfigField>
)}
{controls.mergeQueries && (
<ConfigSwitch
testId="panel-editor-v2-merge-queries"
title="Merge active queries"
description="Bucket all active queries together into one distribution"
title="Combine queries"
description="Puts every active query into one distribution. Hides the legend."
sketch={SWITCH_SKETCHES.combine}
changed={reset('mergeAllActiveQueries').changed}
value={value?.mergeAllActiveQueries ?? false}
onChange={(checked): void =>
onChange({ ...value, mergeAllActiveQueries: checked })
onChange={(mergeAllActiveQueries): void =>
onChange({ ...value, mergeAllActiveQueries })
}
/>
)}

View File

@@ -0,0 +1,15 @@
import type { DashboardtypesHistogramBucketsDTO } from 'api/generated/services/sigNoz.schemas';
import { joinSummary } from '../../utils/summary';
export function summarizeBuckets(
value: DashboardtypesHistogramBucketsDTO | undefined,
): string {
const custom =
typeof value?.bucketCount === 'number' ||
typeof value?.bucketWidth === 'number';
return joinSummary([
custom ? 'Custom' : 'Auto',
value?.mergeAllActiveQueries && 'combined',
]);
}

View File

@@ -1,7 +1,27 @@
.field {
.inset {
display: flex;
flex-direction: column;
gap: 8px;
gap: 10px;
padding: 12px 14px;
border: 1px solid var(--l1-border);
border-radius: 4px;
background: var(--l2-background);
}
.insetRow {
flex-direction: row;
align-items: center;
gap: 12px;
}
.insetLabel {
font-size: 13px;
color: var(--l1-foreground);
}
.opacitySlider {
flex: 1;
min-width: 0;
}
.thresholdField {

View File

@@ -1,91 +1,31 @@
import { Typography } from '@signozhq/ui/typography';
import {
DashboardtypesFillModeDTO,
DashboardtypesLineInterpolationDTO,
DashboardtypesLineStyleDTO,
} from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import { resolveFillOpacity } from 'lib/uPlotV2/utils/fillOpacity';
import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import { resolveFillOpacity } from 'lib/uPlotV2/utils/fillOpacity';
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
import ConfigField from '../../controls/ConfigField/ConfigField';
import ConfigSlider from '../../controls/ConfigSlider/ConfigSlider';
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
import { SegmentIcon } from '../../controls/segmentIcons';
import ConfigTiles from '../../controls/ConfigTiles/ConfigTiles';
import { SWITCH_SKETCHES } from '../../controls/drawings/switchSketches';
import type { SectionEditorContext } from '../../sectionContext';
import { createFieldResetter } from '../../utils/changes';
import DisconnectValuesField from './DisconnectValuesField';
import {
FILL_MODE_OPTIONS,
FILLED_FILL_MODE_OPTIONS,
LINE_INTERPOLATION_HELP,
LINE_INTERPOLATION_OPTIONS,
LINE_STYLE_OPTIONS,
} from './options';
import { formatOpacity } from './utils';
import styles from './ChartAppearanceSection.module.scss';
const LINE_STYLE_OPTIONS = [
{
value: DashboardtypesLineStyleDTO.solid,
label: 'Solid',
icon: 'solid-line' as const,
},
{
value: DashboardtypesLineStyleDTO.dashed,
label: 'Dashed',
icon: 'dashed-line' as const,
},
];
const LINE_INTERPOLATION_OPTIONS = [
{
value: DashboardtypesLineInterpolationDTO.linear,
label: 'Linear',
icon: <SegmentIcon name="interp-linear" />,
},
{
value: DashboardtypesLineInterpolationDTO.spline,
label: 'Spline',
icon: <SegmentIcon name="interp-spline" />,
},
{
value: DashboardtypesLineInterpolationDTO.step_before,
label: 'Step before',
icon: <SegmentIcon name="interp-step-before" />,
},
{
value: DashboardtypesLineInterpolationDTO.step_after,
label: 'Step after',
icon: <SegmentIcon name="interp-step-after" />,
},
];
const FILL_MODE_OPTIONS = [
{
value: DashboardtypesFillModeDTO.none,
label: 'None',
icon: 'fill-none' as const,
},
{
value: DashboardtypesFillModeDTO.solid,
label: 'Solid',
icon: 'fill-solid' as const,
},
{
value: DashboardtypesFillModeDTO.gradient,
label: 'Gradient',
icon: 'fill-gradient' as const,
},
];
// An always-filled kind's wire enum (`AreaFillMode`) has no `none`.
const FILLED_FILL_MODE_OPTIONS = FILL_MODE_OPTIONS.filter(
(option) => option.value !== DashboardtypesFillModeDTO.none,
);
const FILL_OPACITY_STEP = 0.01;
function formatOpacity(opacity: number): string {
return `${Math.round(opacity * 100)}%`;
}
/**
* Edits the `chartAppearance` slice of a TimeSeries panel spec: line style /
* interpolation, fill mode, fill opacity, point markers, and the connect-null-gaps
@@ -93,84 +33,90 @@ function formatOpacity(opacity: number): string {
*/
function ChartAppearanceSection({
value,
defaultValue,
controls,
onChange,
stepInterval,
}: SectionEditorProps<SectionKind.ChartAppearance> &
Pick<SectionEditorContext, 'stepInterval'>): JSX.Element {
const reset = createFieldResetter(value, defaultValue, onChange);
const interpolation = value?.lineInterpolation;
return (
<>
{controls.lineStyle && (
<div className={styles.field}>
<Typography.Text>Line style</Typography.Text>
<ConfigSegmented
<ConfigField label="Line" {...reset('lineStyle')}>
<ConfigTiles
testId="panel-editor-v2-line-style"
aria-label="Line style"
value={value?.lineStyle}
items={LINE_STYLE_OPTIONS}
onChange={(next): void =>
onChange({ ...value, lineStyle: next as DashboardtypesLineStyleDTO })
}
onChange={(lineStyle): void => onChange({ ...value, lineStyle })}
/>
</div>
</ConfigField>
)}
{controls.lineInterpolation && (
<div className={styles.field}>
<Typography.Text>Line interpolation</Typography.Text>
<ConfigSelect
<ConfigField
label="How points connect"
help={interpolation && LINE_INTERPOLATION_HELP[interpolation]}
{...reset('lineInterpolation')}
>
<ConfigTiles
testId="panel-editor-v2-line-interpolation"
placeholder="Select interpolation…"
value={value?.lineInterpolation}
aria-label="How points connect"
value={interpolation}
items={LINE_INTERPOLATION_OPTIONS}
onChange={(next): void =>
onChange({
...value,
lineInterpolation: next,
})
onChange={(lineInterpolation): void =>
onChange({ ...value, lineInterpolation })
}
/>
</div>
</ConfigField>
)}
{controls.fillMode && (
<div className={styles.field}>
<Typography.Text>Fill mode</Typography.Text>
<ConfigSegmented
<ConfigField
label="Area under the line"
{...reset('fillMode', 'fillOpacity')}
>
<ConfigTiles
testId="panel-editor-v2-fill-mode"
aria-label="Area under the line"
value={value?.fillMode}
items={
controls.fillOpacity ? FILLED_FILL_MODE_OPTIONS : FILL_MODE_OPTIONS
}
onChange={(next): void =>
onChange({ ...value, fillMode: next as DashboardtypesFillModeDTO })
}
onChange={(fillMode): void => onChange({ ...value, fillMode })}
/>
</div>
)}
{controls.fillOpacity && (
<div className={styles.field}>
<Typography.Text>Fill opacity</Typography.Text>
<ConfigSlider
testId="panel-editor-v2-fill-opacity"
// The chart's own default, so the thumb starts where an unset fill renders.
value={resolveFillOpacity(value?.fillOpacity)}
min={0}
max={1}
step={FILL_OPACITY_STEP}
formatValue={formatOpacity}
onChange={(fillOpacity): void => onChange({ ...value, fillOpacity })}
/>
</div>
{controls.fillOpacity && (
<div className={cx(styles.inset, styles.insetRow)}>
<span className={styles.insetLabel}>Opacity</span>
<div className={styles.opacitySlider}>
<ConfigSlider
testId="panel-editor-v2-fill-opacity"
// The chart's own default, so the thumb starts where an unset fill renders.
value={resolveFillOpacity(value?.fillOpacity)}
min={0}
max={1}
step={FILL_OPACITY_STEP}
formatValue={formatOpacity}
onChange={(fillOpacity): void => onChange({ ...value, fillOpacity })}
/>
</div>
</div>
)}
</ConfigField>
)}
{controls.showPoints && (
<ConfigSwitch
testId="panel-editor-v2-show-points"
title="Show points"
description="Display individual data points on the chart"
description="Marks each sample the query returned."
sketch={SWITCH_SKETCHES.points}
changed={reset('showPoints').changed}
value={value?.showPoints ?? false}
onChange={(checked): void => onChange({ ...value, showPoints: checked })}
onChange={(showPoints): void => onChange({ ...value, showPoints })}
/>
)}
@@ -179,6 +125,7 @@ function ChartAppearanceSection({
testId="panel-editor-v2-span-gaps"
value={value?.spanGaps}
stepInterval={stepInterval}
{...reset('spanGaps')}
onChange={(spanGaps): void => onChange({ ...value, spanGaps })}
/>
)}

View File

@@ -1,24 +1,16 @@
import { useEffect, useState } from 'react';
import { rangeUtil } from '@grafana/data';
import { Typography } from '@signozhq/ui/typography';
import type { DashboardtypesSpanGapsDTO } from 'api/generated/services/sigNoz.schemas';
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
import ConfigField from '../../controls/ConfigField/ConfigField';
import ConfigTiles from '../../controls/ConfigTiles/ConfigTiles';
import type { FieldResetProps } from '../../utils/changes';
import DisconnectValuesThresholdInput from './DisconnectValuesThresholdInput';
import { DISCONNECT_MODE_OPTIONS, DisconnectValuesMode } from './options';
import { defaultDisconnectDuration } from './utils';
import styles from './ChartAppearanceSection.module.scss';
const DEFAULT_THRESHOLD = '1m';
enum DisconnectValuesMode {
NEVER = 'never',
THRESHOLD = 'threshold',
}
const MODE_OPTIONS = [
{ value: DisconnectValuesMode.NEVER, label: 'Never' },
{ value: DisconnectValuesMode.THRESHOLD, label: 'Threshold' },
];
interface DisconnectValuesFieldProps {
interface DisconnectValuesFieldProps extends FieldResetProps {
testId: string;
value: DashboardtypesSpanGapsDTO | undefined;
/** Query step interval (seconds): seeds the default threshold and floors it. */
@@ -26,13 +18,6 @@ interface DisconnectValuesFieldProps {
onChange: (next: DashboardtypesSpanGapsDTO | undefined) => void;
}
/** Default threshold duration: the step interval (smallest meaningful), else 1m. */
function defaultDuration(stepInterval?: number): string {
return stepInterval && stepInterval > 0
? rangeUtil.secondsToHms(stepInterval)
: DEFAULT_THRESHOLD;
}
/**
* "Disconnect values": Never (span every gap — the chart default) vs Threshold
* (only bridge gaps shorter than a duration). The threshold persists as a
@@ -43,6 +28,8 @@ function DisconnectValuesField({
testId,
value,
stepInterval,
changed,
onReset,
onChange,
}: DisconnectValuesFieldProps): JSX.Element {
const duration = value?.fillLessThan || undefined;
@@ -63,7 +50,7 @@ function DisconnectValuesField({
...value,
fillOnlyBelow: true,
// Seed from the live stepInterval (async — undefined until results load), not mount.
fillLessThan: lastDuration ?? defaultDuration(stepInterval),
fillLessThan: lastDuration ?? defaultDisconnectDuration(stepInterval),
});
return;
}
@@ -72,21 +59,24 @@ function DisconnectValuesField({
};
return (
<>
<div className={styles.field}>
<Typography.Text>Disconnect values</Typography.Text>
<ConfigSegmented
testId={testId}
value={
isThreshold ? DisconnectValuesMode.THRESHOLD : DisconnectValuesMode.NEVER
}
items={MODE_OPTIONS}
onChange={handleMode}
/>
</div>
<ConfigField
label="When data is missing"
help="Breaking the line makes outages and restarts visible."
changed={changed}
onReset={onReset}
>
<ConfigTiles
testId={testId}
aria-label="When data is missing"
value={
isThreshold ? DisconnectValuesMode.THRESHOLD : DisconnectValuesMode.NEVER
}
items={DISCONNECT_MODE_OPTIONS}
onChange={handleMode}
/>
{isThreshold && duration && (
<div className={styles.field}>
<Typography.Text>Threshold value</Typography.Text>
<div className={styles.inset}>
<span className={styles.insetLabel}>Break when a gap is longer than</span>
<DisconnectValuesThresholdInput
testId={`${testId}-value`}
value={duration}
@@ -97,7 +87,7 @@ function DisconnectValuesField({
/>
</div>
)}
</>
</ConfigField>
);
}

View File

@@ -30,16 +30,6 @@ function StatefulSpanGaps({
);
}
// Open the antd Select by clicking its selector, then pick the option by label. The
// line-style and fill-mode controls are ConfigSegmented (buttons), so this helper is
// only used for the line-interpolation ConfigSelect.
async function pickOption(triggerTestId: string, label: string): Promise<void> {
const user = userEvent.setup();
const trigger = screen.getByTestId(triggerTestId);
await user.click(trigger.querySelector('.ant-select-selector') as HTMLElement);
await user.click(await screen.findByRole('option', { name: label }));
}
const ALL_CONTROLS = {
lineStyle: true,
lineInterpolation: true,
@@ -109,7 +99,7 @@ describe('ChartAppearanceSection', () => {
render(
<ChartAppearanceSection
value={undefined}
controls={{ fillOpacity: true }}
controls={{ fillMode: true, fillOpacity: true }}
onChange={jest.fn()}
/>,
);
@@ -126,7 +116,7 @@ describe('ChartAppearanceSection', () => {
render(
<ChartAppearanceSection
value={{ fillOpacity: 0.25 }}
controls={{ fillOpacity: true }}
controls={{ fillMode: true, fillOpacity: true }}
onChange={jest.fn()}
/>,
);
@@ -165,7 +155,7 @@ describe('ChartAppearanceSection', () => {
expect(screen.getByText('Gradient')).toBeInTheDocument();
});
it('writes the chosen line interpolation through the dropdown', async () => {
it('writes the chosen line interpolation through the tiles', async () => {
const onChange = jest.fn();
render(
<ChartAppearanceSection
@@ -175,7 +165,9 @@ describe('ChartAppearanceSection', () => {
/>,
);
await pickOption('panel-editor-v2-line-interpolation', 'Spline');
await userEvent
.setup()
.click(screen.getByTestId('panel-editor-v2-line-interpolation-spline'));
expect(onChange).toHaveBeenCalledWith({ lineInterpolation: 'spline' });
});
@@ -205,7 +197,9 @@ describe('ChartAppearanceSection', () => {
/>,
);
expect(screen.getByText('Never')).toBeInTheDocument();
expect(
screen.getByTestId('panel-editor-v2-span-gaps-never'),
).toBeInTheDocument();
expect(
screen.queryByTestId('panel-editor-v2-span-gaps-value'),
).not.toBeInTheDocument();
@@ -222,7 +216,7 @@ describe('ChartAppearanceSection', () => {
/>,
);
await user.click(screen.getByText('Threshold'));
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-threshold'));
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillOnlyBelow: true, fillLessThan: '1m' },
@@ -284,7 +278,7 @@ describe('ChartAppearanceSection', () => {
/>,
);
await user.click(screen.getByText('Never'));
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-never'));
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillOnlyBelow: false, fillLessThan: undefined },
@@ -360,7 +354,7 @@ describe('ChartAppearanceSection', () => {
/>,
);
await user.click(screen.getByText('Threshold'));
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-threshold'));
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillOnlyBelow: true, fillLessThan: '5m' },
@@ -388,7 +382,7 @@ describe('ChartAppearanceSection', () => {
/>,
);
await user.click(screen.getByText('Threshold'));
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-threshold'));
// Regression: a value seeded at mount would still be the 1m fallback.
expect(onChange).toHaveBeenLastCalledWith({
@@ -442,7 +436,7 @@ describe('ChartAppearanceSection', () => {
// Focus the input first so clicking Never also fires its blur (the toggle race).
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-value'));
await user.click(screen.getByText('Never'));
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-never'));
expect(
screen.queryByTestId('panel-editor-v2-span-gaps-value'),
@@ -453,8 +447,8 @@ describe('ChartAppearanceSection', () => {
const user = userEvent.setup();
render(<StatefulSpanGaps initial={{ spanGaps: { fillLessThan: '5m' } }} />);
await user.click(screen.getByText('Never'));
await user.click(screen.getByText('Threshold'));
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-never'));
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-threshold'));
expect(screen.getByTestId('panel-editor-v2-span-gaps-value')).toHaveValue(
'5m',

View File

@@ -0,0 +1,101 @@
import {
DashboardtypesFillModeDTO,
DashboardtypesLineInterpolationDTO,
DashboardtypesLineStyleDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { ConfigTileItem } from '../../controls/ConfigTiles/ConfigTiles';
import { TILE_DRAWINGS } from '../../controls/drawings/tileDrawings';
export const LINE_STYLE_OPTIONS: ConfigTileItem<DashboardtypesLineStyleDTO>[] =
[
{
value: DashboardtypesLineStyleDTO.solid,
label: 'Solid',
drawing: TILE_DRAWINGS.lineSolid,
},
{
value: DashboardtypesLineStyleDTO.dashed,
label: 'Dashed',
drawing: TILE_DRAWINGS.lineDashed,
},
];
export const LINE_INTERPOLATION_OPTIONS: ConfigTileItem<DashboardtypesLineInterpolationDTO>[] =
[
{
value: DashboardtypesLineInterpolationDTO.linear,
label: 'Straight',
drawing: TILE_DRAWINGS.interpLinear,
},
{
value: DashboardtypesLineInterpolationDTO.spline,
label: 'Smooth',
drawing: TILE_DRAWINGS.interpSpline,
},
{
value: DashboardtypesLineInterpolationDTO.step_before,
label: 'Step before',
drawing: TILE_DRAWINGS.interpStepBefore,
},
{
value: DashboardtypesLineInterpolationDTO.step_after,
label: 'Step after',
drawing: TILE_DRAWINGS.interpStepAfter,
},
];
export const LINE_INTERPOLATION_HELP: Record<
DashboardtypesLineInterpolationDTO,
string
> = {
[DashboardtypesLineInterpolationDTO.linear]:
'Joins each point with a straight line.',
[DashboardtypesLineInterpolationDTO.spline]:
'Curves through each point. Can overshoot between points.',
[DashboardtypesLineInterpolationDTO.step_before]:
'Jumps to each new value before its timestamp.',
[DashboardtypesLineInterpolationDTO.step_after]:
'Holds each value until the next point arrives.',
};
export const FILL_MODE_OPTIONS: ConfigTileItem<DashboardtypesFillModeDTO>[] = [
{
value: DashboardtypesFillModeDTO.none,
label: 'None',
drawing: TILE_DRAWINGS.fillNone,
},
{
value: DashboardtypesFillModeDTO.solid,
label: 'Solid',
drawing: TILE_DRAWINGS.fillSolid,
},
{
value: DashboardtypesFillModeDTO.gradient,
label: 'Gradient',
drawing: TILE_DRAWINGS.fillGradient,
},
];
// An always-filled kind's wire enum (`AreaFillMode`) has no `none`.
export const FILLED_FILL_MODE_OPTIONS = FILL_MODE_OPTIONS.filter(
(option) => option.value !== DashboardtypesFillModeDTO.none,
);
export enum DisconnectValuesMode {
NEVER = 'never',
THRESHOLD = 'threshold',
}
export const DISCONNECT_MODE_OPTIONS: ConfigTileItem<DisconnectValuesMode>[] = [
{
value: DisconnectValuesMode.NEVER,
label: 'Connect the line',
drawing: TILE_DRAWINGS.gapsConnect,
},
{
value: DisconnectValuesMode.THRESHOLD,
label: 'Break long gaps',
drawing: TILE_DRAWINGS.gapsBreak,
},
];

View File

@@ -0,0 +1,41 @@
import {
DashboardtypesFillModeDTO,
DashboardtypesLineStyleDTO,
} from 'api/generated/services/sigNoz.schemas';
import type {
PanelChartAppearanceSlice,
SectionControlsOf,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import { joinSummary } from '../../utils/summary';
import { FILL_MODE_OPTIONS, LINE_INTERPOLATION_OPTIONS } from './options';
function describeLine(value: PanelChartAppearanceSlice): string | undefined {
const interpolation = LINE_INTERPOLATION_OPTIONS.find(
(option) => option.value === value.lineInterpolation,
)?.label;
const dashed = value.lineStyle === DashboardtypesLineStyleDTO.dashed;
if (!interpolation) {
return dashed ? 'Dashed' : undefined;
}
return dashed ? `${interpolation}, dashed` : interpolation;
}
export function summarizeChartAppearance(
value: PanelChartAppearanceSlice | undefined,
controls: SectionControlsOf<SectionKind.ChartAppearance>,
): string {
const appearance = value ?? {};
const fill = FILL_MODE_OPTIONS.find(
(option) =>
option.value === (appearance.fillMode ?? DashboardtypesFillModeDTO.none),
);
return joinSummary([
(controls.lineStyle || controls.lineInterpolation) &&
describeLine(appearance),
controls.fillMode && fill && `${fill.label} fill`,
controls.showPoints && appearance.showPoints && 'points',
controls.spanGaps && appearance.spanGaps?.fillOnlyBelow && 'breaks gaps',
]);
}

View File

@@ -0,0 +1,14 @@
import { rangeUtil } from '@grafana/data';
const DEFAULT_DISCONNECT_DURATION = '1m';
/** The step interval (smallest meaningful gap), else 1m. */
export function defaultDisconnectDuration(stepInterval?: number): string {
return stepInterval && stepInterval > 0
? rangeUtil.secondsToHms(stepInterval)
: DEFAULT_DISCONNECT_DURATION;
}
export function formatOpacity(opacity: number): string {
return `${Math.round(opacity * 100)}%`;
}

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