Compare commits

..

20 Commits

Author SHA1 Message Date
Naman Verma
8943a9454b Merge branch 'main' into nv/dashboard-migration 2026-06-26 02:15:22 +05:30
Naman Verma
9a7ed5b711 feat: note down all errors in migration 2026-06-26 02:07:06 +05:30
Naman Verma
2d75e3d32d chore: separate error type for migration 2026-06-26 01:07:59 +05:30
Abhi kumar
853397a79e feat(dashboards-v2): create new panels from the editor (#11777)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* feat(dashboards-v2): list panel kind (logs/traces) with row detail

* feat(dashboards-v2): list columns editor with datasource column switch

* feat(dashboards-v2): move list columns editor below the query builder

Replace the config-pane Columns section with a dnd-kit reorderable editor
rendered beneath the query builder (V1 parity); sanitize selectFields to the
field-key DTO so saved columns drop non-contract keys (isIndexed).

* style(dashboards-v2): format list panel header with oxfmt

* refactor(dashboards-v2): drop the unsupported-panel fallback

Every panel kind now has a renderer, so the "not yet supported in V2"
body is dead. Remove UnsupportedPanelBody and render the panel body
behind a plain `panelDefinition &&` guard. Also clean up the panel-kind
typing in Panel.tsx: `spec.plugin.kind` is non-optional and already a
PanelKind, so the `as unknown as` cast and `?.` chains go away.

* refactor(dashboards-v2): tighten the getPanelDefinition cast

Drop the dead `if (!kind)` guard (a partial registry already returns
undefined for absent keys) and reduce the `as unknown as` double-cast to
a single `as` — comparability clears it, so the unknown laundering was
unnecessary.

* refactor(dashboards-v2): narrow renderer props to the panel kind

PanelRendererProps<K> now carries a `panel` narrowed to kind K (via a
PluginOfKind helper that picks the variant from the generated plugin
union), so each renderer reads `panel.spec.plugin.spec` as its exact spec
DTO. Removes the per-renderer `as <Kind>SpecDTO` cast (and the now-dead
`?? {}` fallbacks) across all seven renderers; the single unavoidable
widening stays in getPanelDefinition.

* feat(dashboards-v2): create new panels from the editor

Add a draft-first create flow: picking a panel type opens the editor at
`/panel/new?panelKind=…&layoutIndex=…` on an in-memory default panel, and
nothing is persisted until save — cancelling leaves the dashboard
untouched (V1 parity). On save a new panel is minted (uuid) and added to
the target section via `createPanelOps` (resolves the section, or creates
one when the dashboard has none).

All "Add panel" triggers (section header, empty section, empty dashboard,
toolbar) route through a single useCreatePanel hook + the V2 type picker;
the leftover V1 global modal and useAddPanelToSection are removed.

New panels seed sensible defaults from the kind's supported signals: the
query datasource (e.g. List → logs, not the unsupported metrics default)
and, for List, the signal's default columns — so the Columns control
isn't empty on first open. New panels always re-serialize their query for
the kind on save, so the persisted query is valid even if untouched.

echo "--- working tree after commits (should be only dev hack) ---"; git status --short

* fix(dashboards-v2): place a new panel in the last row's free space

createPanelOps placed every new panel at x:0 on a fresh row, so a half-full
last row left its right half empty. Find the first free slot in the section's
last row (right of its panels) and only wrap to a new row when it can't fit.

* fix(uplot): center a single-item chart legend

isSingleRow read the container width from a ref inside useMemo, so it computed
once at mount (ref still null → width 0 → false) and only recovered if the
component happened to re-render. The uPlot legend re-renders on plot sync so it
recovered by luck; the Pie legend doesn't, leaving a single item clipped in a
narrow grid track. Measure the container with useResizeObserver instead.

* feat(dashboards-v2): unified panel empty, no-data and no-query states

Add a shared PanelMessage component (icon + title + description + optional
action) backing every non-chart panel state. PanelBody now shows a 'Nothing to
visualize yet' state for panels with no runnable query and a polished error
state with Retry. NoData becomes a 'No data in this time range' affordance with
an optional Retry, wired through a new optional refetch on the renderer props.

* fix(dashboards-v2): derive the preview "Plotted with" tag from the panel query

The editor preview hardcoded EQueryType.QUERY_BUILDER, so PromQL/ClickHouse
panels were mislabelled and list panels still showed the tag. Add a V2 PlotTag
(mirroring V1: hidden for list panels and before a query exists) fed by a new
getPanelQueryType util that reads the panel's V5 envelopes.

* feat(dashboards-v2): panel header title with description tooltip

Replace PanelHeader's ReactNode title with explicit name + description props,
rendering the description as an info-icon tooltip in the header instead of a
wrapper around the title. Panel passes them directly (no headerTitle memo).

* refactor(dashboards-v2): let consumers own the refetch loader signal

usePanelQuery returns the raw react-query isLoading; the dashboard panel and the
editor preview now compute isLoading || isFetching themselves, so each surface
decides whether a background refetch shows the full loader.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(dashboards-v2): default a threshold's label to an empty string on save

* feat(dashboards-v2): show the panel header in the editor preview

Reuse PanelHeader in PreviewPane so the editor preview shows the panel
title, description tooltip, refetch spinner, and error/warning indicators,
with the body rendered flush beneath it like the dashboard grid panel.

Add a hideActions flag to PanelHeader to suppress the actions menu in the
editor: View/Edit/Clone/Delete don't apply there, and omitting panelActions
alone isn't enough since View/Edit/Download survive their own gates. Wire the
table/list header search through the preview as the grid does, and thread the
raw isFetching for the header's refetch spinner.

* feat(dashboards-v2): seed new panels with per-kind config defaults

A new panel was seeded with an empty plugin spec, so the config pane's
dropdowns and segmented controls (time scope, legend position, line style /
interpolation, fill mode) opened with nothing selected.

Derive a default plugin spec from each kind's declared sections via a new pure
buildDefaultPluginSpec helper and seed it in createDefaultPanel. Each default
equals the matching renderer fallback, so only the config-pane display changes,
not the rendered output. Controls whose empty state already reads as the chart
default (unit/decimals "auto", switches, numeric "Auto" inputs) are left unset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(dashboards-v2): preserve list columns across datasource round-trips

The List panel stores a single selectFields list, so switching the query
signal replaced the columns with the new datasource's defaults — going
logs -> traces -> logs discarded a customized logs selection.

Remember each signal's columns and restore them when switching back;
defaults still seed a signal the first time it's seen.

* docs(dashboards-v2): trim verbose comments to minimal-why style

Condense multi-paragraph block comments and drop comments that restate
the code across the new-panel editor surface, keeping only the non-obvious
"why" and concise JSDoc on functions and type fields. Comment-only — no
code, types, or test assertions changed.

* fix(dashboards-v2): show resource attributes like service.name in trace list panels

The List renderer only flattened nested resource/attribute maps for logs, so a
selected resource field such as service.name (nested under resources_string)
rendered N/A for traces even though the same data shows in V1. Flatten traces too.

* feat(dashboards-v2): open new List panels on a runnable logs query (V1 parity)

A new List panel opened blank: its seed query had an empty orderBy and lived only
in the builder, so the preview didn't run on open. Seed spec.queries at creation
with the V1 list logs query (orderBy timestamp desc) so the panel opens on logs,
pre-sorted, and the preview runs immediately; wire up useSeedNewListColumns so its
default columns (timestamp, body) are populated too. Switching to traces is handled
by the builder's list-view path.

* fix(dashboards-v2): drop all config-pane sections from the List panel

List columns are edited below the query builder, and Context Links isn't wanted in
the config pane, so the List panel now declares no config-pane sections.

* refactor(dashboards-v2): address PR review feedback

- getPanelDefinition: make PanelRegistry total over PanelKind so it never
  returns undefined (a missing kind is now a compile error)
- toQueryEnvelopes/hasRunnableQueries: accept `... | null` and drop the
  `?? []` at call sites
- consolidate panel loading to a single `isFetching` signal (Panel,
  PreviewPane, PanelEditor)
- rename defaultDataSource -> signal; use TelemetrytypesSignalDTO enum for
  the List default columns
- useCreatePanel now owns the panel-type picker state, deduping the three
  add-panel triggers; rename pluginKind -> panelKind
- LabelThresholdRow: extract onSave into a callback; ThresholdsSection test
  uses userEvent
- PreviewPane: drop the optional chaining on the required display field
- trim over-verbose comments

* refactor(dashboards-v2): type panel signals as TelemetrytypesSignalDTO

Replace the query-builder `DataSource` enum with `TelemetrytypesSignalDTO`
across the panel signal plumbing — the panel definitions' `supportedSignals`,
the List columns editor/suggestions/defaults, and the editor query-sync and
column-seeding hooks — and make the signal non-optional where a panel always
resolves one. Drops the now-dead `DataSource` imports.

* chore: pr review changes

* chore: pr review changes

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 18:48:20 +00:00
Naman Verma
1d6eabf927 chore: remove spec md file 2026-06-25 22:37:52 +05:30
Naman Verma
082d7b1b77 test: fix ut to check for v2 internal name 2026-06-25 22:34:39 +05:30
Naman Verma
5019dee2d7 Merge branch 'main' into nv/dashboard-migration 2026-06-25 22:30:55 +05:30
Naman Verma
216de973fb fix: remove nil nil return 2026-06-25 03:07:58 +05:30
Naman Verma
18c0eec5e2 chore: catch typecast errors 2026-06-19 15:49:01 +05:30
Naman Verma
2ccdeb3631 chore: add a catch all panic check to log migration error 2026-06-19 15:15:17 +05:30
Naman Verma
ad12e50bbc fix: extract row and widget positions to build expanded sections 2026-06-19 15:00:32 +05:30
Naman Verma
e247bf3864 fix: sanitize tags instead of throwing error 2026-06-19 14:17:36 +05:30
Naman Verma
f4651ea134 fix: match with lower case signal for variables 2026-06-19 12:17:42 +05:30
Naman Verma
d449a2dbf2 fix: generate internal name from title 2026-06-19 11:24:40 +05:30
Naman Verma
d4b9f91062 Merge branch 'main' into nv/dashboard-migration 2026-06-18 12:28:22 +05:30
Naman Verma
530710b7bc Merge branch 'nv/dashboard-migration' of https://github.com/SigNoz/signoz into nv/dashboard-migration 2026-06-17 12:42:24 +05:30
Naman Verma
4fb5eec08d fix: move WrapInV5Envelope to types package 2026-06-17 12:42:20 +05:30
Naman Verma
f889d36f0f Merge branch 'main' into nv/dashboard-migration 2026-06-17 12:32:08 +05:30
Naman Verma
db12d44523 Merge branch 'main' into nv/dashboard-migration 2026-06-17 07:30:34 +05:30
Naman Verma
86fc0e81ba chore: add migration script from current to perses dashboard 2026-06-14 22:58:08 +05:30
110 changed files with 4335 additions and 1155 deletions

View File

@@ -370,7 +370,7 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
}
for _, cb := range provider.onBeforeRoleDelete {
if err := cb(ctx, orgID, id, role.Name); err != nil {
if err := cb(ctx, orgID, id); err != nil {
return err
}
}

View File

@@ -5,6 +5,7 @@ import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import cx from 'classnames';
import { useCopyToClipboard } from 'hooks/useCopyToClipboard';
import { useResizeObserver } from 'hooks/useDimensions';
import { LegendItem } from 'lib/uPlotV2/config/types';
import { Check, Copy } from '@signozhq/icons';
@@ -36,17 +37,16 @@ export default function Legend({
// Search is intrinsic to the right-positioned legend.
const searchEnabled = position === LegendPosition.RIGHT;
const { width: containerWidth } = useResizeObserver(legendContainerRef);
const isSingleRow = useMemo(() => {
if (!legendContainerRef.current || position !== LegendPosition.BOTTOM) {
if (position !== LegendPosition.BOTTOM || containerWidth <= 0) {
return false;
}
const containerWidth = legendContainerRef.current.clientWidth;
const totalLegendWidth = items.length * (averageLegendWidth + 16);
const totalRows = Math.ceil(totalLegendWidth / containerWidth);
return totalRows <= 1;
}, [averageLegendWidth, items.length, position]);
}, [averageLegendWidth, items.length, position, containerWidth]);
const visibleLegendItems = useMemo(() => {
if (!searchEnabled || !legendSearchQuery.trim()) {

View File

@@ -14,10 +14,11 @@ import type {
import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { useAppContext } from 'providers/App/App';
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useCreatePanel } from '../hooks/useCreatePanel';
import PanelTypeSelectionModal from '../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
import DashboardActions from './DashboardActions/DashboardActions';
import DashboardInfo from './DashboardInfo/DashboardInfo';
import { useEditableTitle } from './DashboardInfo/useEditableTitle';
@@ -50,9 +51,8 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
const { user } = useAppContext();
const { showErrorModal } = useErrorModal();
const setIsPanelTypeSelectionModalOpen = usePanelTypeSelectionModalStore(
(s) => s.setIsPanelTypeSelectionModalOpen,
);
const { isPickerOpen, openPicker, closePicker, createPanel } =
useCreatePanel();
const isAuthor =
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
@@ -108,8 +108,8 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
void logEvent('Dashboard Detail V2: Add new panel clicked', {
dashboardId: id,
});
setIsPanelTypeSelectionModalOpen(true);
}, [id, setIsPanelTypeSelectionModalOpen]);
openPicker();
}, [id, openPicker]);
return (
<section className={styles.dashboardPageToolbarContainer}>
@@ -149,6 +149,11 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
</div>
<VariablesBar dashboard={dashboard} />
</div>
<PanelTypeSelectionModal
open={isPickerOpen}
onClose={closePicker}
onSelect={createPanel}
/>
</section>
);
}

View File

@@ -40,9 +40,9 @@ function ConfigPane({
tableColumns,
}: ConfigPaneProps): JSX.Element {
const definition = getPanelDefinition(panelKind);
const sections = definition?.sections ?? [];
const sections = definition.sections;
const signal = getBuilderQueries(spec.queries)[0]?.signal as
const signal = getBuilderQueries(spec.queries || [])[0]?.signal as
| TelemetrytypesSignalDTO
| undefined;

View File

@@ -2,7 +2,6 @@ import { fireEvent, render, screen } from '@testing-library/react';
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import ConfigPane from '../ConfigPane';
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
function spec(unit?: string): DashboardtypesPanelSpecDTO {
return {
@@ -59,11 +58,4 @@ describe('ConfigPane', () => {
// The TimeSeries kind declares a Formatting section; its collapsible header shows.
expect(screen.getByTestId('config-section-Formatting')).toBeInTheDocument();
});
it('omits the Formatting section for an unknown kind', () => {
renderConfigPane({ panelKind: 'signoz/UnknownPanel' as PanelKind });
expect(
screen.queryByTestId('config-section-Formatting'),
).not.toBeInTheDocument();
});
});

View File

@@ -1,5 +1,6 @@
import { useState } from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { DashboardtypesThresholdWithLabelDTO } from 'api/generated/services/sigNoz.schemas';
import type { AnyThreshold } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
@@ -9,8 +10,8 @@ const THRESHOLDS: DashboardtypesThresholdWithLabelDTO[] = [
{ value: 80, color: '#F5B225', label: 'High', unit: 'percent' },
];
// Stateful harness for flows that depend on the value updating (add/discard). No
// `controls` is passed, exercising the default `label` variant.
// Stateful harness for flows that depend on the value updating (add/discard);
// omits `controls` to exercise the default `label` variant.
function Harness({ yAxisUnit }: { yAxisUnit?: string }): JSX.Element {
const [value, setValue] = useState<AnyThreshold[]>([]);
return (
@@ -33,7 +34,6 @@ describe('ThresholdsSection', () => {
expect(screen.getByTestId('threshold-edit-0')).toBeInTheDocument();
expect(screen.getByText('High')).toBeInTheDocument();
// The editable fields are hidden until the row is edited.
expect(screen.queryByTestId('threshold-value-0')).not.toBeInTheDocument();
});
@@ -54,6 +54,22 @@ describe('ThresholdsSection', () => {
]);
});
it('persists an empty-string label when none is provided', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
// Label absent (e.g. a pre-existing spec); spec requires a string, so save
// must send '' not undefined.
const noLabel = [{ value: 50, color: '#F1575F' }] as AnyThreshold[];
render(<ThresholdsSection value={noLabel} onChange={onChange} />);
await user.click(screen.getByTestId('threshold-edit-0'));
await user.click(screen.getByTestId('threshold-save-0'));
expect(onChange).toHaveBeenCalledWith([
{ value: 50, color: '#F1575F', label: '' },
]);
});
it('does not commit edits when Discard is clicked', () => {
const onChange = jest.fn();
render(<ThresholdsSection value={THRESHOLDS} onChange={onChange} />);
@@ -65,7 +81,6 @@ describe('ThresholdsSection', () => {
fireEvent.click(screen.getByTestId('threshold-discard-0'));
expect(onChange).not.toHaveBeenCalled();
// Back to view mode.
expect(screen.queryByTestId('threshold-value-0')).not.toBeInTheDocument();
expect(screen.getByTestId('threshold-edit-0')).toBeInTheDocument();
});
@@ -83,11 +98,10 @@ describe('ThresholdsSection', () => {
render(<Harness />);
fireEvent.click(screen.getByTestId('panel-editor-v2-add-threshold'));
// New row opens in edit mode.
expect(screen.getByTestId('threshold-value-0')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('threshold-discard-0'));
// Discarding a never-saved row removes it entirely.
fireEvent.click(screen.getByTestId('threshold-discard-0'));
expect(screen.queryByTestId('threshold-value-0')).not.toBeInTheDocument();
expect(screen.queryByTestId('threshold-edit-0')).not.toBeInTheDocument();
});

View File

@@ -1,3 +1,4 @@
import { useCallback } from 'react';
import { Typography } from '@signozhq/ui/typography';
import { Input } from 'antd';
import type { DashboardtypesThresholdWithLabelDTO } from 'api/generated/services/sigNoz.schemas';
@@ -23,10 +24,7 @@ interface LabelThresholdRowProps {
onRemove: () => void;
}
/**
* Value + color + label threshold (TimeSeries / Bar): a line drawn on the chart. Edit
* form is color, value, unit, label.
*/
/** Value + color + label threshold (TimeSeries / Bar): a line drawn on the chart. */
function LabelThresholdRow({
index,
threshold,
@@ -39,6 +37,11 @@ function LabelThresholdRow({
}: LabelThresholdRowProps): JSX.Element {
const { draft, setDraft, setValue } = useThresholdDraft(threshold, isEditing);
// Persist an empty-string label when none was entered — the spec requires a string.
const handleSave = useCallback((): void => {
onSave({ ...draft, label: draft.label ?? '' });
}, [onSave, draft]);
const summary = (
<>
<span className={styles.viewValue}>
@@ -58,7 +61,7 @@ function LabelThresholdRow({
isEditing={isEditing}
summary={summary}
onEdit={onEdit}
onSave={(): void => onSave(draft)}
onSave={handleSave}
onDiscard={onDiscard}
onRemove={onRemove}
>

View File

@@ -29,8 +29,7 @@ import styles from './ListColumnsEditor.module.scss';
interface ListColumnsEditorProps {
spec: DashboardtypesPanelSpecDTO;
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
/** Committed query's signal — scopes the add-dropdown's field suggestions. */
signal: TelemetrytypesSignalDTO | undefined;
signal: TelemetrytypesSignalDTO;
}
/**

View File

@@ -21,7 +21,7 @@ import { useListColumnSuggestions } from '../../hooks/useListColumnSuggestions';
import styles from './AddColumnDropdown.module.scss';
interface AddColumnDropdownProps {
signal: TelemetrytypesSignalDTO | undefined;
signal: TelemetrytypesSignalDTO;
/** Names already chosen — drives the checked state + toggle behavior. */
selectedNames: Set<string>;
onToggle: (field: TelemetrytypesTelemetryFieldKeyDTO) => void;

View File

@@ -21,7 +21,7 @@ interface UseListColumnSuggestions {
* flatten them and index by name so picks can carry their context/data-type.
*/
export function useListColumnSuggestions(
signal: TelemetrytypesSignalDTO | undefined,
signal: TelemetrytypesSignalDTO,
): UseListColumnSuggestions {
const [searchText, setSearchText] = useState('');
const debouncedSearch = useDebounce(searchText, 300);

View File

@@ -1,14 +1,17 @@
import type {
DashboardtypesListPanelSpecDTO,
DashboardtypesPanelSpecDTO,
TelemetrytypesTelemetryFieldKeyDTO,
import {
TelemetrytypesSignalDTO,
type DashboardtypesListPanelSpecDTO,
type DashboardtypesPanelSpecDTO,
type TelemetrytypesTelemetryFieldKeyDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
defaultLogsSelectedColumns,
defaultTraceSelectedColumns,
} from 'container/OptionsMenu/constants';
/**
* The field-key suggestions API and the default-column constants carry extra
* runtime fields (e.g. `isIndexed`) that the save contract rejects. Reduce each
* column to the `TelemetrytypesTelemetryFieldKeyDTO` shape so persisted
* `selectFields` only contain backend-known keys.
* Reduce each column to the field-key DTO shape: the suggestions API and default
* constants carry extra runtime fields (e.g. `isIndexed`) the save contract rejects.
*/
function toFieldKeyDTO(
field: TelemetrytypesTelemetryFieldKeyDTO,
@@ -31,10 +34,26 @@ export function sanitizeSelectFields(
}
/**
* `spec.plugin.spec` is a discriminated union over every panel kind; these helpers
* run only for the List panel, so it's narrowed to the List variant with a single
* localized cast at the boundary.
* logs/traces List-column defaults (V1 parity), sanitized to the field-key DTO.
*/
export function defaultColumnsForSignal(
signal: TelemetrytypesSignalDTO,
): TelemetrytypesTelemetryFieldKeyDTO[] {
if (signal === TelemetrytypesSignalDTO.logs) {
return sanitizeSelectFields(
defaultLogsSelectedColumns as TelemetrytypesTelemetryFieldKeyDTO[],
);
}
if (signal === TelemetrytypesSignalDTO.traces) {
return sanitizeSelectFields(
defaultTraceSelectedColumns as TelemetrytypesTelemetryFieldKeyDTO[],
);
}
return [];
}
// `spec.plugin.spec` is a discriminated union over panel kinds; these List-only
// helpers narrow to the List variant via a single localized cast at the boundary.
export function readSelectFields(
spec: DashboardtypesPanelSpecDTO,
): TelemetrytypesTelemetryFieldKeyDTO[] {

View File

@@ -0,0 +1,35 @@
import { Spline } from '@signozhq/icons';
import { PANEL_TYPES } from 'constants/queryBuilder';
import QueryTypeTag from 'container/NewWidget/LeftContainer/QueryTypeTag';
import { EQueryType } from 'types/common/dashboard';
interface PlotTagProps {
/** Authoring mode of the panel's query; undefined when no query exists yet. */
queryType: EQueryType | undefined;
panelType: PANEL_TYPES;
className?: string;
}
/**
* "Plotted with <query mode>" chip for the editor preview; V2 counterpart of V1's
* PlotTag (duplicated per the split policy). Hidden for list panels and before a
* query exists, where the mode is irrelevant.
*/
function PlotTag({
queryType,
panelType,
className,
}: PlotTagProps): JSX.Element | null {
if (queryType === undefined || panelType === PANEL_TYPES.LIST) {
return null;
}
return (
<div className={className} data-testid="panel-editor-plot-tag">
<Spline size={14} />
Plotted with <QueryTypeTag queryType={queryType} />
</div>
);
}
export default PlotTag;

View File

@@ -43,8 +43,10 @@
border-radius: 4px;
overflow: hidden;
display: flex;
// Header stacks above the body, flush to the border — mirrors the dashboard
// grid's `.panel` so the preview reads as the real panel chrome.
flex-direction: column;
background: var(--l2-background);
padding: 8px;
}
.state {
@@ -57,3 +59,7 @@
font-size: 13px;
text-align: center;
}
.dateTimeSelector {
margin-left: auto;
}

View File

@@ -1,25 +1,28 @@
import { Spline } from '@signozhq/icons';
import { useState } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import QueryTypeTag from 'container/NewWidget/LeftContainer/QueryTypeTag';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import PanelBody from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody';
import PanelHeader from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import { getPanelQueryType } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getPanelQueryType';
import type {
PanelPagination,
PanelQueryData,
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { EQueryType } from 'types/common/dashboard';
import PlotTag from './PlotTag';
import styles from './PreviewPane.module.scss';
interface PreviewPaneProps {
panelId: string;
panel: DashboardtypesPanelDTO;
/** Resolved definition for the panel kind; undefined when the kind is unsupported. */
panelDef: RenderablePanelDefinition | undefined;
/** Resolved definition for the panel kind; */
panelDefinition: RenderablePanelDefinition;
data: PanelQueryData;
isLoading: boolean;
/** Any fetch in flight — drives the header spinner and the body's loading state. */
isFetching: boolean;
error: Error | null;
/** Re-run the query (drives PanelBody's error-state retry). */
refetch: () => void;
@@ -30,50 +33,69 @@ interface PreviewPaneProps {
}
/**
* Live preview for the panel editor. Renders the draft through the same `PanelBody`
* the dashboard grid uses (only `panelMode={DASHBOARD_EDIT}` differs), so the preview
* is the production render path. The query result is owned by the editor root.
* Live preview for the panel editor: renders the draft through the same `PanelBody`
* the dashboard grid uses (only `panelMode` differs), so the preview is the
* production render path. The query result is owned by the editor root.
*/
function PreviewPane({
panelId,
panel,
panelDef,
panelDefinition,
data,
isLoading,
isFetching,
error,
refetch,
onDragSelect,
pagination,
}: PreviewPaneProps): JSX.Element {
const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind];
const queryType = getPanelQueryType(panel);
// Search term is ephemeral preview state, threaded to header + renderer but
// not persisted to the draft spec. Only kinds that declare it render the box.
const searchable = !!panelDefinition.actions.search;
const [searchTerm, setSearchTerm] = useState('');
return (
<div className={styles.preview}>
<div className={styles.header}>
<div className={styles.queryType}>
<Spline size={14} />
Plotted with <QueryTypeTag queryType={EQueryType.QUERY_BUILDER} />
<PlotTag
queryType={queryType}
panelType={panelType}
className={styles.queryType}
/>
<div className={styles.dateTimeSelector}>
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
</div>
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
</div>
<div className={styles.container}>
<div className={styles.surface}>
{panelDef ? (
<PanelBody
panelDefinition={panelDef}
panel={panel}
panelId={panelId}
data={data}
isLoading={isLoading}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
panelMode={PanelMode.DASHBOARD_EDIT}
pagination={pagination}
/>
) : (
<div className={styles.state} data-testid="panel-editor-v2-unknown-kind">
This panel type is not yet supported in V2.
</div>
)}
<PanelHeader
name={panel.spec.display.name}
description={panel.spec.display.description}
panelId={panelId}
panelKind={panel.spec.plugin.kind}
isFetching={isFetching}
error={error}
warning={data.response?.data?.warning}
searchable={searchable}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
hideActions
/>
<PanelBody
panelDefinition={panelDefinition}
panel={panel}
panelId={panelId}
data={data}
isFetching={isFetching}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
panelMode={PanelMode.DASHBOARD_EDIT}
searchTerm={searchable ? searchTerm : undefined}
pagination={pagination}
/>
</div>
</div>
</div>

View File

@@ -0,0 +1,30 @@
import { render, screen } from '@testing-library/react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { EQueryType } from 'types/common/dashboard';
import PlotTag from '../PlotTag';
describe('PlotTag', () => {
it('renders the resolved query mode', () => {
render(
<PlotTag queryType={EQueryType.PROM} panelType={PANEL_TYPES.TIME_SERIES} />,
);
expect(screen.getByTestId('panel-editor-plot-tag')).toBeInTheDocument();
expect(screen.getByText('PromQL')).toBeInTheDocument();
});
it('renders nothing when there is no query yet', () => {
render(<PlotTag queryType={undefined} panelType={PANEL_TYPES.TIME_SERIES} />);
expect(screen.queryByTestId('panel-editor-plot-tag')).not.toBeInTheDocument();
});
it('renders nothing for list panels (query mode is irrelevant)', () => {
render(
<PlotTag
queryType={EQueryType.QUERY_BUILDER}
panelType={PANEL_TYPES.LIST}
/>,
);
expect(screen.queryByTestId('panel-editor-plot-tag')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,34 @@
import {
NEW_PANEL_ID,
newPanelSearch,
parseNewPanelKind,
parseNewPanelLayoutIndex,
} from '../newPanelRoute';
describe('newPanelRoute', () => {
it('round-trips kind + layoutIndex through the new-panel search', () => {
const search = newPanelSearch('signoz/ListPanel', 2);
expect(parseNewPanelKind(NEW_PANEL_ID, search)).toBe('signoz/ListPanel');
expect(parseNewPanelLayoutIndex(search)).toBe(2);
});
it('omits layoutIndex when not provided', () => {
const search = newPanelSearch('signoz/TimeSeriesPanel');
expect(parseNewPanelKind(NEW_PANEL_ID, search)).toBe(
'signoz/TimeSeriesPanel',
);
expect(parseNewPanelLayoutIndex(search)).toBeUndefined();
});
it('returns null for an existing panel id (not the new sentinel)', () => {
const search = newPanelSearch('signoz/ListPanel');
expect(parseNewPanelKind('a1b2c3d4-uuid', search)).toBeNull();
});
it('returns null when the kind param is missing or unknown', () => {
expect(parseNewPanelKind(NEW_PANEL_ID, '')).toBeNull();
expect(
parseNewPanelKind(NEW_PANEL_ID, '?panelKind=NotARealPanel'),
).toBeNull();
});
});

View File

@@ -10,10 +10,13 @@ import {
} from 'container/OptionsMenu/constants';
import { sanitizeSelectFields } from '../../ListColumnsEditor/selectFields';
import { useSwitchColumnsOnSignalChange } from '../useSwitchColumnsOnSignalChange';
import {
useSwitchColumnsOnSignalChange,
type UseSwitchColumnsOnSignalChangeArgs,
} from '../useSwitchColumnsOnSignalChange';
// The hook applies the datasource defaults reduced to the field-key DTO (the V1
// constants carry extra keys like `isIndexed`); assertions mirror that.
// V1 constants carry extra keys (e.g. `isIndexed`); the hook reduces them to the
// field-key DTO, so assertions sanitize the same way.
const expectedLogs = sanitizeSelectFields(
defaultLogsSelectedColumns as TelemetrytypesTelemetryFieldKeyDTO[],
);
@@ -30,16 +33,12 @@ function makeSpec(
} as unknown as DashboardtypesPanelSpecDTO;
}
type Props = {
enabled: boolean;
signal: TelemetrytypesSignalDTO | undefined;
spec: DashboardtypesPanelSpecDTO;
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
};
function renderWith(initial: Props): { rerender: (next: Props) => void } {
function renderWith(initial: UseSwitchColumnsOnSignalChangeArgs): {
rerender: (next: UseSwitchColumnsOnSignalChangeArgs) => void;
} {
const { rerender } = renderHook(
(props: Props) => useSwitchColumnsOnSignalChange(props),
(props: UseSwitchColumnsOnSignalChangeArgs) =>
useSwitchColumnsOnSignalChange(props),
{ initialProps: initial },
);
return { rerender };
@@ -73,6 +72,45 @@ describe('useSwitchColumnsOnSignalChange', () => {
);
});
it('restores the original columns on logs → traces → logs', () => {
// Customized logs selection, not the timestamp/body defaults.
const original = [
{ name: 'timestamp' },
{ name: 'body' },
{ name: 'response_status_code' },
{ name: 'trace_id' },
];
// Mirror the real parent: persist the spec so the next switch stashes the
// columns the previous one applied.
let spec = makeSpec(original);
const onChangeSpec = jest.fn((next: DashboardtypesPanelSpecDTO) => {
spec = next;
});
const { rerender } = renderWith({
enabled: true,
signal: TelemetrytypesSignalDTO.logs,
spec,
onChangeSpec,
});
rerender({
enabled: true,
signal: TelemetrytypesSignalDTO.traces,
spec,
onChangeSpec,
});
expect(selectFieldsOf(spec)).toStrictEqual(expectedTraces);
// Switching back restores the original columns, not the log defaults.
rerender({
enabled: true,
signal: TelemetrytypesSignalDTO.logs,
spec,
onChangeSpec,
});
expect(selectFieldsOf(spec)).toStrictEqual(original);
});
it('switches to the log defaults when going traces → logs', () => {
const onChangeSpec = jest.fn();
const spec = makeSpec([{ name: 'service.name' }]);
@@ -114,20 +152,6 @@ describe('useSwitchColumnsOnSignalChange', () => {
expect(onChangeSpec).not.toHaveBeenCalled();
});
it('does not switch on a transient undefined signal', () => {
const onChangeSpec = jest.fn();
const spec = makeSpec([{ name: 'body' }]);
const { rerender } = renderWith({
enabled: true,
signal: TelemetrytypesSignalDTO.logs,
spec,
onChangeSpec,
});
rerender({ enabled: true, signal: undefined, spec, onChangeSpec });
expect(onChangeSpec).not.toHaveBeenCalled();
});
it('does nothing when disabled (non-List kinds)', () => {
const onChangeSpec = jest.fn();
const spec = makeSpec([{ name: 'body' }]);

View File

@@ -40,7 +40,7 @@ export function useLegendSeries(
getTimeSeriesResults(data?.response),
data.legendMap,
);
const builderQueries = getBuilderQueries(panel?.spec?.queries);
const builderQueries = getBuilderQueries(panel?.spec?.queries || []);
const byLabel = new Map<string, string>();
series.forEach((s) => {

View File

@@ -2,8 +2,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { getIsQueryModified } from 'container/NewWidget/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
@@ -19,14 +20,21 @@ interface UsePanelEditorQuerySyncArgs {
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
/** Re-fetch the preview when the query is unchanged (Stage & Run on a no-op). */
refetch: () => void;
/**
* Serialize the live query on save even when unchanged. Set for a new panel,
* whose seed query is the builder default (not a real saved query).
*/
alwaysSerializeQuery?: boolean;
/** Signal to seed a new panel's builder with — the kind's first supported signal. */
signal?: TelemetrytypesSignalDTO;
}
interface UsePanelEditorQuerySyncApi {
/** Run the current query (Stage & Run / ⌘↵). */
runQuery: () => void;
/** True when the live builder query differs from the saved query (compared builder-normalized to avoid re-serialization noise). */
/** True when the live builder query differs from the saved query. */
isQueryDirty: boolean;
/** Bake the live query into a spec for saving so unstaged edits persist; returns the spec untouched when unchanged. */
/** Bake the live query into a spec so unstaged edits persist; unchanged → spec untouched. */
buildSaveSpec: (
spec: DashboardtypesPanelSpecDTO,
) => DashboardtypesPanelSpecDTO;
@@ -34,27 +42,36 @@ interface UsePanelEditorQuerySyncApi {
/**
* Bridges the shared (URL-synced) query builder and the V2 editor draft: seeds the
* builder from the saved panel, then commits the active query into `draft.spec.queries`
* (what the preview fetches) on a query-type/datasource switch and on Stage & Run.
* builder from the saved panel, then commits the active query into
* `draft.spec.queries` (what the preview fetches) on a query-type/datasource switch
* and on Stage & Run.
*/
export function usePanelEditorQuerySync({
draft,
panelType,
setSpec,
refetch,
alwaysSerializeQuery = false,
signal,
}: UsePanelEditorQuerySyncArgs): UsePanelEditorQuerySyncApi {
const { currentQuery, stagedQuery, handleRunQuery } = useQueryBuilder();
// Saved queries, captured once: seed the builder and serve as the restore target.
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only snapshot
const savedQueries = useMemo(() => draft.spec?.queries ?? [], []);
// A new panel has no saved query: seed from the kind's first supported signal
// instead of letting `fromPerses` fall back to the metrics default (which List
// doesn't support).
const seedQuery = useMemo(
() => fromPerses(savedQueries, panelType),
[savedQueries, panelType],
() =>
savedQueries.length === 0 && signal
? initialQueriesMap[signal]
: fromPerses(savedQueries, panelType),
[savedQueries, panelType, signal],
);
// Force-reset the builder to the SAVED panel on first render only, discarding any
// stale URL query from a prior edit — otherwise the QB and preview diverge and the
// dirty baseline gets captured from the URL. After mount the URL syncs normally.
// Force-reset the builder to the SAVED panel on first render only, discarding a
// stale URL query from a prior edit (else the QB/preview diverge and the dirty
// baseline is captured from the URL). After mount the URL syncs normally.
const isInitialRenderRef = useRef(true);
useShareBuilderUrl({
defaultValue: seedQuery,
@@ -64,11 +81,10 @@ export function usePanelEditorQuerySync({
isInitialRenderRef.current = false;
}, []);
// Commit the live query into the draft (what the preview fetches). The dirty check
// compares against the SAVED query (`seedQuery`), not the URL-synced staged query,
// which can carry stale state across a refresh and make a real switch read as
// "unchanged". Unchanged → restore saved queries; changed → commit. Returns whether
// the draft changed.
// Commit the live query into the draft (what the preview fetches). The dirty
// check compares against the SAVED query (`seedQuery`), not the URL-synced
// staged query, which can carry stale state across a refresh and read a real
// switch as "unchanged". Returns whether the draft changed.
const commitQuery = useCallback(
(query: Query): boolean => {
const next = getIsQueryModified(query, seedQuery)
@@ -76,7 +92,7 @@ export function usePanelEditorQuerySync({
: savedQueries;
// No-op guard at the V5 envelope level: equivalent wrappers (bare
// `signoz/BuilderQuery` vs `signoz/CompositeQuery`) unwrap to the same
// envelopes, so comparing them structurally would falsely dirty the draft.
// envelopes, so a structural compare would falsely dirty the draft.
const current = draft.spec?.queries ?? [];
if (isEqual(toQueryEnvelopes(next), toQueryEnvelopes(current))) {
return false;
@@ -93,8 +109,8 @@ export function usePanelEditorQuerySync({
const queryRef = useRef(currentQuery);
queryRef.current = currentQuery;
// Re-commit on a query-type or datasource switch so the preview refetches. Skip
// mount: the draft already holds the saved queries the builder is force-reset to.
// Re-commit on a query-type/datasource switch so the preview refetches. Skip
// mount: the draft already holds the saved queries the builder is reset to.
const dataSourceSignature = useMemo(
() =>
(currentQuery.builder?.queryData ?? []).map((q) => q.dataSource).join(','),
@@ -119,10 +135,10 @@ export function usePanelEditorQuerySync({
}, [handleRunQuery, commitQuery, currentQuery, refetch]);
// Dirty baseline: the builder's OWN normalized saved query (first non-null
// `stagedQuery` after the mount reset). Comparing builder-normalized to
// `stagedQuery` after the mount reset) — comparing builder-normalized to
// builder-normalized avoids serialization drift reading an untouched query as
// modified. Held in state (not a ref) so capture re-triggers `isQueryDirty`;
// captured once and never moved by Stage & Run, so it stays anchored to saved.
// modified. In state (not a ref) so capture re-triggers `isQueryDirty`; captured
// once and never moved by Stage & Run, so it stays anchored to saved.
const [queryBaseline, setQueryBaseline] = useState<Query | null>(null);
useEffect(() => {
if (queryBaseline === null && stagedQuery) {
@@ -135,10 +151,10 @@ export function usePanelEditorQuerySync({
const buildSaveSpec = useCallback(
(spec: DashboardtypesPanelSpecDTO): DashboardtypesPanelSpecDTO =>
isQueryDirty
isQueryDirty || alwaysSerializeQuery
? { ...spec, queries: toPerses(currentQuery, panelType) }
: spec,
[isQueryDirty, currentQuery, panelType],
[isQueryDirty, alwaysSerializeQuery, currentQuery, panelType],
);
return { runQuery, isQueryDirty, buildSaveSpec };

View File

@@ -1,5 +1,6 @@
import { useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { v4 as uuid } from 'uuid';
import {
getGetDashboardV2QueryKey,
usePatchDashboardV2,
@@ -7,12 +8,20 @@ import {
import {
type DashboardtypesJSONPatchOperationDTO,
type DashboardtypesPanelSpecDTO,
DashboardtypesPanelKindDTO,
DashboardtypesPatchOpDTO,
type GetDashboardV2200,
} from 'api/generated/services/sigNoz.schemas';
import { createPanelOps } from '../../patchOps';
interface UsePanelEditorSaveArgs {
dashboardId: string;
panelId: string;
/** Creating a new panel (vs editing an existing one) — adds panel + layout. */
isNew?: boolean;
/** Target section for a new panel; falls back to the last/new section. */
layoutIndex?: number;
}
interface UsePanelEditorSaveApi {
@@ -22,34 +31,49 @@ interface UsePanelEditorSaveApi {
}
/**
* Persists panel edits via a single RFC-6902 `add` op that replaces the whole panel
* spec at `/spec/panels/{panelId}/spec`, so every config-pane edit is saved (not just
* title/description). `add` doubles as create-or-replace, avoiding a separate
* existence check.
* Persists panel edits for the V2 editor via RFC-6902 JSON Patch. Editing: one
* `add` op replaces the whole spec. Creating (`isNew`): mints a fresh id and adds
* a grid item in the target section. Persists only on save — cancelling never
* touches the dashboard.
*/
export function usePanelEditorSave({
dashboardId,
panelId,
isNew = false,
layoutIndex,
}: UsePanelEditorSaveArgs): UsePanelEditorSaveApi {
const queryClient = useQueryClient();
const { mutateAsync, isLoading, error } = usePatchDashboardV2();
const save = useCallback(
async (spec: DashboardtypesPanelSpecDTO): Promise<void> => {
const ops: DashboardtypesJSONPatchOperationDTO[] = [
{
op: DashboardtypesPatchOpDTO.add,
path: `/spec/panels/${panelId}/spec`,
value: spec,
},
];
const dashboardQueryKey = getGetDashboardV2QueryKey({ id: dashboardId });
let ops: DashboardtypesJSONPatchOperationDTO[];
if (isNew) {
// Resolve the target section against the freshest dashboard we have.
const cached =
queryClient.getQueryData<GetDashboardV2200>(dashboardQueryKey);
ops = createPanelOps({
layouts: cached?.data.spec.layouts ?? [],
layoutIndex,
panelId: uuid(),
panel: { kind: DashboardtypesPanelKindDTO.Panel, spec },
});
} else {
ops = [
{
op: DashboardtypesPatchOpDTO.add,
path: `/spec/panels/${panelId}/spec`,
value: spec,
},
];
}
await mutateAsync({ pathParams: { id: dashboardId }, data: ops });
await queryClient.invalidateQueries(
getGetDashboardV2QueryKey({ id: dashboardId }),
);
await queryClient.invalidateQueries(dashboardQueryKey);
},
[dashboardId, panelId, mutateAsync, queryClient],
[dashboardId, panelId, isNew, layoutIndex, mutateAsync, queryClient],
);
return { save, isSaving: isLoading, error: (error as Error) ?? null };

View File

@@ -0,0 +1,47 @@
import { useEffect, useRef } from 'react';
import type {
DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
defaultColumnsForSignal,
readSelectFields,
writeSelectFields,
} from '../ListColumnsEditor/selectFields';
interface UseSeedNewListColumnsArgs {
/** Gate: a brand-new List panel (the only case that should auto-fill columns). */
enabled: boolean;
/** Default signal for the new panel — its kind's first supported signal. */
signal: TelemetrytypesSignalDTO;
spec: DashboardtypesPanelSpecDTO;
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
}
/**
* Seeds a brand-new List panel's columns with its default signal's columns so the
* Columns control isn't empty on first open. Runs once and only when empty: an
* empty selection is a valid "show all fields" state, so existing panels and
* user-cleared selections are never touched.
*/
export function useSeedNewListColumns({
enabled,
signal,
spec,
onChangeSpec,
}: UseSeedNewListColumnsArgs): void {
const seededRef = useRef(false);
useEffect(() => {
if (!enabled || seededRef.current || !signal) {
return;
}
// Only seed when empty — don't clobber a selection that's already present.
if (readSelectFields(spec).length > 0) {
return;
}
seededRef.current = true;
onChangeSpec(writeSelectFields(spec, defaultColumnsForSignal(signal)));
}, [enabled, signal, spec, onChangeSpec]);
}

View File

@@ -1,52 +1,31 @@
import { useEffect, useRef } from 'react';
import {
type DashboardtypesPanelSpecDTO,
import type {
DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
type TelemetrytypesTelemetryFieldKeyDTO,
TelemetrytypesTelemetryFieldKeyDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
defaultLogsSelectedColumns,
defaultTraceSelectedColumns,
} from 'container/OptionsMenu/constants';
defaultColumnsForSignal,
readSelectFields,
writeSelectFields,
} from '../ListColumnsEditor/selectFields';
import { sanitizeSelectFields } from '../ListColumnsEditor/selectFields';
/**
* The datasource's default List columns (V1 parity), sanitized to the field-key
* DTO — the V1 constants carry extra keys (isIndexed) the save contract rejects.
* Other signals (metrics) don't produce a list, so they clear the selection.
*/
function defaultColumnsForSignal(
signal: TelemetrytypesSignalDTO,
): TelemetrytypesTelemetryFieldKeyDTO[] {
if (signal === TelemetrytypesSignalDTO.logs) {
return sanitizeSelectFields(
defaultLogsSelectedColumns as TelemetrytypesTelemetryFieldKeyDTO[],
);
}
if (signal === TelemetrytypesSignalDTO.traces) {
return sanitizeSelectFields(
defaultTraceSelectedColumns as TelemetrytypesTelemetryFieldKeyDTO[],
);
}
return [];
}
interface UseSwitchColumnsOnSignalChangeArgs {
export interface UseSwitchColumnsOnSignalChangeArgs {
/** Gate so the switch only runs for the List kind (the only one with columns). */
enabled: boolean;
/** The panel's current telemetry signal (logs / traces / metrics). */
signal: TelemetrytypesSignalDTO | undefined;
signal: TelemetrytypesSignalDTO;
spec: DashboardtypesPanelSpecDTO;
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
}
/**
* Switches the List panel's chosen columns to the new datasource's defaults when
* the panel's telemetry signal changes (e.g. logs → traces). V1 kept a separate
* field list per datasource; V2 stores a single `selectFields`, so columns picked
* for one signal are meaningless after switching — replace them with the new
* source's sensible defaults (matching V1's logs/traces list defaults).
* Swaps the List panel's columns when the telemetry signal changes. V2 stores a
* single `selectFields`, so each signal's columns are stashed and restored on
* switch-back; a signal seen for the first time gets the datasource defaults (V1
* parity).
*/
export function useSwitchColumnsOnSignalChange({
enabled,
@@ -55,28 +34,28 @@ export function useSwitchColumnsOnSignalChange({
onChangeSpec,
}: UseSwitchColumnsOnSignalChangeArgs): void {
const prevSignalRef = useRef(signal);
const columnsBySignalRef = useRef<
Map<string, TelemetrytypesTelemetryFieldKeyDTO[]>
>(new Map());
useEffect(() => {
const prev = prevSignalRef.current;
prevSignalRef.current = signal;
if (!enabled) {
return;
}
// Only an actual switch between two known signals swaps the columns;
// transient `undefined` states (mid query-edit) leave the selection intact.
if (!prev || !signal || prev === signal) {
const prev = prevSignalRef.current;
// Track only real signals: a transient `undefined` (mid query-edit) must
// not become `prev`, or stash/restore would lose a step.
prevSignalRef.current = signal;
if (!prev || prev === signal) {
return;
}
onChangeSpec({
...spec,
plugin: {
...spec.plugin,
spec: {
...spec.plugin.spec,
selectFields: defaultColumnsForSignal(signal),
},
},
} as DashboardtypesPanelSpecDTO);
// Stash the leaving signal's columns; restore the entering one's, or its
// datasource defaults the first time it's seen.
columnsBySignalRef.current.set(prev, readSelectFields(spec));
const restored =
columnsBySignalRef.current.get(signal) ?? defaultColumnsForSignal(signal);
onChangeSpec(writeSelectFields(spec, restored));
}, [enabled, signal, spec, onChangeSpec]);
}

View File

@@ -6,8 +6,8 @@ import {
useDefaultLayout,
} from '@signozhq/ui/resizable';
import { toast } from '@signozhq/ui/sonner';
import type {
DashboardtypesPanelDTO,
import {
type DashboardtypesPanelDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
@@ -29,6 +29,7 @@ import { usePanelQuery } from '../hooks/usePanelQuery';
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
import { usePanelEditorQuerySync } from './hooks/usePanelEditorQuerySync';
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
import { useSwitchColumnsOnSignalChange } from './hooks/useSwitchColumnsOnSignalChange';
import { useTableColumns } from './hooks/useTableColumns';
import ListColumnsEditor from './ListColumnsEditor/ListColumnsEditor';
@@ -39,6 +40,10 @@ interface PanelEditorContainerProps {
dashboardId: string;
panelId: string;
panel: DashboardtypesPanelDTO;
/** Creating a new panel (seeded default) vs editing an existing one. */
isNew?: boolean;
/** Target section for a new panel; falls back to the last/new section. */
layoutIndex?: number;
/** Leave the editor (navigate back to the dashboard) without saving. */
onClose: () => void;
/** Called after a successful save — navigates back to the dashboard. */
@@ -46,19 +51,26 @@ interface PanelEditorContainerProps {
}
/**
* V2 panel editor page body (rendered full-page by `PanelEditorPage`): a resizable
* split with the live preview + query builder on the left and the config pane on the
* right. Owns the draft state and the save round-trip.
* V2 panel editor page body: a resizable split with the live preview + query
* builder on the left and the config pane on the right. Owns the draft state and
* the save round-trip.
*/
function PanelEditorContainer({
dashboardId,
panelId,
panel,
isNew = false,
layoutIndex,
onClose,
onSaved,
}: PanelEditorContainerProps): JSX.Element {
const { draft, spec, setSpec, isSpecDirty } = usePanelEditorDraft(panel);
const { save, isSaving } = usePanelEditorSave({ dashboardId, panelId });
const { save, isSaving } = usePanelEditorSave({
dashboardId,
panelId,
isNew,
layoutIndex,
});
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: 'panel-editor-v2',
storage: layoutStorage,
@@ -79,47 +91,41 @@ function PanelEditorContainer({
PANEL_TYPES.TIME_SERIES;
// One shared query result for the whole editor; the preview renders it.
const panelDef = getPanelDefinition(draft.spec.plugin.kind);
const {
data,
isLoading,
isFetching,
error,
cancelQuery,
refetch,
pagination,
} = usePanelQuery({
panel: draft,
panelId,
enabled: !!panelDef,
});
const panelDefinition = getPanelDefinition(draft.spec.plugin.kind);
const { data, isFetching, error, cancelQuery, refetch, pagination } =
usePanelQuery({
panel: draft,
panelId,
enabled: !!panelDefinition,
});
// A new panel's default signal (its kind's first supported) — seeds the query and columns.
const defaultSignal = panelDefinition.supportedSignals[0];
// Seed the shared query builder from the draft and expose the Stage-&-Run action.
const { runQuery, isQueryDirty, buildSaveSpec } = usePanelEditorQuerySync({
draft,
panelType,
setSpec,
refetch,
// New panel's seed query is the builder default, not a real saved query —
// always serialize it on save.
alwaysSerializeQuery: isNew,
signal: defaultSignal,
});
// Spec and query dirtiness are tracked independently so query re-serialization
// never false-dirties.
const isDirty = isSpecDirty || isQueryDirty;
// The List panel edits its columns below the query builder (V1 parity), so the
// editor container resolves the committed query's signal once and shares it
// with both the columns control and the datasource-switch effect below.
// never false-dirties. A new panel is always savable (you're creating it).
const isDirty = isNew || isSpecDirty || isQueryDirty;
const isListPanel = fullKind === 'signoz/ListPanel';
// The builder-query `signal` literal matches the TelemetrytypesSignalDTO enum
// values; cast at this boundary (as ConfigPane does) so the columns editor's
// field-key lookup is typed.
const listSignal = getBuilderQueries(spec.queries)[0]?.signal as
| TelemetrytypesSignalDTO
| undefined;
const listSignal =
(getBuilderQueries(spec.queries || [])[0]
?.signal as TelemetrytypesSignalDTO) || TelemetrytypesSignalDTO.logs;
// When the List panel's datasource changes, swap its columns to the new
// source's defaults (V1 kept a per-datasource field list; V2 has one
// `selectFields`). Driven by the committed query's signal, so it lives in the
// editor container alongside the query sync — ConfigPane stays presentational.
// Swap the List panel's columns to the new signal's defaults on signal change
// (V1 had a per-signal field list; V2 has one `selectFields`).
useSwitchColumnsOnSignalChange({
enabled: isListPanel,
signal: listSignal,
@@ -127,6 +133,14 @@ function PanelEditorContainer({
onChangeSpec: setSpec,
});
// Seed a new List panel's default columns so the Columns control isn't empty.
useSeedNewListColumns({
enabled: isNew && isListPanel,
signal: defaultSignal,
spec,
onChangeSpec: setSpec,
});
// Drag-to-zoom on the preview updates the URL-synced time window, as on the dashboard.
const { onDragSelect } = usePanelInteractions();
const legendSeries = useLegendSeries(draft, data);
@@ -166,17 +180,19 @@ function PanelEditorContainer({
onLayoutChanged={onMainLayoutChanged}
>
<ResizablePanel minSize="55%" maxSize="65%" defaultSize="60%">
<PreviewPane
panelId={panelId}
panel={draft}
panelDef={panelDef}
data={data}
isLoading={isLoading}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
pagination={pagination}
/>
{panelDefinition && (
<PreviewPane
panelId={panelId}
panel={draft}
panelDefinition={panelDefinition}
data={data}
isFetching={isFetching}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
pagination={pagination}
/>
)}
</ResizablePanel>
<ResizableHandle withHandle className={styles.handle} />
<ResizablePanel minSize="35%" maxSize="45%" defaultSize="40%">

View File

@@ -0,0 +1,48 @@
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from '../Panels/types/panelKind';
// New (unsaved) panels share a fixed id segment, carrying kind + target section
// in the query: `/panel/new?panelKind=signoz/ListPanel&layoutIndex=2`. The real
// id is generated on save.
export const NEW_PANEL_ID = 'new';
const PANEL_KIND_PARAM = 'panelKind';
const LAYOUT_INDEX_PARAM = 'layoutIndex';
/** Query string (incl. leading `?`) for the new-panel editor route. */
export function newPanelSearch(
panelKind: PanelKind,
layoutIndex?: number,
): string {
const params = new URLSearchParams({ [PANEL_KIND_PARAM]: panelKind });
if (layoutIndex !== undefined) {
params.set(LAYOUT_INDEX_PARAM, String(layoutIndex));
}
return `?${params.toString()}`;
}
/**
* The PanelKind a `panel/new` route is creating, or null when the id isn't the
* new-panel sentinel or the `panelKind` param is missing/unknown (stale link).
*/
export function parseNewPanelKind(
panelId: string,
search: string,
): PanelKind | null {
if (panelId !== NEW_PANEL_ID) {
return null;
}
const kind = new URLSearchParams(search).get(PANEL_KIND_PARAM);
return kind && kind in PANEL_KIND_TO_PANEL_TYPE ? (kind as PanelKind) : null;
}
/** Target section index for a new panel, or undefined when unset/invalid. */
export function parseNewPanelLayoutIndex(search: string): number | undefined {
const raw = new URLSearchParams(search).get(LAYOUT_INDEX_PARAM);
if (raw === null || raw === '') {
return undefined;
}
const n = Number(raw);
return Number.isNaN(n) ? undefined : n;
}

View File

@@ -1,11 +0,0 @@
.noData {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
.noDataText {
font-size: 14px;
}

View File

@@ -1,27 +1,39 @@
import { Typography } from '@signozhq/ui/typography';
import { Clock, RotateCw } from '@signozhq/icons';
import styles from './NoData.module.scss';
import PanelMessage from '../PanelMessage/PanelMessage';
interface NoDataProps {
/** Message to display. Defaults to "No data". */
label?: string;
/** Title override. Defaults to the time-range empty-state copy. */
title?: string;
/** Description override. Defaults to the "widen the range" hint. */
description?: string;
/** When provided, renders a Retry button that re-runs the query. */
onRetry?: () => void;
'data-testid'?: string;
}
/**
* Shared empty-state for panel renderers, shown when a query resolves but
* returns nothing to plot. Centred in the panel body so every panel kind
* surfaces the same "No data" affordance instead of each renderer (or its
* underlying chart) inventing its own copy and casing.
* Shared empty-state for panel renderers: wraps `PanelMessage` so every panel
* kind surfaces the same "no data" affordance when a query returns nothing.
*/
function NoData({
label = 'No data',
title = 'No data in this time range',
description = 'Nothing in the selected window. Try widening the range.',
onRetry,
'data-testid': testId = 'panel-no-data',
}: NoDataProps): JSX.Element {
return (
<div className={styles.noData} data-testid={testId}>
<Typography.Text className={styles.noDataText}>{label}</Typography.Text>
</div>
<PanelMessage
icon={<Clock size={18} />}
title={title}
description={description}
action={
onRetry
? { label: 'Retry', onClick: onRetry, icon: <RotateCw size={14} /> }
: undefined
}
data-testid={testId}
/>
);
}

View File

@@ -0,0 +1,50 @@
// Centred, vertically-stacked panel state (no query / no data / error). Fills
// the panel body below the header and centres its content both axes.
.message {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 16px;
text-align: center;
min-height: 0;
min-width: 0;
}
// Muted glyph in a soft tinted disc so the icon reads as decorative chrome
// rather than an actionable control.
.icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
margin-bottom: 4px;
border-radius: 8px;
color: var(--l2-foreground);
background: var(--l2-background);
}
.iconDanger {
color: var(--bg-cherry-500);
background: var(--bg-cherry-500-transparent, rgba(231, 64, 64, 0.12));
}
.title {
font-size: 13px;
font-weight: 500;
color: var(--l1-foreground);
}
.description {
font-size: 12px;
color: var(--l2-foreground);
max-width: 280px;
overflow-wrap: anywhere;
}
.action {
margin-top: 8px;
}

View File

@@ -0,0 +1,68 @@
import type { ReactElement, ReactNode } from 'react';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import styles from './PanelMessage.module.scss';
export interface PanelMessageAction {
label: string;
onClick: () => void;
/** Optional leading icon for the action button. */
icon?: ReactElement;
}
interface PanelMessageProps {
/** Glyph shown above the title — sets the state's visual identity. */
icon: ReactNode;
title: string;
/** Secondary line explaining the state / suggesting a next step. */
description?: string;
/** Optional call-to-action (e.g. Retry). Omitted → no button. */
action?: PanelMessageAction;
/** `danger` tints the icon for failure states; `neutral` for empty states. */
tone?: 'neutral' | 'danger';
'data-testid'?: string;
}
/**
* Shared centred panel state (icon + title + optional description/action) so the
* no-query / no-data / error states stay visually consistent across call sites.
*/
function PanelMessage({
icon,
title,
description,
action,
tone = 'neutral',
'data-testid': testId,
}: PanelMessageProps): JSX.Element {
return (
<div className={styles.message} data-testid={testId}>
<div className={cx(styles.icon, { [styles.iconDanger]: tone === 'danger' })}>
{icon}
</div>
<Typography.Text className={styles.title}>{title}</Typography.Text>
{description && (
<Typography.Text className={styles.description}>
{description}
</Typography.Text>
)}
{action && (
<Button
variant="outlined"
color="secondary"
size="sm"
prefix={action.icon}
onClick={action.onClick}
className={styles.action}
data-testid={testId ? `${testId}-action` : undefined}
>
{action.label}
</Button>
)}
</div>
);
}
export default PanelMessage;

View File

@@ -0,0 +1,46 @@
import { fireEvent, render, screen } from '@testing-library/react';
import PanelMessage from '../PanelMessage';
describe('PanelMessage', () => {
it('renders the icon, title and description', () => {
render(
<PanelMessage
icon={<svg data-testid="icon" />}
title="Nothing to visualize yet"
description="This panel has no query."
data-testid="panel-state"
/>,
);
expect(screen.getByTestId('panel-state')).toBeInTheDocument();
expect(screen.getByTestId('icon')).toBeInTheDocument();
expect(screen.getByText('Nothing to visualize yet')).toBeInTheDocument();
expect(screen.getByText('This panel has no query.')).toBeInTheDocument();
});
it('renders no action button when no action is provided', () => {
render(
<PanelMessage icon={null} title="No data" data-testid="panel-state" />,
);
expect(screen.queryByTestId('panel-state-action')).not.toBeInTheDocument();
});
it('renders the action button and fires onClick when pressed', () => {
const onClick = jest.fn();
render(
<PanelMessage
icon={null}
title="Couldnt load panel data"
action={{ label: 'Retry', onClick }}
data-testid="panel-error"
/>,
);
const button = screen.getByTestId('panel-error-action');
expect(button).toHaveTextContent('Retry');
fireEvent.click(button);
expect(onClick).toHaveBeenCalledTimes(1);
});
});

View File

@@ -32,6 +32,7 @@ function BarPanelRenderer({
panelId,
panel,
data,
refetch,
onClick,
onDragSelect,
dashboardPreference,
@@ -42,14 +43,13 @@ function BarPanelRenderer({
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
// The registry guarantees the kind, so the cast is a boundary narrowing.
const spec = useMemo<DashboardtypesBarChartPanelSpecDTO>(
() => panel.spec.plugin.spec as DashboardtypesBarChartPanelSpecDTO,
() => panel.spec.plugin.spec,
[panel.spec.plugin.spec],
);
const builderQueries = useMemo(
() => getBuilderQueries(panel.spec.queries),
() => getBuilderQueries(panel.spec.queries || []),
[panel.spec.queries],
);
@@ -138,7 +138,7 @@ function BarPanelRenderer({
data-testid="bar-panel-renderer"
className={PanelStyles.panelContainer}
>
{flatSeries.length === 0 && <NoData />}
{flatSeries.length === 0 && <NoData onRetry={refetch} />}
{flatSeries.length > 0 &&
containerDimensions.width > 0 &&
containerDimensions.height > 0 && (

View File

@@ -1,15 +1,18 @@
import { DataSource } from 'types/common/queryBuilder';
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
kind: 'signoz/BarChartPanel',
displayName: 'Bar Chart',
Renderer,
sections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
actions: {
view: true,
edit: true,

View File

@@ -26,6 +26,7 @@ function HistogramPanelRenderer({
panelId,
panel,
data,
refetch,
panelMode,
onClick,
}: PanelRendererProps<'signoz/HistogramPanel'>): JSX.Element {
@@ -34,14 +35,13 @@ function HistogramPanelRenderer({
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
// The registry guarantees the kind, so the cast is a boundary narrowing.
const spec = useMemo<DashboardtypesHistogramPanelSpecDTO>(
() => panel.spec.plugin.spec as DashboardtypesHistogramPanelSpecDTO,
() => panel.spec.plugin.spec,
[panel.spec.plugin.spec],
);
const builderQueries = useMemo(
() => getBuilderQueries(panel.spec.queries),
() => getBuilderQueries(panel.spec.queries || []),
[panel.spec.queries],
);
@@ -113,7 +113,7 @@ function HistogramPanelRenderer({
data-testid="histogram-panel-renderer"
className={PanelStyles.panelContainer}
>
{flatSeries.length === 0 && <NoData />}
{flatSeries.length === 0 && <NoData onRetry={refetch} />}
{flatSeries.length > 0 &&
containerDimensions.width > 0 &&
containerDimensions.height > 0 && (

View File

@@ -1,15 +1,18 @@
import { DataSource } from 'types/common/queryBuilder';
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
kind: 'signoz/HistogramPanel',
displayName: 'Histogram',
Renderer,
sections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
actions: {
view: true,
edit: true,

View File

@@ -26,14 +26,14 @@ import { useListRowInteraction } from './useListRowInteraction';
import styles from './ListPanel.module.scss';
// `body` flexes to fill the remaining table width (module-level so the resize
// hook's memo dependency stays referentially stable across renders).
// `body` flexes to fill remaining width; module-level to stay referentially stable for the resize hook's memo.
const BODY_FLEX_COLUMNS = ['body'];
function ListPanelRenderer({
panelId,
panel,
data,
refetch,
searchTerm = '',
pagination,
}: PanelRendererProps<'signoz/ListPanel'>): JSX.Element {
@@ -42,19 +42,17 @@ function ListPanelRenderer({
const { height } = useResizeObserver(containerRef);
const { scrollY } = useMemo(() => computeTableLayout(height), [height]);
// The registry guarantees this Renderer only runs for `signoz/ListPanel`, so
// the cast is a documented boundary narrowing.
// `panel` is narrowed to this kind by PanelRendererProps, so no cast needed.
const spec = useMemo<DashboardtypesListPanelSpecDTO>(
() => (panel.spec.plugin.spec ?? {}) as DashboardtypesListPanelSpecDTO,
() => panel.spec.plugin.spec,
[panel.spec.plugin.spec],
);
// Telemetry signal of the panel's first builder query drives data flattening,
// per-signal cell rendering, and the row-click behavior (log drawer vs trace
// navigation). Cast at this boundary (the query carries the same string values).
// Telemetry signal of the first builder query; drives flattening, cell rendering,
// and row-click behavior. Cast is safe — the query carries the same string values.
const signal = useMemo(
() =>
(getBuilderQueries(panel.spec.queries)[0]
(getBuilderQueries(panel.spec.queries || [])[0]
?.signal as TelemetrytypesSignalDTO) || TelemetrytypesSignalDTO.logs,
[panel.spec.queries],
);
@@ -83,7 +81,7 @@ function ListPanelRenderer({
[table, signal, formatTimezoneAdjustedTimestamp],
);
// User-resizable columns, persisted per panel; `body` flexes to fill width.
// User-resizable columns, persisted per panel.
const { columns: resizableColumns, components } = useResizableColumns({
panelId,
columns,
@@ -92,8 +90,7 @@ function ListPanelRenderer({
const dataSource = useMemo(() => table?.rows ?? [], [table]);
// Header search filters the current page client-side (V1 parity); paging
// across pages is server-side via `pagination`.
// Header search filters the current page client-side (V1 parity); cross-page paging is server-side via `pagination`.
const filteredDataSource = useMemo(
() => filterTableRows(dataSource, searchTerm),
[dataSource, searchTerm],
@@ -115,8 +112,7 @@ function ListPanelRenderer({
[spec.selectFields],
);
// Show the footer whenever the panel pages server-side (no explicit query
// limit), so the page-size picker is always reachable — V1 parity.
// Show the footer whenever the panel pages server-side, so the page-size picker stays reachable (V1 parity).
const showPager = !!pagination;
return (
@@ -126,7 +122,7 @@ function ListPanelRenderer({
className={PanelStyles.panelContainer}
>
{!table || dataSource.length === 0 ? (
<NoData />
<NoData onRetry={refetch} />
) : (
<>
<div
@@ -142,9 +138,7 @@ function ListPanelRenderer({
components={components}
dataSource={filteredDataSource}
pagination={false}
// Scroll the body vertically only — no `x: 'max-content'`, which
// forced a content-width min and pushed columns off-screen;
// `tableLayout="fixed"` fits them to the available width.
// Vertical scroll only; `x: 'max-content'` forced a content-width min that pushed columns off-screen.
scroll={{ y: scrollY }}
onRow={onRow}
/>

View File

@@ -1,6 +1,5 @@
import {
type DashboardtypesListPanelSpecDTO,
type DashboardtypesPanelDTO,
type QueryRangeV5200,
} from 'api/generated/services/sigNoz.schemas';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
@@ -10,16 +9,19 @@ import type {
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { fireEvent, render } from 'tests/test-utils';
import { BaseRendererProps } from '../../../types/rendererProps';
import type {
PanelOfKind,
PanelRendererProps,
} from '../../../types/rendererProps';
import ListPanelRenderer from '../Renderer';
function panelWith(
spec: DashboardtypesListPanelSpecDTO,
): DashboardtypesPanelDTO {
): PanelOfKind<'signoz/ListPanel'> {
return {
kind: 'Panel',
spec: { plugin: { kind: 'signoz/ListPanel', spec } },
} as unknown as DashboardtypesPanelDTO;
} as unknown as PanelOfKind<'signoz/ListPanel'>;
}
// V5 raw response: one result carrying flattened log rows.
@@ -49,13 +51,13 @@ const emptyData: PanelQueryData = {
};
function renderPanel(
props: Partial<BaseRendererProps>,
props: Partial<PanelRendererProps<'signoz/ListPanel'>>,
): ReturnType<typeof render> {
const baseProps: BaseRendererProps = {
const baseProps: PanelRendererProps<'signoz/ListPanel'> = {
panelId: 'panel-1',
panel: panelWith({}),
data: emptyData,
isLoading: false,
isFetching: false,
error: null,
panelMode: PanelMode.DASHBOARD_VIEW,
...props,

View File

@@ -1,15 +1,17 @@
import { DataSource } from 'types/common/queryBuilder';
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
export const definition: PanelDefinition<'signoz/ListPanel'> = {
kind: 'signoz/ListPanel',
displayName: 'List',
Renderer,
// Raw records come from logs and traces; metrics don't produce row data.
supportedSignals: [DataSource.LOGS, DataSource.TRACES],
supportedSignals: [
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
sections,
actions: {
view: true,

View File

@@ -1,5 +1,3 @@
import type { SectionConfig } from '../../types/sections';
// List columns are edited below the query builder, not in the config pane, so
// only Context Links shows here.
export const sections: SectionConfig[] = [{ kind: 'contextLinks' }];
export const sections: SectionConfig[] = [];

View File

@@ -16,10 +16,10 @@ import ValueDisplay from './components/ValueDisplay/ValueDisplay';
function NumberPanelRenderer({
panel,
data,
refetch,
}: PanelRendererProps<'signoz/NumberPanel'>): JSX.Element {
// The registry guarantees the kind, so the cast is a boundary narrowing.
const spec = useMemo<DashboardtypesNumberPanelSpecDTO>(
() => panel.spec.plugin.spec as DashboardtypesNumberPanelSpecDTO,
() => panel.spec.plugin.spec,
[panel.spec.plugin.spec],
);
@@ -60,7 +60,7 @@ function NumberPanelRenderer({
className={PanelStyles.panelContainer}
>
{value === null ? (
<NoData data-testid="number-panel-no-data" />
<NoData data-testid="number-panel-no-data" onRetry={refetch} />
) : (
<ValueDisplay
value={formattedValue}

View File

@@ -93,7 +93,7 @@ function renderPanel(
panelId: 'panel-1',
panel: panelWith({}),
data: emptyData,
isLoading: false,
isFetching: false,
error: null,
panelMode: PanelMode.DASHBOARD_VIEW,
...props,

View File

@@ -1,15 +1,18 @@
import { DataSource } from 'types/common/queryBuilder';
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
export const definition: PanelDefinition<'signoz/NumberPanel'> = {
kind: 'signoz/NumberPanel',
displayName: 'Number',
Renderer,
sections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
actions: {
view: true,
edit: true,

View File

@@ -20,13 +20,13 @@ function PiePanelRenderer({
panelId,
panel,
data,
refetch,
onClick,
}: PanelRendererProps<'signoz/PieChartPanel'>): JSX.Element {
const isDarkMode = useIsDarkMode();
// The registry guarantees the kind, so the cast is a boundary narrowing.
const spec = useMemo<DashboardtypesPieChartPanelSpecDTO>(
() => panel.spec.plugin.spec as DashboardtypesPieChartPanelSpecDTO,
() => panel.spec.plugin.spec,
[panel.spec.plugin.spec],
);
@@ -70,7 +70,7 @@ function PiePanelRenderer({
return (
<div data-testid="pie-panel-renderer" className={PanelStyles.panelContainer}>
{slices.length === 0 ? (
<NoData />
<NoData onRetry={refetch} />
) : (
<Pie
data={slices}

View File

@@ -1,15 +1,18 @@
import { DataSource } from 'types/common/queryBuilder';
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
kind: 'signoz/PieChartPanel',
displayName: 'Pie Chart',
Renderer,
sections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
actions: {
view: true,
edit: true,

View File

@@ -25,10 +25,10 @@ function TablePanelRenderer({
panelId,
panel,
data,
refetch,
searchTerm = '',
}: PanelRendererProps<'signoz/TablePanel'>): JSX.Element {
// Measure the panel so each page roughly fills it (min 10 rows) and the
// header stays pinned while the body scrolls.
// Measure the panel so each page roughly fills it (min 10 rows) with a pinned header.
const containerRef = useRef<HTMLDivElement>(null);
const { height } = useResizeObserver(containerRef);
const { pageSize, scrollY } = useMemo(
@@ -36,12 +36,9 @@ function TablePanelRenderer({
[height],
);
// The registry guarantees this Renderer only runs when
// `panel.spec.plugin.kind === 'signoz/TablePanel'`, so the cast is a
// documented boundary narrowing. Memoized so the `?? {}` fallback doesn't
// produce a fresh object on each render.
// `panel` is narrowed to this kind by PanelRendererProps, so no cast needed.
const spec = useMemo<DashboardtypesTablePanelSpecDTO>(
() => (panel.spec.plugin.spec ?? {}) as DashboardtypesTablePanelSpecDTO,
() => panel.spec.plugin.spec,
[panel.spec.plugin.spec],
);
@@ -92,15 +89,13 @@ function TablePanelRenderer({
[table],
);
// Header search filters rows client-side (V1 parity). Falls back to the full
// set when the term is empty, so non-searching tables pay nothing.
// Header search filters rows client-side (V1 parity); empty term returns the full set, so non-searching tables pay nothing.
const filteredDataSource = useMemo(
() => filterTableRows(dataSource, searchTerm),
[dataSource, searchTerm],
);
// Keep pagination in range as the filtered set shrinks: a new term snaps back
// to the first page so the user never lands on a now-empty page.
// Snap back to page 1 on a new search term so the filtered set never lands on a now-empty page.
const [page, setPage] = useState(1);
useEffect(() => setPage(1), [searchTerm]);
@@ -111,7 +106,7 @@ function TablePanelRenderer({
className={PanelStyles.panelContainer}
>
{!table || dataSource.length === 0 ? (
<NoData />
<NoData onRetry={refetch} />
) : (
<div className={styles.container}>
<Table

View File

@@ -1,5 +1,4 @@
import {
type DashboardtypesPanelDTO,
type DashboardtypesTablePanelSpecDTO,
type QueryRangeV5200,
} from 'api/generated/services/sigNoz.schemas';
@@ -7,16 +6,19 @@ import { PanelMode } from 'container/DashboardContainer/visualization/panels/typ
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { render } from 'tests/test-utils';
import { BaseRendererProps } from '../../../types/rendererProps';
import type {
PanelOfKind,
PanelRendererProps,
} from '../../../types/rendererProps';
import TablePanelRenderer from '../Renderer';
function panelWith(
spec: DashboardtypesTablePanelSpecDTO,
): DashboardtypesPanelDTO {
): PanelOfKind<'signoz/TablePanel'> {
return {
kind: 'Panel',
spec: { plugin: { kind: 'signoz/TablePanel', spec } },
} as unknown as DashboardtypesPanelDTO;
} as unknown as PanelOfKind<'signoz/TablePanel'>;
}
// V5 scalar response: one joined result with a group column + an aggregation column.
@@ -60,13 +62,13 @@ const emptyData: PanelQueryData = {
};
function renderPanel(
props: Partial<BaseRendererProps>,
props: Partial<PanelRendererProps<'signoz/TablePanel'>>,
): ReturnType<typeof render> {
const baseProps: BaseRendererProps = {
const baseProps: PanelRendererProps<'signoz/TablePanel'> = {
panelId: 'panel-1',
panel: panelWith({}),
data: emptyData,
isLoading: false,
isFetching: false,
error: null,
panelMode: PanelMode.DASHBOARD_VIEW,
...props,

View File

@@ -1,15 +1,18 @@
import { DataSource } from 'types/common/queryBuilder';
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
export const definition: PanelDefinition<'signoz/TablePanel'> = {
kind: 'signoz/TablePanel',
displayName: 'Table',
Renderer,
sections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
// Tables carry tabular data worth exporting (V1 parity: download is table-only).
actions: {
view: true,

View File

@@ -32,6 +32,7 @@ function TimeSeriesPanelRenderer({
panelId,
panel,
data,
refetch,
onClick,
onDragSelect,
dashboardPreference,
@@ -42,15 +43,13 @@ function TimeSeriesPanelRenderer({
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
// The registry guarantees the kind, so the cast is a boundary narrowing.
// Memoized so the `?? {}` fallback doesn't produce a fresh object each render.
const spec = useMemo<DashboardtypesTimeSeriesPanelSpecDTO>(
() => (panel.spec.plugin.spec ?? {}) as DashboardtypesTimeSeriesPanelSpecDTO,
() => panel.spec.plugin.spec,
[panel.spec.plugin.spec],
);
const builderQueries = useMemo(
() => getBuilderQueries(panel.spec.queries),
() => getBuilderQueries(panel.spec.queries || []),
[panel.spec.queries],
);
@@ -140,7 +139,7 @@ function TimeSeriesPanelRenderer({
data-testid="time-series-renderer"
className={PanelStyles.panelContainer}
>
{flatSeries.length === 0 && <NoData />}
{flatSeries.length === 0 && <NoData onRetry={refetch} />}
{flatSeries.length > 0 &&
containerDimensions.width > 0 &&
containerDimensions.height > 0 && (

View File

@@ -1,15 +1,18 @@
import { DataSource } from 'types/common/queryBuilder';
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
kind: 'signoz/TimeSeriesPanel',
displayName: 'Time Series',
Renderer,
sections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
actions: {
view: true,
edit: true,

View File

@@ -11,9 +11,7 @@ import type {
} from './types/panelDefinition';
import { PanelKind } from './types/panelKind';
// Pure assembly: each kind owns its own PanelDefinition (see
// `kinds/<Kind>/definition.ts`). Registering a new panel = add its folder and a
// single entry below — no other central file needs editing.
// Each kind owns its PanelDefinition; registering a new panel is one entry here.
export const PANELS: PanelRegistry = {
[TimeSeries.kind]: TimeSeries,
[BarChart.kind]: BarChart,
@@ -24,15 +22,8 @@ export const PANELS: PanelRegistry = {
[List.kind]: List,
};
export function getPanelDefinition(
kind: PanelKind,
): RenderablePanelDefinition | undefined {
if (!kind) {
return undefined;
}
// The registry is correlated by kind, so a string lookup yields a union over
// every kind's exactly-typed definition. The renderer cannot be validated
// against that union at the JSX boundary, so widen to the kind-agnostic
// surface here — the single, intentional cast for the whole panel system.
return PANELS[kind] as unknown as RenderablePanelDefinition | undefined;
export function getPanelDefinition(kind: PanelKind): RenderablePanelDefinition {
// Single intentional cast widening the per-kind Renderer to the kind-agnostic
// prop surface (a per-kind renderer can't be statically validated against the union).
return PANELS[kind] as RenderablePanelDefinition;
}

View File

@@ -1,5 +1,5 @@
import type { ComponentType } from 'react';
import { DataSource } from 'types/common/queryBuilder';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { SectionConfig } from './sections';
import type { AnyPanelInteractionProps } from './interactions';
@@ -35,12 +35,13 @@ export interface PanelDefinition<K extends PanelKind = PanelKind> {
displayName: string;
Renderer: ComponentType<PanelRendererProps<K>>;
sections: SectionConfig[];
supportedSignals: DataSource[];
supportedSignals: TelemetrytypesSignalDTO[];
actions: PanelActionCapabilities;
}
// Indexing with a literal kind yields that kind's exactly-typed PanelDefinition.
export type PanelRegistry = { [K in PanelKind]?: PanelDefinition<K> };
// Total over PanelKind: every kind must be registered (missing → compile error),
// so getPanelDefinition never returns undefined.
export type PanelRegistry = { [K in PanelKind]: PanelDefinition<K> };
// PanelDefinition with its Renderer widened to the kind-agnostic prop surface.
// getPanelDefinition resolves to this, concentrating the unavoidable cast in one

View File

@@ -1,4 +1,8 @@
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelPluginDTO,
DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import type {
DashboardCursorSync,
SyncTooltipFilterMode,
@@ -22,37 +26,59 @@ export interface DashboardPreference {
dashboardId?: string;
}
// Kind-agnostic props every renderer receives. Kind-specific interaction props
// are layered on per-kind by PanelRendererProps<K>.
/** Kind-agnostic props every renderer receives; kind-specific interactions are layered on by PanelRendererProps<K>. */
export interface BaseRendererProps {
panelId: string;
/**
* The whole perses panel — renderers derive `spec` and `queries` from this.
* Required: the render boundary only mounts a renderer once the panel and its
* kind are resolved, so a renderer never sees an absent panel.
*/
/** The whole panel — renderers derive `spec` and `queries` from it. Required: the render boundary only mounts once panel + kind resolve. */
panel: DashboardtypesPanelDTO;
/** Raw V5 fetch result — response + the request that produced it. */
data: PanelQueryData;
isLoading: boolean;
isFetching: boolean;
error: Error | null;
/** Re-run the panel query; wired to the no-data Retry affordance. Optional so standalone call sites (e.g. the editor preview) can omit it. */
refetch?: () => void;
/** Gate for the drill-down right-click menu. Off by default in V2. */
enableDrillDown?: boolean;
/** Render context (dashboard widget vs. standalone vs. editor); see PanelMode. */
panelMode: PanelMode;
/** Dashboard-level preferences propagated to every panel; shell resolves, renderer consumes. */
dashboardPreference?: DashboardPreference;
/**
* Free-text filter from the header search box, applied client-side. Only
* meaningful for kinds that declare `actions.search`; others ignore it.
*/
/** Free-text header filter, applied client-side. Only meaningful for kinds that declare `actions.search`. */
searchTerm?: string;
/** Server-side paging handles. Present only for raw/list panels; others ignore it. */
pagination?: PanelPagination;
}
// Renderer props for a specific kind: shared base plus that kind's interaction
// surface. Indexing PanelInteractionMap forces it to cover every PanelKind; the
// default K = PanelKind yields the widest surface (a union over all kinds).
export type PanelRendererProps<K extends PanelKind = PanelKind> =
BaseRendererProps & PanelInteractionMap[K];
// The single plugin variant for kind K, picked from the generated plugin union.
// Distributes over the union, coercing each member's nominal kind-enum to its
// string value (`${VK & string}`) to match K. K = PanelKind recovers the full union.
type PluginOfKind<K extends PanelKind> =
DashboardtypesPanelPluginDTO extends infer V
? V extends { kind: infer VK }
? `${VK & string}` extends K
? V
: never
: never
: never;
// The panel narrowed to kind K: the wire DTO with `plugin` (and `plugin.spec`)
// fixed to K's single variant, so a renderer reads `panel.spec.plugin.spec` as
// its own spec type with no cast.
export type PanelOfKind<K extends PanelKind = PanelKind> = Omit<
DashboardtypesPanelDTO,
'spec'
> & {
spec: Omit<DashboardtypesPanelSpecDTO, 'plugin'> & {
plugin: PluginOfKind<K>;
};
};
// Renderer props for kind K: the base (with `panel` narrowed to K) plus K's
// interaction surface (PanelInteractionMap[K]), so a renderer sees its exact spec
// and only the gestures it supports. The default K = PanelKind is the widest surface.
export type PanelRendererProps<K extends PanelKind = PanelKind> = Omit<
BaseRendererProps,
'panel'
> & {
panel: PanelOfKind<K>;
} & PanelInteractionMap[K];

View File

@@ -0,0 +1,67 @@
import {
DashboardtypesFillModeDTO,
DashboardtypesLegendPositionDTO,
DashboardtypesLineInterpolationDTO,
DashboardtypesLineStyleDTO,
DashboardtypesTimePreferenceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { sections as barSections } from '../../kinds/BarChartPanel/sections';
import { sections as histogramSections } from '../../kinds/HistogramPanel/sections';
import { sections as listSections } from '../../kinds/ListPanel/sections';
import { sections as timeSeriesSections } from '../../kinds/TimeSeriesPanel/sections';
import type { SectionConfig } from '../../types/sections';
import { buildDefaultPluginSpec } from '../buildDefaultPluginSpec';
describe('buildDefaultPluginSpec', () => {
it('seeds the TimeSeries dropdowns/segmented controls with their renderer defaults', () => {
expect(buildDefaultPluginSpec(timeSeriesSections)).toStrictEqual({
visualization: {
timePreference: DashboardtypesTimePreferenceDTO.global_time,
},
legend: { position: DashboardtypesLegendPositionDTO.bottom },
chartAppearance: {
lineStyle: DashboardtypesLineStyleDTO.solid,
lineInterpolation: DashboardtypesLineInterpolationDTO.spline,
fillMode: DashboardtypesFillModeDTO.none,
},
});
});
it('omits chartAppearance for a kind that does not declare it (Bar)', () => {
expect(buildDefaultPluginSpec(barSections)).toStrictEqual({
visualization: {
timePreference: DashboardtypesTimePreferenceDTO.global_time,
},
legend: { position: DashboardtypesLegendPositionDTO.bottom },
});
});
it('seeds only the legend for Histogram (no visualization section)', () => {
expect(buildDefaultPluginSpec(histogramSections)).toStrictEqual({
legend: { position: DashboardtypesLegendPositionDTO.bottom },
});
});
it('returns an empty spec for a kind with no seeded controls (List)', () => {
expect(buildDefaultPluginSpec(listSections)).toStrictEqual({});
});
it('does not seed controls that already show a clear default', () => {
// `axes` and `formatting` stay unset — their empty state is the chart default.
const sections: SectionConfig[] = [
{ kind: 'axes', controls: { minMax: true, logScale: true } },
{ kind: 'formatting', controls: { unit: true, decimals: true } },
{ kind: 'thresholds', controls: { variant: 'label' } },
{ kind: 'contextLinks' },
];
expect(buildDefaultPluginSpec(sections)).toStrictEqual({});
});
it('only seeds the legend position when the kind exposes that control', () => {
const sections: SectionConfig[] = [
{ kind: 'legend', controls: { colors: true } },
];
expect(buildDefaultPluginSpec(sections)).toStrictEqual({});
});
});

View File

@@ -0,0 +1,20 @@
import { buildDefaultQueries } from '../buildDefaultQueries';
describe('buildDefaultQueries', () => {
it('seeds a List panel with a runnable logs query ordered by timestamp desc', () => {
const queries = buildDefaultQueries('signoz/ListPanel');
expect(queries).toHaveLength(1);
// orderBy timestamp desc must survive serialization so the preview opens
// pre-sorted (V1 parity).
const serialized = JSON.stringify(queries);
expect(serialized).toContain('timestamp');
expect(serialized).toContain('desc');
expect(serialized.toLowerCase()).toContain('logs');
});
it('seeds no query for non-List kinds (they seed from the builder)', () => {
expect(buildDefaultQueries('signoz/TimeSeriesPanel')).toStrictEqual([]);
expect(buildDefaultQueries('signoz/NumberPanel')).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,50 @@
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import { getPanelQueryType } from '../getPanelQueryType';
function panelWithEnvelopes(envelopes: unknown[]): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
display: { name: 'P' },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: envelopes.length
? [
{
spec: {
plugin: { kind: 'signoz/CompositeQuery', spec: { queries: envelopes } },
},
},
]
: [],
},
} as unknown as DashboardtypesPanelDTO;
}
describe('getPanelQueryType', () => {
it('returns undefined when the panel has no query', () => {
expect(getPanelQueryType(panelWithEnvelopes([]))).toBeUndefined();
});
it('reports the builder mode for builder queries', () => {
const panel = panelWithEnvelopes([
{ type: 'builder_query', spec: { signal: 'traces', name: 'A' } },
]);
expect(getPanelQueryType(panel)).toBe(EQueryType.QUERY_BUILDER);
});
it('reports PromQL when a promql envelope is present', () => {
const panel = panelWithEnvelopes([
{ type: 'promql', spec: { query: 'up', name: 'A' } },
]);
expect(getPanelQueryType(panel)).toBe(EQueryType.PROM);
});
it('reports ClickHouse when a clickhouse_sql envelope is present', () => {
const panel = panelWithEnvelopes([
{ type: 'clickhouse_sql', spec: { query: 'SELECT 1', name: 'A' } },
]);
expect(getPanelQueryType(panel)).toBe(EQueryType.CLICKHOUSE);
});
});

View File

@@ -0,0 +1,69 @@
import {
DashboardtypesFillModeDTO,
DashboardtypesLegendPositionDTO,
DashboardtypesLineInterpolationDTO,
DashboardtypesLineStyleDTO,
DashboardtypesTimePreferenceDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { SectionConfig, SectionSpecMap } from '../types/sections';
/**
* Seeded plugin-spec slices, typed as canonical section slices so each value is
* checked against its DTO. A partial cross-section, not any single kind's spec,
* so the union cast stays localized to `createDefaultPanel`.
*/
export interface DefaultPluginSpec {
visualization?: SectionSpecMap['visualization'];
legend?: SectionSpecMap['legend'];
chartAppearance?: SectionSpecMap['chartAppearance'];
}
/**
* Seeds per-kind config defaults derived from the kind's declared `sections` so the
* config pane opens populated. Values equal the renderer fallbacks (display only).
* Controls whose empty state already IS the default are left unset.
*/
export function buildDefaultPluginSpec(
sections: SectionConfig[],
): DefaultPluginSpec {
const spec: DefaultPluginSpec = {};
sections.forEach((section) => {
switch (section.kind) {
case 'visualization':
if (section.controls.timePreference) {
spec.visualization = {
timePreference: DashboardtypesTimePreferenceDTO.global_time,
};
}
break;
case 'legend':
if (section.controls.position) {
spec.legend = { position: DashboardtypesLegendPositionDTO.bottom };
}
break;
case 'chartAppearance': {
const chartAppearance: SectionSpecMap['chartAppearance'] = {};
if (section.controls.lineStyle) {
chartAppearance.lineStyle = DashboardtypesLineStyleDTO.solid;
}
if (section.controls.lineInterpolation) {
chartAppearance.lineInterpolation =
DashboardtypesLineInterpolationDTO.spline;
}
if (section.controls.fillMode) {
chartAppearance.fillMode = DashboardtypesFillModeDTO.none;
}
if (Object.keys(chartAppearance).length > 0) {
spec.chartAppearance = chartAppearance;
}
break;
}
default:
break;
}
});
return spec;
}

View File

@@ -0,0 +1,14 @@
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import { listViewInitialLogQuery, PANEL_TYPES } from 'constants/queryBuilder';
import { toPerses } from '../../queryV5/persesQueryAdapters';
import { PANEL_KIND_TO_PANEL_TYPE, type PanelKind } from '../types/panelKind';
/** Seed query for a new panel. Only List needs one (logs, timestamp desc) so its
* preview runs on open; other kinds start empty and seed from the builder. */
export function buildDefaultQueries(kind: PanelKind): DashboardtypesQueryDTO[] {
if (PANEL_KIND_TO_PANEL_TYPE[kind] === PANEL_TYPES.LIST) {
return toPerses(listViewInitialLogQuery, PANEL_TYPES.LIST);
}
return [];
}

View File

@@ -8,7 +8,7 @@ import type { BuilderQuery } from 'types/api/v5/queryRange';
* downstream code needs. Returns the generated v5 `BuilderQuery` shape directly.
*/
export function getBuilderQueries(
queries: DashboardtypesQueryDTO[] | null | undefined,
queries: DashboardtypesQueryDTO[],
): BuilderQuery[] {
if (!queries) {
return [];

View File

@@ -0,0 +1,20 @@
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import { toQueryEnvelopes } from '../../queryV5/buildQueryRangeRequest';
import { deriveQueryType } from '../../queryV5/persesQueryAdapters';
/**
* The authoring mode (builder / ClickHouse / PromQL) of a panel's query. Returns
* `undefined` when the panel has no query yet so callers can hide query-type chrome
* (e.g. the editor preview's "Plotted with" tag) rather than defaulting to builder.
*/
export function getPanelQueryType(
panel: DashboardtypesPanelDTO,
): EQueryType | undefined {
const envelopes = toQueryEnvelopes(panel.spec.queries || []);
if (envelopes.length === 0) {
return undefined;
}
return deriveQueryType(envelopes);
}

View File

@@ -1,11 +1,12 @@
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
import dashboardEmojiUrl from '@/assets/Icons/dashboard_emoji.svg';
import landscapeUrl from '@/assets/Icons/landscape.svg';
import { useCreatePanel } from '../../hooks/useCreatePanel';
import PanelTypeSelectionModal from '../Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
import styles from './DashboardEmptyState.module.scss';
interface DashboardEmptyStateProps {
@@ -15,9 +16,8 @@ interface DashboardEmptyStateProps {
function DashboardEmptyState({
canAddPanel,
}: DashboardEmptyStateProps): JSX.Element {
const setIsPanelTypeSelectionModalOpen = usePanelTypeSelectionModalStore(
(s) => s.setIsPanelTypeSelectionModalOpen,
);
const { isPickerOpen, openPicker, closePicker, createPanel } =
useCreatePanel();
return (
<section className={styles.emptyState}>
@@ -48,7 +48,7 @@ function DashboardEmptyState({
<Button
color="primary"
prefix={<Plus size="md" />}
onClick={(): void => setIsPanelTypeSelectionModalOpen(true)}
onClick={(): void => openPicker()}
testId="add-panel"
>
New Panel
@@ -56,6 +56,11 @@ function DashboardEmptyState({
)}
</div>
</div>
<PanelTypeSelectionModal
open={isPickerOpen}
onClose={closePicker}
onSelect={createPanel}
/>
</section>
);
}

View File

@@ -1,9 +1,7 @@
import { useMemo, useState } from 'react';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { useState } from 'react';
import type {
DashboardtypesPanelDTO,
DashboardtypesTimePreferenceDTO,
DashboardtypesPanelPluginKindDTO as PanelKind,
} from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import { panelTimePreferenceLabel } from 'pages/DashboardPageV2/DashboardContainer/hooks/resolvePanelTimeWindow';
@@ -12,15 +10,12 @@ import { usePanelQuery } from 'pages/DashboardPageV2/DashboardContainer/hooks/us
import type { DashboardSection } from '../../utils';
import { usePanelInteractions } from './hooks/usePanelInteractions';
import PanelBody from './PanelBody/PanelBody';
import UnsupportedPanelBody from './PanelBody/UnsupportedPanelBody';
import PanelHeader from './PanelHeader/PanelHeader';
import styles from './Panel.module.scss';
/**
* Layout context for the panel actions menu — pure data, present only in
* editable mode. No callbacks: the menu resolves its own mutations from
* store-backed hooks (useDeletePanel / useMovePanelToSection), and edit is
* URL-driven (useOpenPanelEditor).
* Layout context for the panel actions menu — present only in editable mode. No
* callbacks: the menu resolves its own mutations from store-backed hooks.
*/
export interface PanelActionsConfig {
currentLayoutIndex: number;
@@ -37,10 +32,8 @@ interface PanelProps {
}
/**
* A single dashboard panel: chrome (header) + content (body). Thin orchestrator
* — data fetching lives in `usePanelQuery`, cross-panel interactions in
* `usePanelInteractions`, and the loading/error/chart state machine in
* `PanelBody`.
* A single dashboard panel (header + body). Thin orchestrator: fetching lives in
* `usePanelQuery`, interactions in `usePanelInteractions`, state in `PanelBody`.
*/
function Panel({
panel,
@@ -48,17 +41,15 @@ function Panel({
isVisible,
panelActions,
}: PanelProps): JSX.Element {
const name = panel.spec.display?.name;
const name = panel.spec.display.name;
const description = panel.spec.display?.description;
const fullKind = panel.spec.plugin?.kind as unknown as PanelKind;
const kind = fullKind?.replace(/^signoz\//, '') ?? 'unknown';
const queryCount = panel.spec.queries?.length ?? 0;
const fullKind = panel.spec.plugin.kind;
// A per-panel relative time preference (anything other than global_time) is
// surfaced as a pill in the header. `visualization` is common to every
// plugin-spec variant — localized cast reads it without narrowing on kind.
// A per-panel time preference is surfaced as a header pill. `visualization` is
// common to every plugin-spec variant — localized cast reads it without
// narrowing on kind.
const timePreference = (
panel.spec.plugin?.spec as
panel.spec.plugin.spec as
| { visualization?: { timePreference?: DashboardtypesTimePreferenceDTO } }
| undefined
)?.visualization?.timePreference;
@@ -66,41 +57,28 @@ function Panel({
const panelDefinition = getPanelDefinition(fullKind);
// Header search: only kinds that declare it (e.g. tables) render the box; the
// term is owned here and threaded to both the header (input) and the renderer
// (filter), the two being siblings under this orchestrator.
// Header search: only kinds that declare it render the box. The term is owned
// here and threaded to both the header (input) and renderer (filter).
const searchable = !!panelDefinition?.actions.search;
const [searchTerm, setSearchTerm] = useState('');
const { data, isLoading, isFetching, error, refetch, pagination } =
usePanelQuery({
panel,
panelId,
// Lazy: only fetch once the section is on screen (undefined → treat as
// visible) and a renderer exists for the kind.
enabled: !!panelDefinition && isVisible !== false,
});
const { data, isFetching, error, refetch, pagination } = usePanelQuery({
panel,
panelId,
// Lazy: fetch only once on screen (undefined → visible) and a renderer exists.
enabled: !!panelDefinition && isVisible !== false,
});
const { onDragSelect, dashboardPreference } = usePanelInteractions();
const headerTitle = useMemo(() => {
if (!description) {
return name;
}
return (
<TooltipSimple title={description}>
<span>{name}</span>
</TooltipSimple>
);
}, [name, description]);
return (
<div
className={styles.panel}
data-panel-visible={isVisible ? 'true' : 'false'}
>
<PanelHeader
title={headerTitle}
name={name}
description={description}
panelId={panelId}
panelKind={fullKind}
isFetching={isFetching}
@@ -112,13 +90,13 @@ function Panel({
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
/>
{panelDefinition ? (
{panelDefinition && (
<PanelBody
panelDefinition={panelDefinition}
panel={panel}
panelId={panelId}
data={data}
isLoading={isLoading}
isFetching={isFetching}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
@@ -126,9 +104,6 @@ function Panel({
searchTerm={searchable ? searchTerm : undefined}
pagination={pagination}
/>
) : (
// TODO: remove this after all panel kinds are supported
<UnsupportedPanelBody kind={kind} queryCount={queryCount} />
)}
</div>
);

View File

@@ -1,4 +1,7 @@
.trigger {
--button-width: 24px;
--button-height: 24px;
--button-padding: 4px;
display: inline-flex;
align-items: center;
justify-content: center;

View File

@@ -1,5 +1,4 @@
// Generic centred body — used by the loading indicator and the
// unsupported-kind fallback.
// Generic centred body — used by the loading indicator.
.body {
flex: 1;
display: flex;
@@ -11,10 +10,6 @@
text-align: center;
}
.bodyKind {
margin-bottom: 6px;
}
// Container for the rendered chart — fills the panel below the header and lets
// the chart shrink (min-* 0) so it resizes with the grid cell.
.chartContainer {
@@ -23,26 +18,3 @@
min-height: 0;
min-width: 0;
}
// Error state — shown only when there's no stale data to fall back to.
.error {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
text-align: center;
}
.errorIcon {
color: var(--bg-cherry-500);
}
.errorMessage {
color: var(--l2-foreground);
font-size: 12px;
max-width: 90%;
overflow-wrap: anywhere;
}

View File

@@ -1,11 +1,11 @@
import { Spin } from 'antd';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { Loader, TriangleAlert } from '@signozhq/icons';
import { Loader, RotateCw, SquarePlus, TriangleAlert } from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import PanelMessage from 'pages/DashboardPageV2/DashboardContainer/Panels/components/PanelMessage/PanelMessage';
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
import { hasRunnableQueries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/buildQueryRangeRequest';
import type {
PanelPagination,
PanelQueryData,
@@ -15,13 +15,12 @@ import { panelStatusFromError } from '../PanelStatus/utils';
import styles from './PanelBody.module.scss';
interface PanelBodyProps {
/** Resolved renderer for the panel kind — always present (`Panel` renders the
* unsupported fallback itself when none is registered). */
/** Resolved renderer for the panel kind (`Panel` handles the unsupported case). */
panelDefinition: RenderablePanelDefinition;
panel: DashboardtypesPanelDTO;
panelId: string;
data: PanelQueryData;
isLoading: boolean;
isFetching: boolean;
error: Error | null;
refetch: () => void;
onDragSelect: (start: number, end: number) => void;
@@ -36,20 +35,15 @@ interface PanelBodyProps {
}
/**
* Renders a panel whose kind has a registered renderer, as an explicit state
* machine:
*
* error + no data → error message with retry
* first load (no data) → loading indicator
* otherwise → the kind's renderer (owns its own "No Data" state and keeps
* stale data mounted during background refetches)
* Renders a panel's body as a state machine: not-configured / error+no-data /
* first-load / renderer. The renderer keeps stale data mounted across refetches.
*/
function PanelBody({
panelDefinition,
panel,
panelId,
data,
isLoading,
isFetching,
error,
refetch,
onDragSelect,
@@ -58,31 +52,44 @@ function PanelBody({
searchTerm,
pagination,
}: PanelBodyProps): JSX.Element {
// react-query keeps the previous response during background refetches, so
// `data.response` presence is the "have something to show" signal — surface a
// hard failure only when there's nothing to keep on screen.
// react-query keeps the previous response during refetches, so its presence is
// the "have something to show" signal — only fail hard when there's nothing.
const hasData = !!data.response;
const queries = panel.spec.queries || [];
if (error && !hasData) {
// Parse the API error like the header popover does, so the body shows the
// backend message (not the raw axios "status code 4xx").
const errorDetail = panelStatusFromError(error);
// Not-configured panel: no runnable query, so nothing to error/load on.
if (!hasRunnableQueries(queries)) {
return (
<div className={styles.error} data-testid="panel-error">
<TriangleAlert size={20} className={styles.errorIcon} />
<Typography.Text className={styles.errorMessage}>
{errorDetail?.message || 'Failed to load panel data'}
</Typography.Text>
<Button variant="outlined" color="secondary" onClick={refetch}>
Retry
</Button>
</div>
<PanelMessage
icon={<SquarePlus size={18} />}
title="Nothing to visualize yet"
description="This panel has no query. Add one to start plotting data."
data-testid="panel-no-query"
/>
);
}
// First load only — refetches keep the response populated so the chart stays
// mounted instead of blinking.
if (isLoading) {
if (error && !hasData) {
// Parse the API error (as the header popover does) to show the backend
// message, not the raw axios "status code 4xx".
const errorDetail = panelStatusFromError(error);
return (
<PanelMessage
icon={<TriangleAlert size={18} />}
tone="danger"
title="Couldnt load panel data"
description={errorDetail?.message || 'Something went wrong while fetching.'}
action={{
label: 'Retry',
onClick: refetch,
icon: <RotateCw size={14} />,
}}
data-testid="panel-error"
/>
);
}
if (isFetching) {
return (
<div className={styles.body} data-testid="panel-loading">
<Spin indicator={<Loader size={14} className="animate-spin" />} />
@@ -96,8 +103,9 @@ function PanelBody({
panelId={panelId}
panel={panel}
data={data}
isLoading={isLoading}
isFetching={isFetching}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
panelMode={panelMode}
enableDrillDown={false}

View File

@@ -1,30 +0,0 @@
import styles from './PanelBody.module.scss';
interface UnsupportedPanelBodyProps {
/** Short, signoz-prefix-stripped panel kind (e.g. "TablePanel"). */
kind: string;
queryCount: number;
}
/**
* Body shown when no renderer is registered for the panel's kind. Split out so
* `PanelBody` only ever runs with a resolved renderer.
*/
function UnsupportedPanelBody({
kind,
queryCount,
}: UnsupportedPanelBodyProps): JSX.Element {
return (
<div className={styles.body} data-testid="panel-unknown-kind-fallback">
<div>
<div className={styles.bodyKind}>{kind} panel</div>
<div>
{queryCount} {queryCount === 1 ? 'query' : 'queries'} · not yet supported
in V2
</div>
</div>
</div>
);
}
export default UnsupportedPanelBody;

View File

@@ -0,0 +1,66 @@
import { render, screen } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import PanelBody from '../PanelBody';
// Stub the renderer so these tests focus on PanelBody's state machine.
const MockRenderer = (): JSX.Element => <div data-testid="mock-renderer" />;
const panelDefinition = {
Renderer: MockRenderer,
} as unknown as RenderablePanelDefinition;
function panelWith(queries: unknown[]): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
display: { name: 'P' },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries,
},
} as unknown as DashboardtypesPanelDTO;
}
const baseProps = {
panelDefinition,
panelId: 'p1',
data: {} as PanelQueryData,
isFetching: false,
error: null,
refetch: jest.fn(),
onDragSelect: jest.fn(),
};
describe('PanelBody', () => {
it('shows the not-configured state when the panel has no runnable query', () => {
render(<PanelBody {...baseProps} panel={panelWith([])} />);
expect(screen.getByTestId('panel-no-query')).toBeInTheDocument();
expect(screen.getByText('Nothing to visualize yet')).toBeInTheDocument();
expect(screen.queryByTestId('mock-renderer')).not.toBeInTheDocument();
});
it('renders the kind renderer once a runnable query is present', () => {
const panel = panelWith([
{
spec: {
plugin: {
kind: 'signoz/CompositeQuery',
spec: {
queries: [
{ type: 'builder_query', spec: { signal: 'traces', name: 'A' } },
],
},
},
},
},
]);
render(<PanelBody {...baseProps} panel={panel} />);
expect(screen.getByTestId('mock-renderer')).toBeInTheDocument();
expect(screen.queryByTestId('panel-no-query')).not.toBeInTheDocument();
});
});

View File

@@ -53,3 +53,8 @@
border: 1px solid var(--l3-border);
cursor: default;
}
.descriptionTooltip {
max-width: 240px;
padding: 12px;
}

View File

@@ -1,7 +1,7 @@
import { useMemo, type ReactNode } from 'react';
import { useMemo } from 'react';
import { Info, Loader } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import type { Querybuildertypesv5QueryWarnDataDTO as WarningDTO } from 'api/generated/services/sigNoz.schemas';
import { Loader } from '@signozhq/icons';
import cx from 'classnames';
import type { PanelTimePreferenceLabel } from 'pages/DashboardPageV2/DashboardContainer/hooks/resolvePanelTimeWindow';
@@ -18,9 +18,10 @@ import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types
import { TooltipSimple } from '@signozhq/ui/tooltip';
interface PanelHeaderProps {
title: ReactNode;
name: string;
description?: string;
panelId: string;
/** Full plugin kind — drives kind-gated menu actions; */
/** Full plugin kind — drives kind-gated menu actions. */
panelKind: PanelKind;
/** Background refresh in flight — shows a spinner without blinking the chart. */
isFetching: boolean;
@@ -38,11 +39,18 @@ interface PanelHeaderProps {
searchTerm?: string;
/** Pushes a new search term up to the shell. */
onSearchChange?: (value: string) => void;
/**
* Suppress the actions menu entirely — for the editor preview, where
* panel-level actions don't apply (some survive their gates without
* `panelActions`, so omitting it isn't enough).
*/
hideActions?: boolean;
}
/** Panel chrome: drag handle, title, refetch + status indicators, actions. */
function PanelHeader({
title,
name,
description,
panelId,
panelKind,
isFetching,
@@ -53,6 +61,7 @@ function PanelHeader({
searchable,
searchTerm = '',
onSearchChange,
hideActions,
}: PanelHeaderProps): JSX.Element {
const errorDetail = useMemo(() => panelStatusFromError(error), [error]);
@@ -64,7 +73,20 @@ function PanelHeader({
return (
<div className={cx(styles.header, 'panel-drag-handle')}>
<div className={styles.headerLeft}>
<Typography.Text className={styles.headerTitle}>{title}</Typography.Text>
<Typography.Text className={styles.headerTitle}>{name}</Typography.Text>
{description && (
<TooltipSimple
title={description}
arrow
tooltipContentProps={{ className: styles.descriptionTooltip }}
>
<Info
className={styles.headerInfoIcon}
size={14}
data-testid="panel-header-info-icon"
/>
</TooltipSimple>
)}
{isFetching && (
<Loader
size={12}
@@ -73,8 +95,8 @@ function PanelHeader({
/>
)}
</div>
{/* `panel-no-drag` opts this region out of the grid drag handle so the
actions menu is clickable instead of starting a panel drag. */}
{/* `panel-no-drag` opts this region out of the drag handle so clicks hit
the controls instead of starting a panel drag. */}
<div className={cx('panel-no-drag', styles.actions)}>
{searchable && onSearchChange && (
<PanelHeaderSearch value={searchTerm ?? ''} onChange={onSearchChange} />
@@ -91,11 +113,13 @@ function PanelHeader({
<PanelStatusPopover variant="warning" detail={warningDetail} />
)}
{/* Renders nothing when no action survives its gates (kind/role/context). */}
<PanelActionsMenu
panelId={panelId}
panelKind={panelKind}
panelActions={panelActions}
/>
{!hideActions && (
<PanelActionsMenu
panelId={panelId}
panelKind={panelKind}
panelActions={panelActions}
/>
)}
</div>
</div>
);

View File

@@ -1,13 +1,14 @@
import { Modal } from 'antd';
import { Button } from '@signozhq/ui/button';
import type { PanelKind } from '../../../Panels/types/panelKind';
import { PANEL_TYPES } from './constants';
import styles from './PanelTypeSelectionModal.module.scss';
interface PanelTypeSelectionModalProps {
open: boolean;
onClose: () => void;
onSelect: (pluginKind: string) => void;
onSelect: (pluginKind: PanelKind) => void;
}
function PanelTypeSelectionModal({

View File

@@ -1,5 +1,7 @@
import type { PanelKind } from '../../../Panels/types/panelKind';
export interface PanelType {
pluginKind: string;
pluginKind: PanelKind;
label: string;
icon: JSX.Element;
}

View File

@@ -7,23 +7,23 @@ import type { Warning } from 'types/api';
import PanelHeader from '../PanelHeader/PanelHeader';
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
// PanelHeader's status indicators render a radix tooltip, which needs a
// TooltipProvider ancestor (supplied globally by AppLayout at runtime).
// Status indicators use a radix tooltip, which needs a TooltipProvider ancestor
// (supplied globally by AppLayout at runtime).
const renderWithProvider = (ui: ReactElement): ReturnType<typeof render> =>
render(<TooltipProvider>{ui}</TooltipProvider>);
// The actions menu has its own gating logic (kind/role/context) and its own
// tests; stub it so this test exercises only the header's status indicators.
// Stub the actions menu (its gating logic is tested separately) so this asserts
// only whether the menu mounts, per the `hideActions` switch.
jest.mock(
'../PanelActionsMenu/PanelActionsMenu',
() =>
function MockPanelActionsMenu(): null {
return null;
function MockPanelActionsMenu(): ReactElement {
return <div data-testid="panel-actions-menu" />;
},
);
const baseProps = {
title: 'My panel',
name: 'My panel',
panelKind: 'signoz/TimeSeriesPanel' as PanelKind,
panelId: 'panel-1',
isFetching: false,
@@ -36,6 +36,27 @@ const warning: Warning = {
warnings: [],
};
describe('PanelHeader title and description', () => {
it('renders the panel name', () => {
renderWithProvider(<PanelHeader {...baseProps} />);
expect(screen.getByText('My panel')).toBeInTheDocument();
});
it('shows the description info icon when a description is provided', () => {
renderWithProvider(
<PanelHeader {...baseProps} description="What this panel measures" />,
);
expect(screen.getByTestId('panel-header-info-icon')).toBeInTheDocument();
});
it('renders no description info icon when there is no description', () => {
renderWithProvider(<PanelHeader {...baseProps} />);
expect(
screen.queryByTestId('panel-header-info-icon'),
).not.toBeInTheDocument();
});
});
describe('PanelHeader status indicators', () => {
it('shows the error indicator whenever an error is present', () => {
renderWithProvider(<PanelHeader {...baseProps} error={new Error('boom')} />);
@@ -76,8 +97,8 @@ describe('PanelHeader search', () => {
await user.click(screen.getByTestId('panel-header-search-trigger'));
// The input is controlled to the (fixed) `searchTerm` here, so each keystroke
// reports a single character — assert one to confirm changes are propagated.
// Input is controlled to a fixed `searchTerm`, so each keystroke reports a
// single character — one is enough to confirm changes propagate.
const input = screen.getByTestId('panel-header-search-input');
await user.type(input, 'f');
expect(onSearchChange).toHaveBeenCalledWith('f');
@@ -103,6 +124,18 @@ describe('PanelHeader search', () => {
});
});
describe('PanelHeader actions menu', () => {
it('mounts the actions menu by default', () => {
renderWithProvider(<PanelHeader {...baseProps} />);
expect(screen.getByTestId('panel-actions-menu')).toBeInTheDocument();
});
it('hides the actions menu when hideActions is set (editor preview)', () => {
renderWithProvider(<PanelHeader {...baseProps} hideActions />);
expect(screen.queryByTestId('panel-actions-menu')).not.toBeInTheDocument();
});
});
describe('PanelHeader time-preference pill', () => {
it('shows the pill with the short label when the panel overrides the dashboard time', () => {
renderWithProvider(

View File

@@ -1,73 +0,0 @@
import { useCallback } from 'react';
import { v4 as uuid } from 'uuid';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import {
addPanelToSectionOps,
createDefaultPanel,
panelRef,
} from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
interface Params {
sections: DashboardSection[];
}
export interface AddPanelArgs {
layoutIndex: number;
pluginKind: string;
}
/**
* Creates a new panel and places its item ref at the bottom of the target
* section, as one atomic patch. Structure-only: the panel is a valid minimal
* placeholder (its query is filled in once the panel editor lands).
*/
export function useAddPanelToSection({
sections,
}: Params): (args: AddPanelArgs) => Promise<void> {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const { showErrorModal } = useErrorModal();
return useCallback(
async ({ layoutIndex, pluginKind }: AddPanelArgs): Promise<void> => {
const target = sections.find((s) => s.layoutIndex === layoutIndex);
if (!target) {
return;
}
const panelId = uuid();
const nextY = target.items.reduce(
(max, i) => Math.max(max, i.y + i.height),
0,
);
try {
await patchDashboardV2(
{ id: dashboardId },
addPanelToSectionOps({
panelId,
panel: createDefaultPanel(pluginKind),
layoutIndex,
item: {
x: 0,
y: nextY,
width: 6,
height: 6,
content: { $ref: panelRef(panelId) },
},
}),
);
refetch();
} catch (error) {
showErrorModal(error as APIError);
}
},
[sections, dashboardId, refetch, showErrorModal],
);
}

View File

@@ -59,7 +59,6 @@ export function useClonePanel({
}),
);
// toast.promise reports the failure, so no separate error modal here.
toast.promise(clone, {
loading: 'Cloning panel…',
success: 'Panel cloned',
@@ -67,13 +66,13 @@ export function useClonePanel({
position: 'top-center',
});
// Refetch only on success; swallow the rejection (toast owns the error
// UX) to avoid an unhandled rejection.
// Refetch only on success; toast.promise owns the error UX, so swallow
// the rejection to avoid an unhandled rejection.
try {
await clone;
refetch();
} catch {
// no-op — toast.promise owns the error UX.
// no-op
}
},
[sections, dashboardId, refetch],

View File

@@ -3,11 +3,10 @@ import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
import ConfirmDeleteDialog from '../../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
import { useCreatePanel } from '../../../hooks/useCreatePanel';
import type { DashboardSection } from '../../../utils';
import type { AddPanelArgs } from '../../Panel/hooks/useAddPanelToSection';
import PanelTypeSelectionModal from '../../Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
import { useDashboardStore } from '../../../store/useDashboardStore';
import { useDeleteSection } from '../hooks/useDeleteSection';
@@ -22,24 +21,16 @@ import styles from './Section.module.scss';
interface SectionProps {
section: DashboardSection;
/** Adds a panel to this section; present only in editable sectioned mode. */
onAddPanel?: (args: AddPanelArgs) => void;
/** All sections — layout context for the panel menu's move/delete actions. */
sections?: DashboardSection[];
/** Provided by SortableSection in sectioned mode; absent for untitled/free-flow. */
dragHandle?: SectionDragHandle;
}
function Section({
section,
onAddPanel,
sections,
dragHandle,
}: SectionProps): JSX.Element {
function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const setIsPanelTypeSelectionModalOpen = usePanelTypeSelectionModalStore(
(s) => s.setIsPanelTypeSelectionModalOpen,
);
const { isPickerOpen, openPicker, closePicker, createPanel } =
useCreatePanel();
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Placeholder signal for lazy panel query-loading (consumed in a later PR):
@@ -65,15 +56,6 @@ function Section({
[rename],
);
const [isAddingPanel, setIsAddingPanel] = useState(false);
const handleSelectPanelType = useCallback(
(pluginKind: string): void => {
onAddPanel?.({ layoutIndex: section.layoutIndex, pluginKind });
setIsAddingPanel(false);
},
[onAddPanel, section.layoutIndex],
);
const { deleteSection } = useDeleteSection({ section });
const handleDeleteSection = useCallback((): void => {
void deleteSection();
@@ -121,7 +103,7 @@ function Section({
isEditable
? {
onRename: (): void => setIsRenaming(true),
onAddPanel: (): void => setIsAddingPanel(true),
onAddPanel: (): void => openPicker(section.layoutIndex),
onDeleteSection: (): void => setIsDeleteOpen(true),
}
: undefined
@@ -138,7 +120,7 @@ function Section({
variant="dashed"
color="secondary"
prefix={<Plus size="md" />}
onClick={(): void => setIsPanelTypeSelectionModalOpen(true)}
onClick={(): void => openPicker(section.layoutIndex)}
testId={`section-add-panel-${section.id}`}
>
New Panel
@@ -156,9 +138,9 @@ function Section({
onSubmit={handleRenameSubmit}
/>
<PanelTypeSelectionModal
open={isAddingPanel}
onClose={(): void => setIsAddingPanel(false)}
onSelect={handleSelectPanelType}
open={isPickerOpen}
onClose={closePicker}
onSelect={createPanel}
/>
<ConfirmDeleteDialog
open={isDeleteOpen}

View File

@@ -11,7 +11,6 @@ import {
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
import type { DashboardSection } from '../../utils';
import { useAddPanelToSection } from '../Panel/hooks/useAddPanelToSection';
import { useDashboardStore } from '../../store/useDashboardStore';
import { useSectionDragReorder } from './hooks/useSectionDragReorder';
import Section from './Section/Section';
@@ -35,8 +34,6 @@ function SectionList({ sections, layouts }: SectionListProps): JSX.Element {
onDragCancel,
} = useSectionDragReorder({ sections, layouts });
const onAddPanel = useAddPanelToSection({ sections });
// Only titled sections participate in reordering; untitled (free-flow)
// blocks render in place without a drag handle.
const sortableIds = useMemo(
@@ -66,19 +63,9 @@ function SectionList({ sections, layouts }: SectionListProps): JSX.Element {
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
{orderedSections.map((section) =>
section.title ? (
<SortableSection
key={section.id}
section={section}
sections={sections}
onAddPanel={onAddPanel}
/>
<SortableSection key={section.id} section={section} sections={sections} />
) : (
<Section
key={section.id}
section={section}
sections={sections}
onAddPanel={onAddPanel}
/>
<Section key={section.id} section={section} sections={sections} />
),
)}
</SortableContext>

View File

@@ -2,19 +2,16 @@ import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type { DashboardSection } from '../../utils';
import type { AddPanelArgs } from '../Panel/hooks/useAddPanelToSection';
import Section from './Section/Section';
interface SortableSectionProps {
section: DashboardSection;
sections: DashboardSection[];
onAddPanel: (args: AddPanelArgs) => void;
}
function SortableSection({
section,
sections,
onAddPanel,
}: SortableSectionProps): JSX.Element {
const {
attributes,
@@ -41,7 +38,6 @@ function SortableSection({
<Section
section={section}
sections={sections}
onAddPanel={onAddPanel}
dragHandle={{ attributes, listeners, setActivatorNodeRef }}
/>
</div>

View File

@@ -0,0 +1,142 @@
import type {
DashboardGridItemDTO,
DashboardtypesLayoutDTO,
} from 'api/generated/services/sigNoz.schemas';
import { createDefaultPanel, createPanelOps } from '../patchOps';
function item(y: number, height: number): DashboardGridItemDTO {
return { x: 0, y, width: 6, height, content: { $ref: '#/spec/panels/x' } };
}
function itemAt(
x: number,
y: number,
width: number,
height: number,
): DashboardGridItemDTO {
return { x, y, width, height, content: { $ref: '#/spec/panels/x' } };
}
function section(items: DashboardGridItemDTO[]): DashboardtypesLayoutDTO {
return {
kind: 'Grid',
spec: { display: { title: 'S' }, items },
} as DashboardtypesLayoutDTO;
}
describe('createDefaultPanel', () => {
it('builds a Panel of the given kind with no queries (filled in on save)', () => {
const panel = createDefaultPanel('signoz/NumberPanel');
expect(panel.kind).toBe('Panel');
expect(panel.spec.plugin.kind).toBe('signoz/NumberPanel');
expect(panel.spec.queries).toStrictEqual([]);
expect(panel.spec.display.name).toBe('New panel');
});
});
describe('createPanelOps', () => {
const panel = createDefaultPanel('signoz/TimeSeriesPanel');
it('adds the panel + a grid item in the requested section', () => {
const layouts = [section([item(0, 6)]), section([])];
const ops = createPanelOps({ layouts, layoutIndex: 0, panelId: 'p1', panel });
expect(ops).toHaveLength(2);
expect(ops[0]).toMatchObject({ op: 'add', path: '/spec/panels/p1' });
expect(ops[1]).toMatchObject({
op: 'add',
path: '/spec/layouts/0/spec/items/-',
});
expect((ops[1].value as DashboardGridItemDTO).content?.$ref).toBe(
'#/spec/panels/p1',
);
});
it('fills the empty right half of a row instead of wrapping to a new one', () => {
// Left half filled → new 6-wide panel fits at x:6 in the same row.
const layouts = [section([item(0, 6)])];
const ops = createPanelOps({ layouts, layoutIndex: 0, panelId: 'p1', panel });
const value = ops[1].value as DashboardGridItemDTO;
expect(value.x).toBe(6);
expect(value.y).toBe(0);
});
it('wraps to a new row when the last row is full', () => {
// Full-width (12) row leaves no room → panel drops to the next row.
const layouts = [section([itemAt(0, 0, 12, 6)])];
const ops = createPanelOps({ layouts, layoutIndex: 0, panelId: 'p1', panel });
const value = ops[1].value as DashboardGridItemDTO;
expect(value.x).toBe(0);
expect(value.y).toBe(6);
});
it('ignores a gap in an upper row and only fills the last row', () => {
// Upper-row gap is ignored when the last row is full → starts a fresh row.
const layouts = [section([itemAt(0, 0, 6, 6), itemAt(0, 6, 12, 6)])];
const ops = createPanelOps({ layouts, layoutIndex: 0, panelId: 'p1', panel });
const value = ops[1].value as DashboardGridItemDTO;
expect(value.x).toBe(0);
expect(value.y).toBe(12);
});
it('fills the right of the last row when it has room', () => {
// Half-filled last row → panel sits at x:6 of that row.
const layouts = [section([itemAt(0, 0, 12, 6), itemAt(0, 6, 6, 6)])];
const ops = createPanelOps({ layouts, layoutIndex: 0, panelId: 'p1', panel });
const value = ops[1].value as DashboardGridItemDTO;
expect(value.x).toBe(6);
expect(value.y).toBe(6);
});
it('checks the last row of the target section only, not other sections', () => {
// Placement uses the target section's (1) last row, ignoring section 0's gap.
const layouts = [
section([itemAt(0, 0, 6, 6)]),
section([itemAt(0, 0, 12, 6)]),
];
const ops = createPanelOps({ layouts, layoutIndex: 1, panelId: 'p1', panel });
expect(ops[1].path).toBe('/spec/layouts/1/spec/items/-');
const value = ops[1].value as DashboardGridItemDTO;
expect(value.x).toBe(0);
expect(value.y).toBe(6);
});
it('falls back to the last section when no index is requested', () => {
const layouts = [section([]), section([item(0, 6)])];
const ops = createPanelOps({
layouts,
layoutIndex: undefined,
panelId: 'p1',
panel,
});
expect(ops[1].path).toBe('/spec/layouts/1/spec/items/-');
});
it('falls back to the last section when the requested index is out of range', () => {
const layouts = [section([])];
const ops = createPanelOps({ layouts, layoutIndex: 5, panelId: 'p1', panel });
expect(ops[1].path).toBe('/spec/layouts/0/spec/items/-');
});
it('creates a section first when the dashboard has none', () => {
const ops = createPanelOps({
layouts: [],
layoutIndex: undefined,
panelId: 'p1',
panel,
});
expect(ops).toHaveLength(3);
expect(ops[0]).toMatchObject({ op: 'add', path: '/spec/layouts/-' });
expect(ops[1]).toMatchObject({ op: 'add', path: '/spec/panels/p1' });
expect(ops[2].path).toBe('/spec/layouts/0/spec/items/-');
expect((ops[2].value as DashboardGridItemDTO).y).toBe(0);
});
});

View File

@@ -0,0 +1,53 @@
import { useCallback, useState } from 'react';
import { generatePath } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { newPanelSearch, NEW_PANEL_ID } from '../PanelEditor/newPanelRoute';
import type { PanelKind } from '../Panels/types/panelKind';
import { useDashboardStore } from '../store/useDashboardStore';
interface UseCreatePanelResult {
isPickerOpen: boolean;
/** Pass the target section's layout index; omit → last/new section. */
openPicker: (layoutIndex?: number) => void;
closePicker: () => void;
createPanel: (panelKind: PanelKind) => void;
}
/**
* Drives new-panel creation from any "Add panel" trigger: owns the panel-type
* picker state and navigates to the editor on a draft panel. Nothing is persisted
* until save.
*/
export function useCreatePanel(): UseCreatePanelResult {
const { safeNavigate } = useSafeNavigate();
const dashboardId = useDashboardStore((s) => s.dashboardId);
const [isPickerOpen, setIsPickerOpen] = useState(false);
// Captured on open, consumed on select.
const [layoutIndex, setLayoutIndex] = useState<number | undefined>(undefined);
const openPicker = useCallback((index?: number): void => {
setLayoutIndex(index);
setIsPickerOpen(true);
}, []);
const closePicker = useCallback((): void => {
setIsPickerOpen(false);
}, []);
const createPanel = useCallback(
(panelKind: PanelKind): void => {
setIsPickerOpen(false);
const path = generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, {
dashboardId,
panelId: NEW_PANEL_ID,
});
safeNavigate(`${path}${newPanelSearch(panelKind, layoutIndex)}`);
},
[safeNavigate, dashboardId, layoutIndex],
);
return { isPickerOpen, openPicker, closePicker, createPanel };
}

View File

@@ -77,12 +77,12 @@ export function usePanelQuery({
const fullKind = panel.spec.plugin.kind;
const panelType =
(fullKind && PANEL_KIND_TO_PANEL_TYPE[fullKind]) ?? PANEL_TYPES.TIME_SERIES;
const queries = panel.spec.queries;
const queries = useMemo(() => panel.spec.queries || [], [panel.spec.queries]);
// V1 parity: a list query with an explicit `limit` shows without a server pager; without
// one it pages server-side at a user-selectable size.
const hasExplicitLimit = useMemo(
() => !!getBuilderQueries(queries ?? [])[0]?.limit,
() => !!getBuilderQueries(queries)[0]?.limit,
[queries],
);
const isPaginated = panelType === PANEL_TYPES.LIST && !hasExplicitLimit;
@@ -135,7 +135,7 @@ export function usePanelQuery({
const requestPayload = useMemo(
() =>
buildQueryRangeRequest({
queries: queries ?? [],
queries,
panelType,
startMs,
endMs,
@@ -145,9 +145,9 @@ export function usePanelQuery({
[queries, panelType, startMs, endMs, fillGaps, isPaginated, offset, pageSize],
);
const legendMap = useMemo(() => extractLegendMap(queries ?? []), [queries]);
const legendMap = useMemo(() => extractLegendMap(queries), [queries]);
const runnable = useMemo(() => hasRunnableQueries(queries ?? []), [queries]);
const runnable = useMemo(() => hasRunnableQueries(queries), [queries]);
const queryKey = useMemo(
() => [

View File

@@ -2,7 +2,6 @@ import { useEffect } from 'react';
import { FullScreen, useFullScreenHandle } from 'react-full-screen';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import PanelTypeSelectionModal from 'container/DashboardContainer/PanelTypeSelectionModal';
import useComponentPermission from 'hooks/useComponentPermission';
import { useAppContext } from 'providers/App/App';
@@ -66,9 +65,6 @@ function DashboardContainer({
/>
<PanelsAndSectionsLayout layouts={spec.layouts} panels={spec.panels} />
</div>
{/* Shared panel-type picker (V1 component): opened from any "New Panel"
trigger; navigates to the widget editor route on selection. */}
<PanelTypeSelectionModal />
</FullScreen>
);
}

View File

@@ -3,17 +3,22 @@ import type {
DashboardtypesJSONPatchOperationDTO,
DashboardtypesLayoutDTO,
DashboardtypesPanelDTO,
DashboardtypesPanelPluginDTO,
DashboardtypesQueryDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
DashboardtypesPanelKindDTO,
DashboardtypesPatchOpDTO,
} from 'api/generated/services/sigNoz.schemas';
import { DashboardtypesPatchOpDTO } from 'api/generated/services/sigNoz.schemas';
import type { PanelKind } from './Panels/types/panelKind';
import type { DefaultPluginSpec } from './Panels/utils/buildDefaultPluginSpec';
import type { GridItem } from './utils';
/**
* Pure RFC-6902 JSON-Patch builders for the V2 dashboard spec. These are
* intentionally side-effect-free (no React, no network) so they can be unit
* tested and reused by the layout hooks. JSON pointers target the postable
* shape: `/spec/layouts/...`, `/spec/panels/...` (matches the existing V2
* patches in DashboardSettings/Overview and DashboardDescription).
* Pure (no React/network) RFC-6902 JSON-Patch builders for the V2 dashboard
* spec. Pointers target the postable shape: `/spec/layouts/...`,
* `/spec/panels/...`.
*/
const { add, replace, remove } = DashboardtypesPatchOpDTO;
@@ -25,29 +30,27 @@ export function panelRef(panelId: string): string {
}
/**
* Builds a minimal, backend-valid panel for a given plugin kind. The spec
* requires exactly one query whose plugin kind is allowed for the panel;
* `signoz/BuilderQuery` is allowed for every panel kind and its contents are not
* validated, so an empty builder query is the safe default. The real query is
* filled in once the panel editor lands.
* Builds a fresh panel of the given kind to seed the editor. The caller resolves
* `pluginSpec` (config defaults) and `queries` (a kind's seed query) so this stays
* free of the React panel registry.
*/
export function createDefaultPanel(pluginKind: string): DashboardtypesPanelDTO {
// The DTO types plugin/query kinds as large generated enum unions; the kind
// here is chosen dynamically by the user, so we build the structurally-valid
// shape and assert the type.
export function createDefaultPanel(
pluginKind: PanelKind,
pluginSpec: DefaultPluginSpec = {},
queries: DashboardtypesQueryDTO[] = [],
): DashboardtypesPanelDTO {
return {
kind: 'Panel',
kind: DashboardtypesPanelKindDTO.Panel,
spec: {
display: { name: 'New panel' },
plugin: { kind: pluginKind, spec: {} },
queries: [
{
kind: 'TimeSeriesQuery',
spec: { plugin: { kind: 'signoz/BuilderQuery', spec: { name: 'A' } } },
},
],
// `plugin` is a discriminated union; kind is runtime-chosen, so assert here.
plugin: {
kind: pluginKind,
spec: pluginSpec,
} as DashboardtypesPanelPluginDTO,
queries,
},
} as unknown as DashboardtypesPanelDTO;
};
}
/** Converts a UI grid item back into the spec's grid-item DTO shape. */
@@ -115,6 +118,93 @@ export function addPanelToSectionOps({
];
}
interface CreatePanelOpsArgs {
/** Current sections, used to resolve the target and the next free row. */
layouts: DashboardtypesLayoutDTO[];
/** Preferred section (from the "Add panel" trigger); falls back to the last. */
layoutIndex: number | undefined;
panelId: string;
panel: DashboardtypesPanelDTO;
}
const NEW_PANEL_SIZE = { width: 6, height: 6 };
/** Columns in the section grid — mirrors `cols` on SectionGrid's GridLayout. */
const GRID_COLS = 12;
/**
* Placement for a new grid item: drop it right of the last row if there's room,
* else wrap to a fresh row at the bottom. Only the last row is considered (items
* sharing the greatest top-y); gaps in earlier rows are left alone.
*/
function findFreeSlot(
items: DashboardGridItemDTO[],
width: number,
): { x: number; y: number } {
const w = Math.min(width, GRID_COLS);
if (items.length === 0) {
return { x: 0, y: 0 };
}
const bottom = items.reduce(
(max, it) => Math.max(max, (it.y ?? 0) + (it.height ?? 0)),
0,
);
const lastRowY = items.reduce((max, it) => Math.max(max, it.y ?? 0), 0);
const lastRowRightEdge = items
.filter((it) => (it.y ?? 0) === lastRowY)
.reduce((max, it) => Math.max(max, (it.x ?? 0) + (it.width ?? 0)), 0);
if (lastRowRightEdge + w <= GRID_COLS) {
return { x: lastRowRightEdge, y: lastRowY };
}
return { x: 0, y: bottom };
}
/**
* Ops to persist a brand-new panel (editor save path): resolve the target
* section (requested index if valid, else last, else a freshly-created one) and
* place the panel via `findFreeSlot`.
*/
export function createPanelOps({
layouts,
layoutIndex,
panelId,
panel,
}: CreatePanelOpsArgs): DashboardtypesJSONPatchOperationDTO[] {
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
const requested =
layoutIndex !== undefined && layouts[layoutIndex] !== undefined
? layoutIndex
: layouts.length - 1;
let targetIndex = requested;
let items: DashboardGridItemDTO[] = layouts[requested]?.spec.items ?? [];
if (targetIndex < 0) {
// No sections yet — create an untitled one and target it.
ops.push(addSectionOp(''));
targetIndex = 0;
items = [];
}
const { x, y } = findFreeSlot(items, NEW_PANEL_SIZE.width);
ops.push(
...addPanelToSectionOps({
panelId,
panel,
layoutIndex: targetIndex,
item: {
x,
y,
...NEW_PANEL_SIZE,
content: { $ref: panelRef(panelId) },
},
}),
);
return ops;
}
interface MovePanelArgs {
sourceIndex: number;
sourceItems: GridItem[];

View File

@@ -124,14 +124,33 @@ describe('prepareRawTable', () => {
expect(table?.columns).not.toContain('attributes_string');
});
it('does not flatten for non-log signals (traces return flat data)', () => {
// Trace rows nest resource attributes the same way logs do: the span fields
// (`name`, `duration_nano`) are top-level, but `service.name` lives under
// `resources_string`, so it must be lifted to render (V1 parity).
const traceRow = {
timestamp: '2026-06-24T16:43:57Z',
data: {
name: 'resolve',
duration_nano: 4120000,
attributes_string: {},
resources_string: { 'service.name': 'adservice' },
},
};
it('flattens nested resource maps for traces so service.name resolves', () => {
const table = prepareRawTable({
results: [result([logRow])],
selectFields: [],
results: [result([traceRow])],
selectFields: [field('name'), field('duration_nano'), field('service.name')],
signal: TelemetrytypesSignalDTO.traces,
});
expect(table?.rows[0]).toMatchObject({
name: 'resolve',
duration_nano: 4120000,
'service.name': 'adservice',
});
// The structured map is retained for the drawer, lifted child is a column.
expect(table?.rows[0]).toHaveProperty('resources_string');
expect(table?.rows[0]).not.toHaveProperty('service.name');
expect(table?.columns).toContain('service.name');
});
});

View File

@@ -54,7 +54,10 @@ export function toQueryEnvelopes(
queries: DashboardtypesQueryDTO[],
): Querybuildertypesv5QueryEnvelopeDTO[] {
// Backend invariant: panel.queries.length === 1. Only the first entry is consumed.
const plugin = queries[0]?.spec?.plugin;
if (!queries || queries.length === 0) {
return [];
}
const plugin = queries[0].spec.plugin;
if (!plugin?.spec) {
return [];
}

View File

@@ -48,7 +48,7 @@ const isBuilderQueryEnvelope = (
envelope: Querybuildertypesv5QueryEnvelopeDTO,
): boolean => envelope.type === Querybuildertypesv5QueryTypeDTO.builder_query;
function deriveQueryType(
export function deriveQueryType(
envelopes: Querybuildertypesv5QueryEnvelopeDTO[],
): EQueryType {
if (envelopes.some((e) => e.type === Querybuildertypesv5QueryTypeDTO.promql)) {

View File

@@ -25,9 +25,9 @@ interface PrepareRawTableArgs {
/** The panel's chosen columns; when empty, columns are derived from the rows. */
selectFields: TelemetrytypesTelemetryFieldKeyDTO[];
/**
* Panel telemetry signal. `logs` flattens nested attribute/resource maps one
* level so selected fields (e.g. `service.name`) resolve (V1 `FlatLogData`
* parity). Absent on the derive-columns fallback.
* Panel telemetry signal. `logs`/`traces` flatten nested attribute/resource
* maps one level so selected fields (e.g. `service.name`) resolve (V1
* `FlatLogData` parity). Absent on the derive-columns fallback.
*/
signal?: TelemetrytypesSignalDTO;
}
@@ -85,7 +85,11 @@ export function prepareRawTable({
return undefined;
}
const shouldFlatten = signal === TelemetrytypesSignalDTO.logs;
// Logs and traces both nest resource/attribute maps; lift them so selected
// fields like `service.name` resolve to a column (V1 parity).
const shouldFlatten =
signal === TelemetrytypesSignalDTO.logs ||
signal === TelemetrytypesSignalDTO.traces;
const rows: RawTableRow[] = result.rows.map((row, index) => {
const data = row.data ?? {};
return {

View File

@@ -1,4 +1,4 @@
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import {
generatePath,
Redirect,
@@ -12,14 +12,20 @@ import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { getPanelDefinition } from '../DashboardContainer/Panels/registry';
import { buildDefaultPluginSpec } from '../DashboardContainer/Panels/utils/buildDefaultPluginSpec';
import { buildDefaultQueries } from '../DashboardContainer/Panels/utils/buildDefaultQueries';
import PanelEditorContainer from '../DashboardContainer/PanelEditor';
import {
parseNewPanelKind,
parseNewPanelLayoutIndex,
} from '../DashboardContainer/PanelEditor/newPanelRoute';
import { createDefaultPanel } from '../DashboardContainer/patchOps';
import styles from './PanelEditorPage.module.scss';
/**
* Full-page route for editing a V2 dashboard panel. Fetches the dashboard, resolves
* the panel from its spec, and hands `PanelEditorContainer` the navigate-back
* callbacks. The save round-trip invalidates the dashboard query, so returning shows
* the persisted edit without an explicit refetch here.
* Full-page route for editing a V2 dashboard panel. Resolves the panel from the
* fetched dashboard spec and wires up navigate-back callbacks.
*/
function PanelEditorPage(): JSX.Element {
const { dashboardId, panelId } = useParams<{
@@ -33,12 +39,29 @@ function PanelEditorPage(): JSX.Element {
id: dashboardId,
});
const dashboard = data?.data;
const panel = dashboard?.spec.panels[panelId];
// A `panel/new?panelKind=…` route means "create": seed a default panel of that
// kind rather than looking one up. Persisted (with a real id) only on save.
const newKind = parseNewPanelKind(panelId, search);
const existingPanel = dashboard?.spec.panels[panelId];
const panel = useMemo(
() =>
newKind
? createDefaultPanel(
newKind,
buildDefaultPluginSpec(getPanelDefinition(newKind)?.sections ?? []),
buildDefaultQueries(newKind),
)
: existingPanel,
[newKind, existingPanel],
);
// Target section for a newly-created panel (set by the "Add panel" trigger).
const layoutIndex = parseNewPanelLayoutIndex(search);
const backToDashboard = useCallback((): void => {
// Carry only dashboard params back; drop editor-only URL state (chiefly
// `compositeQuery`, the query builder's URL sync) so it doesn't leak into the
// dashboard. Time lives in Redux, so it survives without being in the URL.
// Carry only dashboard params; drop editor-only URL state (chiefly
// `compositeQuery`) so it doesn't leak into the dashboard. Time lives in Redux.
const params = new URLSearchParams();
const variables = new URLSearchParams(search).get(QueryParams.variables);
if (variables) {
@@ -65,7 +88,7 @@ function PanelEditorPage(): JSX.Element {
);
}
// Stale/deleted panel ref: redirect to the dashboard rather than render an empty editor.
// No panel (stale/deleted id, or unknown new-panel kind) — send the user back.
if (!panel) {
return (
<Redirect
@@ -79,6 +102,8 @@ function PanelEditorPage(): JSX.Element {
dashboardId={dashboardId}
panelId={panelId}
panel={panel}
isNew={!!newKind}
layoutIndex={layoutIndex}
onClose={backToDashboard}
onSaved={backToDashboard}
/>

View File

@@ -92,7 +92,7 @@ type AuthZ interface {
}
// OnBeforeRoleDelete is a callback invoked before a role is deleted.
type OnBeforeRoleDelete func(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID, roleName string) error
type OnBeforeRoleDelete func(context.Context, valuer.UUID, valuer.UUID) error
type Handler interface {
Create(http.ResponseWriter, *http.Request)

View File

@@ -44,7 +44,3 @@ type Handler interface {
Update(http.ResponseWriter, *http.Request)
Delete(http.ResponseWriter, *http.Request)
}
type Getter interface {
OnBeforeRoleDelete(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID, roleName string) error
}

View File

@@ -1,45 +0,0 @@
package implauthdomain
import (
"context"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/modules/authdomain"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type getter struct {
store authtypes.AuthDomainStore
}
func NewGetter(store authtypes.AuthDomainStore) authdomain.Getter {
return &getter{store: store}
}
func (getter *getter) OnBeforeRoleDelete(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID, roleName string) error {
domains, err := getter.store.ListByOrgID(ctx, orgID)
if err != nil {
return err
}
referencedBy := make([]string, 0)
for _, domain := range domains {
for _, mappedRole := range domain.AuthDomainConfig().RoleMapping.RoleNames() {
if mappedRole == roleName {
referencedBy = append(referencedBy, domain.StorableAuthDomain().Name)
break
}
}
}
if len(referencedBy) > 0 {
return errors.WithAdditionalf(
errors.New(errors.TypeInvalidInput, authtypes.ErrCodeRoleHasAuthDomainMappings, "role is referenced by an SSO role mapping, remove it before deleting"),
"referenced by auth domain(s): %s", strings.Join(referencedBy, ", "),
)
}
return nil
}

View File

@@ -4,7 +4,6 @@ import (
"context"
"github.com/SigNoz/signoz/pkg/authn"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/modules/authdomain"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -13,18 +12,13 @@ import (
type module struct {
store authtypes.AuthDomainStore
authNs map[authtypes.AuthNProvider]authn.AuthN
authz authz.AuthZ
}
func NewModule(store authtypes.AuthDomainStore, authNs map[authtypes.AuthNProvider]authn.AuthN, authz authz.AuthZ) authdomain.Module {
return &module{store: store, authNs: authNs, authz: authz}
func NewModule(store authtypes.AuthDomainStore, authNs map[authtypes.AuthNProvider]authn.AuthN) authdomain.Module {
return &module{store: store, authNs: authNs}
}
func (module *module) Create(ctx context.Context, domain *authtypes.AuthDomain) error {
if err := module.validateRoleMapping(ctx, domain); err != nil {
return err
}
return module.store.Create(ctx, domain)
}
@@ -56,10 +50,6 @@ func (module *module) ListByOrgID(ctx context.Context, orgID valuer.UUID) ([]*au
}
func (module *module) Update(ctx context.Context, domain *authtypes.AuthDomain) error {
if err := module.validateRoleMapping(ctx, domain); err != nil {
return err
}
return module.store.Update(ctx, domain)
}
@@ -84,13 +74,3 @@ func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[strin
return stats, nil
}
func (module *module) validateRoleMapping(ctx context.Context, domain *authtypes.AuthDomain) error {
roleNames := domain.AuthDomainConfig().RoleMapping.RoleNames()
if len(roleNames) == 0 {
return nil
}
_, err := module.authz.ListByOrgIDAndNames(ctx, domain.StorableAuthDomain().OrgID, roleNames)
return err
}

View File

@@ -18,7 +18,7 @@ func NewGetter(store serviceaccounttypes.Store) serviceaccount.Getter {
return &getter{store: store}
}
func (getter *getter) OnBeforeRoleDelete(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID, _ string) error {
func (getter *getter) OnBeforeRoleDelete(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID) error {
serviceAccounts, err := getter.store.GetServiceAccountsByOrgIDAndRoleID(ctx, orgID, roleID)
if err != nil {
return err

View File

@@ -13,7 +13,7 @@ import (
type Getter interface {
// OnBeforeRoleDelete checks if any service accounts are assigned to the role and rejects deletion if so.
OnBeforeRoleDelete(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID, roleName string) error
OnBeforeRoleDelete(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID) error
}
type Module interface {

View File

@@ -9,7 +9,6 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/authn"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/authdomain"
@@ -30,10 +29,9 @@ type module struct {
authDomain authdomain.Module
tokenizer tokenizer.Tokenizer
orgGetter organization.Getter
authz authz.AuthZ
}
func NewModule(providerSettings factory.ProviderSettings, authNs map[authtypes.AuthNProvider]authn.AuthN, userSetter user.Setter, userGetter user.Getter, authDomain authdomain.Module, tokenizer tokenizer.Tokenizer, orgGetter organization.Getter, authz authz.AuthZ) session.Module {
func NewModule(providerSettings factory.ProviderSettings, authNs map[authtypes.AuthNProvider]authn.AuthN, userSetter user.Setter, userGetter user.Getter, authDomain authdomain.Module, tokenizer tokenizer.Tokenizer, orgGetter organization.Getter) session.Module {
return &module{
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/session/implsession"),
authNs: authNs,
@@ -42,7 +40,6 @@ func NewModule(providerSettings factory.ProviderSettings, authNs map[authtypes.A
authDomain: authDomain,
tokenizer: tokenizer,
orgGetter: orgGetter,
authz: authz,
}
}
@@ -146,23 +143,15 @@ func (module *module) CreateCallbackAuthNSession(ctx context.Context, authNProvi
}
roleMapping := authDomain.AuthDomainConfig().RoleMapping
roleAttributeExists := false
if roleMapping != nil && roleMapping.UseRoleAttribute && callbackIdentity.Role != "" {
_, err := module.authz.GetByOrgIDAndName(ctx, callbackIdentity.OrgID, authtypes.NormalizeRoleName(callbackIdentity.Role))
if err == nil {
roleAttributeExists = true
}
}
roleNames := roleMapping.NewRolesFromCallbackIdentity(callbackIdentity, roleAttributeExists)
role := roleMapping.NewRoleFromCallbackIdentity(callbackIdentity)
signozManagedRole := authtypes.MustGetSigNozManagedRoleFromExistingRole(role)
newUser, err := types.NewUser(callbackIdentity.Name, callbackIdentity.Email, callbackIdentity.OrgID, types.UserStatusActive)
if err != nil {
return "", err
}
newUser, err = module.userSetter.GetOrCreateUser(ctx, newUser, user.WithRoleNames(roleNames))
newUser, err = module.userSetter.GetOrCreateUser(ctx, newUser, user.WithRoleNames([]string{signozManagedRole}))
if err != nil {
return "", err
}

View File

@@ -239,7 +239,7 @@ func (module *getter) VerifyResetPasswordToken(ctx context.Context, token string
return nil
}
func (module *getter) OnBeforeRoleDelete(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID, _ string) error {
func (module *getter) OnBeforeRoleDelete(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID) error {
users, err := module.GetUsersByOrgIDAndRoleID(ctx, orgID, roleID)
if err != nil {
return err

View File

@@ -96,7 +96,7 @@ type Getter interface {
GetUsersByOrgIDAndRoleID(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID) ([]*types.User, error)
// OnBeforeRoleDelete checks if any users are assigned to the role and rejects deletion if so.
OnBeforeRoleDelete(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID, roleName string) error
OnBeforeRoleDelete(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID) error
// VerifyResetPasswordToken checks if a reset password token exists and is not expired.
VerifyResetPasswordToken(ctx context.Context, token string) error

View File

@@ -128,7 +128,6 @@ func NewModules(
}
userSetter := impluser.NewSetter(impluser.NewStore(sqlstore, providerSettings), tokenizer, emailing, providerSettings, orgSetter, authz, analytics, config.User, userRoleStore, userGetter, onDeleteUser)
ruleStore := sqlrulestore.NewRuleStore(sqlstore, queryParser, providerSettings)
authDomainModule := implauthdomain.NewModule(implauthdomain.NewStore(sqlstore), authNs, authz)
return Modules{
OrgGetter: orgGetter,
@@ -143,8 +142,8 @@ func NewModules(
QuickFilter: quickfilter,
TraceFunnel: impltracefunnel.NewModule(impltracefunnel.NewStore(sqlstore)),
RawDataExport: implrawdataexport.NewModule(querier),
AuthDomain: authDomainModule,
Session: implsession.NewModule(providerSettings, authNs, userSetter, userGetter, authDomainModule, tokenizer, orgGetter, authz),
AuthDomain: implauthdomain.NewModule(implauthdomain.NewStore(sqlstore), authNs),
Session: implsession.NewModule(providerSettings, authNs, userSetter, userGetter, implauthdomain.NewModule(implauthdomain.NewStore(sqlstore), authNs), tokenizer, orgGetter),
SpanPercentile: implspanpercentile.NewModule(querier, providerSettings),
Services: implservices.NewModule(querier, telemetryStore),
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, ruleStore, dashboard, providerSettings, config.MetricsExplorer),

View File

@@ -215,7 +215,6 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewRecreateUserDashboardPreferenceFactory(sqlstore, sqlschema),
sqlmigration.NewMigrateRecurrenceBoundsFactory(sqlstore),
sqlmigration.NewAddDashboardViewFactory(sqlstore, sqlschema),
sqlmigration.NewMigrateSSORoleMappingNamesFactory(sqlstore),
)
}

View File

@@ -24,7 +24,6 @@ import (
"github.com/SigNoz/signoz/pkg/instrumentation"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/meterreporter"
"github.com/SigNoz/signoz/pkg/modules/authdomain/implauthdomain"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/organization"
@@ -350,13 +349,10 @@ func New(
// Initialize service account getter
serviceAccountGetter := implserviceaccount.NewGetter(implserviceaccount.NewStore(sqlstore))
authDomainGetter := implauthdomain.NewGetter(implauthdomain.NewStore(sqlstore))
// Build pre-delete callbacks from modules
onBeforeRoleDelete := []authz.OnBeforeRoleDelete{
userGetter.OnBeforeRoleDelete,
serviceAccountGetter.OnBeforeRoleDelete,
authDomainGetter.OnBeforeRoleDelete,
}
// Initialize authz

View File

@@ -1,127 +0,0 @@
package sqlmigration
import (
"context"
"encoding/json"
"log/slog"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type migrateSSORoleMappingNames struct {
sqlstore sqlstore.SQLStore
logger *slog.Logger
}
type authDomainRow struct {
bun.BaseModel `bun:"table:auth_domain"`
ID string `bun:"id"`
Data string `bun:"data"`
}
var legacyRoleToManagedRoleName = map[string]string{
"ADMIN": "signoz-admin",
"EDITOR": "signoz-editor",
"VIEWER": "signoz-viewer",
}
type ssoRoleMapping struct {
DefaultRole string `json:"defaultRole"`
GroupMappings map[string]string `json:"groupMappings"`
UseRoleAttribute bool `json:"useRoleAttribute"`
}
func NewMigrateSSORoleMappingNamesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("migrate_sso_role_mapping_names"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &migrateSSORoleMappingNames{sqlstore: sqlstore, logger: ps.Logger}, nil
},
)
}
func (migration *migrateSSORoleMappingNames) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *migrateSSORoleMappingNames) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
rows := make([]*authDomainRow, 0)
if err := tx.NewSelect().Model(&rows).Scan(ctx); err != nil {
return err
}
for _, row := range rows {
config := make(map[string]json.RawMessage)
if err := json.Unmarshal([]byte(row.Data), &config); err != nil {
migration.logger.WarnContext(ctx, "skipping auth domain with unreadable data", slog.String("auth_domain_id", row.ID), errors.Attr(err))
continue
}
roleMappingRaw, ok := config["roleMapping"]
if !ok || string(roleMappingRaw) == "null" {
continue
}
var roleMapping ssoRoleMapping
if err := json.Unmarshal(roleMappingRaw, &roleMapping); err != nil {
migration.logger.WarnContext(ctx, "skipping auth domain with unreadable role mapping", slog.String("auth_domain_id", row.ID), errors.Attr(err))
continue
}
changed := false
if managed, ok := legacyRoleToManagedRoleName[strings.ToUpper(roleMapping.DefaultRole)]; ok {
roleMapping.DefaultRole = managed
changed = true
}
for group, role := range roleMapping.GroupMappings {
if managed, ok := legacyRoleToManagedRoleName[strings.ToUpper(role)]; ok {
roleMapping.GroupMappings[group] = managed
changed = true
}
}
if !changed {
continue
}
newRoleMapping, err := json.Marshal(roleMapping)
if err != nil {
return err
}
config["roleMapping"] = newRoleMapping
newData, err := json.Marshal(config)
if err != nil {
return err
}
if _, err := tx.NewUpdate().
Model((*authDomainRow)(nil)).
Set("data = ?", string(newData)).
Where("id = ?", row.ID).
Exec(ctx); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *migrateSSORoleMappingNames) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -10,6 +10,7 @@ import (
"strings"
"github.com/SigNoz/signoz/pkg/telemetrytraces"
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
)
type migrateCommon struct {
@@ -23,119 +24,10 @@ func NewMigrateCommon(logger *slog.Logger) *migrateCommon {
}
}
// WrapInV5Envelope delegates to querybuildertypesv5.WrapInV5Envelope; the
// transform is stateless and shared with the v1→v2 dashboard conversion.
func (migration *migrateCommon) WrapInV5Envelope(name string, queryMap map[string]any, queryType string) map[string]any {
// Create a properly structured v5 query
v5Query := map[string]any{
"name": name,
"disabled": queryMap["disabled"],
"legend": queryMap["legend"],
}
if name != queryMap["expression"] {
// formula
queryType = "builder_formula"
v5Query["expression"] = queryMap["expression"]
if functions, ok := queryMap["functions"]; ok {
v5Query["functions"] = functions
}
return map[string]any{
"type": queryType,
"spec": v5Query,
}
}
// Add signal based on data source
if dataSource, ok := queryMap["dataSource"].(string); ok {
switch dataSource {
case "traces":
v5Query["signal"] = "traces"
case "logs":
v5Query["signal"] = "logs"
case "metrics":
v5Query["signal"] = "metrics"
}
}
if stepInterval, ok := queryMap["stepInterval"]; ok {
v5Query["stepInterval"] = stepInterval
}
if aggregations, ok := queryMap["aggregations"]; ok {
v5Query["aggregations"] = aggregations
}
if filter, ok := queryMap["filter"]; ok {
v5Query["filter"] = filter
}
// Copy groupBy with proper structure
if groupBy, ok := queryMap["groupBy"].([]any); ok {
v5GroupBy := make([]any, len(groupBy))
for i, gb := range groupBy {
if gbMap, ok := gb.(map[string]any); ok {
v5GroupBy[i] = map[string]any{
"name": gbMap["key"],
"fieldDataType": gbMap["dataType"],
"fieldContext": gbMap["type"],
}
}
}
v5Query["groupBy"] = v5GroupBy
}
// Copy orderBy with proper structure
if orderBy, ok := queryMap["orderBy"].([]any); ok {
v5OrderBy := make([]any, len(orderBy))
for i, ob := range orderBy {
if obMap, ok := ob.(map[string]any); ok {
v5OrderBy[i] = map[string]any{
"key": map[string]any{
"name": obMap["columnName"],
"fieldDataType": obMap["dataType"],
"fieldContext": obMap["type"],
},
"direction": obMap["order"],
}
}
}
v5Query["order"] = v5OrderBy
}
// Copy selectColumns as selectFields
if selectColumns, ok := queryMap["selectColumns"].([]any); ok {
v5SelectFields := make([]any, len(selectColumns))
for i, col := range selectColumns {
if colMap, ok := col.(map[string]any); ok {
v5SelectFields[i] = map[string]any{
"name": colMap["key"],
"fieldDataType": colMap["dataType"],
"fieldContext": colMap["type"],
}
}
}
v5Query["selectFields"] = v5SelectFields
}
// Copy limit and offset
if limit, ok := queryMap["limit"]; ok {
v5Query["limit"] = limit
}
if offset, ok := queryMap["offset"]; ok {
v5Query["offset"] = offset
}
if having, ok := queryMap["having"]; ok {
v5Query["having"] = having
}
if functions, ok := queryMap["functions"]; ok {
v5Query["functions"] = functions
}
return map[string]any{
"type": queryType,
"spec": v5Query,
}
return querybuildertypesv5.WrapInV5Envelope(name, queryMap, queryType)
}
func (mc *migrateCommon) updateQueryData(ctx context.Context, queryData map[string]any, version, widgetType string) bool {

View File

@@ -2,6 +2,10 @@ package authtypes
import (
"encoding/json"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
)
type AttributeMapping struct {
@@ -47,95 +51,83 @@ func (attr *AttributeMapping) UnmarshalJSON(data []byte) error {
}
type RoleMapping struct {
// Default role assigned to new SSO users when no group mapping applies.
// Default role any new SSO users. Defaults to "VIEWER"
DefaultRole string `json:"defaultRole"`
// Map of IDP group name to SigNoz role name.
// Map of IDP group names to SigNoz roles. Key is group name, value is SigNoz role
GroupMappings map[string]string `json:"groupMappings"`
// If true, use the role claim directly from IDP instead of group mappings.
// If true, use the role claim directly from IDP instead of group mappings
UseRoleAttribute bool `json:"useRoleAttribute"`
}
func (roleMapping *RoleMapping) UnmarshalJSON(data []byte) error {
type alias RoleMapping
func (typ *RoleMapping) UnmarshalJSON(data []byte) error {
type Alias RoleMapping
var temp alias
var temp Alias
if err := json.Unmarshal(data, &temp); err != nil {
return err
}
temp.DefaultRole = NormalizeRoleName(temp.DefaultRole)
for group, role := range temp.GroupMappings {
temp.GroupMappings[group] = NormalizeRoleName(role)
if temp.DefaultRole != "" {
if _, err := types.NewRole(strings.ToUpper(temp.DefaultRole)); err != nil {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid default role %s", temp.DefaultRole)
}
}
*roleMapping = RoleMapping(temp)
for group, role := range temp.GroupMappings {
if _, err := types.NewRole(strings.ToUpper(role)); err != nil {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid role %s for group %s", role, group)
}
}
*typ = RoleMapping(temp)
return nil
}
func (roleMapping *RoleMapping) NewRolesFromCallbackIdentity(callbackIdentity *CallbackIdentity, roleAttributeExists bool) []string {
func (roleMapping *RoleMapping) NewRoleFromCallbackIdentity(callbackIdentity *CallbackIdentity) types.Role {
if roleMapping == nil {
return []string{SigNozViewerRoleName}
return types.RoleViewer
}
if roleAttributeExists {
return []string{NormalizeRoleName(callbackIdentity.Role)}
if roleMapping.UseRoleAttribute && callbackIdentity.Role != "" {
if role, err := types.NewRole(strings.ToUpper(callbackIdentity.Role)); err == nil {
return role
}
}
if len(roleMapping.GroupMappings) > 0 && len(callbackIdentity.Groups) > 0 {
roleNames := make([]string, 0)
seen := make(map[string]struct{})
highestRole := types.RoleViewer
found := false
for _, group := range callbackIdentity.Groups {
roleName, exists := roleMapping.GroupMappings[group]
if !exists {
continue
if mappedRole, exists := roleMapping.GroupMappings[group]; exists {
found = true
if role, err := types.NewRole(strings.ToUpper(mappedRole)); err == nil {
if compareRoles(role, highestRole) > 0 {
highestRole = role
}
}
}
if _, duplicate := seen[roleName]; duplicate {
continue
}
seen[roleName] = struct{}{}
roleNames = append(roleNames, roleName)
}
if len(roleNames) > 0 {
return roleNames
if found {
return highestRole
}
}
return []string{roleMapping.DefaultRoleName()}
}
func (roleMapping *RoleMapping) DefaultRoleName() string {
if roleMapping.DefaultRole != "" {
return roleMapping.DefaultRole
}
return SigNozViewerRoleName
}
func (roleMapping *RoleMapping) RoleNames() []string {
if roleMapping == nil {
return nil
}
seen := make(map[string]struct{})
roleNames := make([]string, 0, len(roleMapping.GroupMappings)+1)
if roleMapping.DefaultRole != "" {
seen[roleMapping.DefaultRole] = struct{}{}
roleNames = append(roleNames, roleMapping.DefaultRole)
if role, err := types.NewRole(strings.ToUpper(roleMapping.DefaultRole)); err == nil {
return role
}
}
for _, roleName := range roleMapping.GroupMappings {
if roleName == "" {
continue
}
if _, duplicate := seen[roleName]; duplicate {
continue
}
seen[roleName] = struct{}{}
roleNames = append(roleNames, roleName)
}
return roleNames
return types.RoleViewer
}
func compareRoles(a, b types.Role) int {
order := map[types.Role]int{
types.RoleViewer: 0,
types.RoleEditor: 1,
types.RoleAdmin: 2,
}
return order[a] - order[b]
}

View File

@@ -25,7 +25,6 @@ var (
ErrCodeRoleUnsupported = errors.MustNewCode("role_unsupported")
ErrCodeRoleHasUserAssignees = errors.MustNewCode("role_has_user_assignees")
ErrCodeRoleHasServiceAccountAssignees = errors.MustNewCode("role_has_service_account_assignees")
ErrCodeRoleHasAuthDomainMappings = errors.MustNewCode("role_has_auth_domain_mappings")
)
var (
@@ -304,20 +303,6 @@ func MustGetSigNozManagedRoleFromExistingRole(role types.Role) string {
return managedRole
}
func NormalizeRoleName(role string) string {
legacyRole, err := types.NewRole(strings.ToUpper(role))
if err != nil {
return role
}
managedRole, ok := ExistingRoleToSigNozManagedRoleMap[legacyRole]
if !ok {
return role
}
return managedRole
}
type RoleStore interface {
Create(context.Context, *Role) error
Get(context.Context, valuer.UUID, valuer.UUID) (*Role, error)

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