Compare commits

..

3 Commits

Author SHA1 Message Date
Ashwin Bhatkal
b2f048770e feat(dashboards-v2): list tags, DSL search, per-user pinning & org-shared views (#11868)
Some checks are pending
build-staging / staging (push) Blocked by required conditions
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
Release Drafter / update_release_draft (push) Waiting to run
* feat(dashboards-v2): remove temporary V2 list banner

The 'You're on the V2 dashboards page' warning banner was a stop-gap while the
page was in flux. Remove it now that the page is the intended destination.

* feat(dashboards-v2): strict key:value tag input with inline edit

Add a shared TagKeyValueInput that accepts tags only as key:value pairs,
committed on Enter and rejecting bare values, with double-click inline editing,
removable sienna chips, and a key:value parser. Use it on the dashboard settings
Overview form and the create-dashboard modal (replacing the comma-separated text
field). The dashboard list item type adopts the per-user list DTO so rows carry
the data the tag/pin features need.

* feat(dashboards-v2): DSL search with key suggestions and tag filtering

The search box now passes raw filter DSL straight through (parenthesized to keep
its precedence isolated) instead of wrapping the term in name CONTAINS, so users
can type name/tag/created_by clauses directly. A suggestions dropdown offers the
reserved DSL keys plus the tag keys reported by the list endpoint. Adds a Tags
filter chip (multi-select from the API's tags) alongside Created by / Updated.

* feat(dashboards-v2): per-user dashboard pinning

Replace the localStorage favorite star with backend per-user pinning. The row
action pins/unpins via the v2 per-user endpoints (pin/unpin), refreshes the
personalized list, and surfaces the 10-pin limit (HTTP 409) as a toast. Uses the
Pin/PinOff icons.

* feat(dashboards-v2): org-shared saved views via the views API

Persist saved views through the backend Views API (list/create/update/delete)
instead of localStorage, so they are shared across the org. A view stores the
combined DSL query plus sort/order and folds into the search box on select. Wires
the personalized list (pinned-aware), renames the 'Favorites' built-in to
'Pinned', renames the metadata popover to 'Columns', and polishes the views rail
(active accent, left-aligned title).

* refactor(dashboards-v2): group list utils under a utils/ folder

Move filterQuery, dslSuggestions, views and the general helpers (plus their tests)
into DashboardsListPageV2/utils/ so the page's growing set of non-component modules
lives in one place; update import paths. No behavior change.

* feat(dashboards-v2): retain tags across refetches + DSL keyboard nav

Accumulate the tags reported by the list endpoint across refetches so previously-
seen tags stay selectable in the Tags chip and DSL suggestions even when a filtered
page omits them. The DSL suggestions dropdown is now keyboard-navigable (arrow keys
+ Enter, Escape to close) and renders as a seamless full-width panel attached to the
input.

* refactor(dashboards-v2): prefer @signozhq Button/Typography, polish views rail

Replace raw <button>/<span> in the V2 list and tag input with @signozhq Button and
Typography, and apply icon classes directly to the icon (no wrapper span). Switch the
pin row to Pin/PinOff. Views rail: larger view-name font, 'Filter views by name'
placeholder, and an editable tag chip using a flattened ghost Button.

* chore(dashboards-v2): fix formatting in moved util files

* refactor(dashboards-v2): use an enum for built-in view ids

Replace the BuiltinViewId string-literal union with a TS enum (string values
unchanged, so the URL view param stays compatible) and use its members across the
view catalogue, snapshots, and the All-view check.

* refactor(dashboards-v2): trim redundant tag-chip remove-button overrides

Ghost background is already transparent and the hover colors are not needed; keep
only the size overrides plus color:inherit so the close icon matches the chip sienna.

* fix(dashboards-v2): don't open the dashboard when cancelling delete

The confirm dialog renders through this component's contextHolder, which sits inside
the row's click-to-open handler; React replays portal events up the tree, so the
Cancel click bubbled to the row and navigated. Stop propagation on cancel.

* fix(dashboards-v2): blend the views-rail delete action into the row

Revert the delete action to a ghost icon that stays transparent over the row's hover
background and only turns red on its own hover (the outlined variant broke the
unified background), stop its click from selecting the view, and restore the
content-driven row height so all rows stay uniform.

* fix(dashboards-v2): overlay the views-rail delete instead of reserving space

Absolutely position the delete action on the row's right edge (created views only)
so it no longer takes flex space, and drop the --button-height:auto hack so the row
keeps its natural height. pointer-events are gated so the hidden button can't
intercept row clicks.

* fix(dashboards-v2): align tag chip weight and fix remove-button hover

Match the chip text weight to the list-row badge (normal, not medium) so the colour
reads the same, and give the remove (×) a sienna-tinted hover instead of the Button's
default grey. Resting × colour stays at the Button default.
2026-06-30 10:35:45 +00:00
Abhi kumar
0711786701 fix(dashboards-v2): panel editor fixes + span-gaps Disconnect Values control (#11864)
* fix(dashboards-v2): disable panel types unsupported by the datasource

A new panel's builder is seeded with the kind's default signal, but
`spec.queries` stays empty until the query is modified — so the type
switcher saw an undefined datasource and never disabled incompatible
types (e.g. List on a metrics panel, which then breaks rendering).
Resolve the signal with a fallback to the kind's default signal so
compatibility is enforced from the first render.

* fix(dashboards-v2): place toolbar-created panels in the root section

The top-right "New Panel" button creates a panel with no section context,
which createPanelOps resolved to the LAST section instead of the root.
Fall back to the first (root) section when no valid index is given; still
create an untitled section when the dashboard has none.

* feat(dashboards-v2): add "Move out of section" panel action

The "Move to section" submenu only listed titled sections, so a panel
in a titled section couldn't be moved back to the untitled root. Add a
direct "Move out of section" action, shown when the panel sits in a
titled section and an untitled root section exists to receive it.

* fix(dashboards-v2): allow clearing the threshold value input

The threshold "Value" field was a controlled numeric input, so an
emptied field snapped back to 0 (Number("") is 0, not NaN) and the
seeded 0 could never be removed. Hold a local string so the field can
be cleared and edited; shared by all threshold row variants.

* feat(dashboards-v2): redesign span-gaps as a "Disconnect values" control

Replace the raw seconds input with a Never/Threshold toggle plus a
duration "Threshold value" field. The threshold is stored verbatim as a
duration string ("10m", "5s") — the wire format the backend expects — and
parsed back to seconds only for rendering and validation. Threads the
query step interval through the config pane to seed/floor the threshold,
and rejects invalid or below-step-interval entries inline (V1 parity).

* refactor(dashboards-v2): rename Formatting section to "Formatting & Units"

Serialize section header test ids (lowercase, spaces → dashes) so a
multi-word title doesn't break the data-testid, and update the test.

* chore(dashboards-v2): tidy panel-editor query helpers

- useLegendSeries: drop redundant optional chaining on panel.spec.
- Remove the unused getPanelKindLabel util.

* chore: pr review changes

* chore: fmt fix

* feat(dashboards-v2): deterministic panel capability guard (type × query-type × signal) (#11865)

* feat(dashboards-v2): add a panel capability guard

Centralize "what works with what" for V2 dashboard panels into one
deterministic guard. Each panel kind declares its supported query types and
optional query-builder field rules alongside its existing supported signals; a
pure `capabilities` module reads the panel registry to answer panel x
query-type x signal validity, coerce an unsupported query type, and resolve the
query-builder fields a kind hides.

`supportedQueryTypes` is required, so the registry's mapped type forces every
present and future kind to declare it. This re-homes the panel->query-type
compatibility that V2 previously imported from V1's NewWidget/utils into V2 land.

No behavior change: no consumer is wired to the guard yet.

* feat(dashboards-v2): gate the panel editor through the capability guard

Route the panel editor's query builder and visualization type switcher through
the capability guard instead of V1's PANEL_TYPE_TO_QUERY_TYPES (now no longer
imported by any V2 file):

- PanelEditorQueryBuilder is keyed on PanelKind; its query-type tabs and
  query-builder field visibility come from the guard.
- Switching the panel kind coerces the active query type via the guard.
- The visualization type switcher disables a kind when the active query type or
  datasource is incompatible with it (e.g. List under ClickHouse/PromQL, or List
  with a metrics query). The live query type is read from the query-builder
  provider so a not-yet-staged new panel still gates correctly, and a tooltip
  explains why a type is disabled. ConfigSelect gains opt-in per-option tooltips.

* chore: pr review changes

* chore: lint fix
2026-06-30 09:40:15 +00:00
Vinicius Lourenço
aeda0a5144 refactor(authz): fixes on error feedback (#11901)
* refactor(authz): ensure all error messages matches with api

* refactor(roles): little fixes on UI
2026-06-30 09:33:56 +00:00
115 changed files with 3196 additions and 719 deletions

View File

@@ -71,10 +71,6 @@ web:
sentry:
# Whether to enable Sentry in web.
enabled: false
# The Sentry DSN.
dsn: ""
# The Sentry tunnel URL.
tunnel: ""
pylon:
# Whether to enable Pylon in web.
enabled: false

View File

@@ -4,7 +4,5 @@ VITE_FRONTEND_API_ENDPOINT="http://localhost:8080"
VITE_PYLON_APP_ID="pylon-app-id"
VITE_APPCUES_APP_ID="appcess-app-id"
VITE_PYLON_IDENTITY_SECRET="pylon-identity-secret"
VITE_SENTRY_DSN="sentry-dsn"
VITE_TUNNEL_URL="sentry-tunnel-url"
CI="1"

View File

@@ -35,7 +35,6 @@ import { PreferenceContextProvider } from 'providers/preferences/context/Prefere
import { QueryBuilderProvider } from 'providers/QueryBuilder';
import { LicenseStatus } from 'types/api/licensesV3/getActive';
import { extractDomain } from 'utils/app';
import { getWebSettings } from 'utils/bootSettings';
import { Home } from './pageComponents';
import PrivateRoute from './Private';
@@ -334,19 +333,24 @@ function App(): JSX.Element {
}, [user, isFetchingUser, isCloudUser, enableAnalytics]);
useEffect(() => {
const webSettings = getWebSettings();
if (isCloudUser || isEnterpriseSelfHostedUser) {
if ((webSettings?.posthog.enabled ?? true) && process.env.POSTHOG_KEY) {
if (
(window.signozBootData?.settings?.posthog.enabled ?? true) &&
process.env.POSTHOG_KEY
) {
posthog.init(process.env.POSTHOG_KEY, {
api_host: 'https://us.i.posthog.com',
person_profiles: 'identified_only', // or 'always' to create profiles for anonymous users as well
});
}
if (!isSentryInitialized && (webSettings?.sentry.enabled ?? true)) {
if (
!isSentryInitialized &&
(window.signozBootData?.settings?.sentry.enabled ?? true)
) {
Sentry.init({
dsn: webSettings?.sentry.dsn,
tunnel: webSettings?.sentry.tunnel,
dsn: process.env.SENTRY_DSN,
tunnel: process.env.TUNNEL_URL,
environment: process.env.ENVIRONMENT,
release: process.env.VERSION,
integrations: [

View File

@@ -1,5 +1,5 @@
import { ReactElement } from 'react';
import { render, screen } from 'tests/test-utils';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { buildPermission } from 'hooks/useAuthZ/utils';
import type { AuthZObject, BrandedPermission } from 'hooks/useAuthZ/types';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
@@ -66,6 +66,29 @@ describe('AuthZTooltip — single check', () => {
expect(screen.getByRole('button', { name: 'Action' })).toBeDisabled();
});
it('shows formatted permission message in tooltip when denied', async () => {
mockUseAuthZ.mockReturnValue({
...noPermissions,
permissions: { [createPerm]: { isGranted: false } },
});
render(
<AuthZTooltip checks={[createPerm]}>
<TestButton />
</AuthZTooltip>,
);
const user = userEvent.setup();
await user.hover(screen.getByRole('button', { name: 'Action' }));
const expectedMessage =
'user/some-user-id is not authorized to perform create:serviceaccount:*';
await waitFor(() => {
const matches = screen.queryAllByText(expectedMessage);
expect(matches.length).toBeGreaterThan(0);
});
});
it('disables child while loading', () => {
mockUseAuthZ.mockReturnValue({ ...noPermissions, isLoading: true });
@@ -142,4 +165,31 @@ describe('AuthZTooltip — multi-check (checks array)', () => {
attachRolePerm,
);
});
it('shows multiple formatted permissions in tooltip when both denied', async () => {
const sa = attachSAPerm('sa-1');
mockUseAuthZ.mockReturnValue({
...noPermissions,
permissions: {
[sa]: { isGranted: false },
[attachRolePerm]: { isGranted: false },
},
});
render(
<AuthZTooltip checks={[sa, attachRolePerm]}>
<TestButton />
</AuthZTooltip>,
);
const user = userEvent.setup();
await user.hover(screen.getByRole('button', { name: 'Action' }));
const expectedMessage =
'user/some-user-id is not authorized to perform attach:serviceaccount:sa-1, attach:role:*';
await waitFor(() => {
const matches = screen.queryAllByText(expectedMessage);
expect(matches.length).toBeGreaterThan(0);
});
});
});

View File

@@ -7,7 +7,8 @@ import {
} from '@signozhq/ui/tooltip';
import type { BrandedPermission } from 'hooks/useAuthZ/types';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import { parsePermission } from 'hooks/useAuthZ/utils';
import { formatPermission } from 'hooks/useAuthZ/utils';
import { useAppContext } from 'providers/App/App';
import styles from './AuthZTooltip.module.scss';
interface AuthZTooltipProps {
@@ -19,19 +20,14 @@ interface AuthZTooltipProps {
function formatDeniedMessage(
denied: BrandedPermission[],
userId: string,
override?: string,
): string {
if (override) {
return override;
}
const labels = denied.map((p) => {
const { relation, object } = parsePermission(p);
const resource = object.split(':')[0];
return `${relation} ${resource}`;
});
return labels.length === 1
? `You don't have ${labels[0]} permission`
: `You don't have ${labels.join(', ')} permissions`;
const permissions = denied.map(formatPermission).join(', ');
return `user/${userId} is not authorized to perform ${permissions}`;
}
function AuthZTooltip({
@@ -40,6 +36,7 @@ function AuthZTooltip({
enabled = true,
tooltipMessage,
}: AuthZTooltipProps): JSX.Element {
const { user } = useAppContext();
const shouldCheck = enabled && checks.length > 0;
const { permissions, isLoading } = useAuthZ(checks, { enabled: shouldCheck });
@@ -75,7 +72,7 @@ function AuthZTooltip({
</span>
</TooltipTrigger>
<TooltipContent className={styles.errorContent}>
{formatDeniedMessage(deniedPermissions, tooltipMessage)}
{formatDeniedMessage(deniedPermissions, user.id, tooltipMessage)}
</TooltipContent>
</TooltipRoot>
</TooltipProvider>

View File

@@ -2,3 +2,11 @@
box-sizing: border-box;
width: 100%;
}
.permission {
color: var(--l2-foreground);
}
.permissionCode {
font-family: monospace;
}

View File

@@ -5,9 +5,8 @@ describe('PermissionDeniedCallout', () => {
it('renders the permission name in the callout message', () => {
render(<PermissionDeniedCallout permissionName="serviceaccount:attach" />);
expect(screen.getByText(/You don't have/)).toBeInTheDocument();
expect(screen.getByText(/is not authorized/)).toBeInTheDocument();
expect(screen.getByText(/serviceaccount:attach/)).toBeInTheDocument();
expect(screen.getByText(/permission/)).toBeInTheDocument();
});
it('accepts an optional className', () => {

View File

@@ -1,6 +1,8 @@
import { Callout } from '@signozhq/ui/callout';
import cx from 'classnames';
import styles from './PermissionDeniedCallout.module.scss';
import { useAppContext } from 'providers/App/App';
import { Typography } from '@signozhq/ui/typography';
interface PermissionDeniedCalloutProps {
permissionName: string;
@@ -11,6 +13,8 @@ function PermissionDeniedCallout({
permissionName,
className,
}: PermissionDeniedCalloutProps): JSX.Element {
const { user } = useAppContext();
return (
<Callout
type="error"
@@ -18,7 +22,11 @@ function PermissionDeniedCallout({
size="small"
className={cx(styles.callout, className)}
>
{`You don't have ${permissionName} permission`}
<Typography.Text className={styles.permission}>
<code className={styles.permissionCode}>user/{user.id}</code> is not
authorized to perform{' '}
<code className={styles.permissionCode}>{permissionName}</code>
</Typography.Text>
</Callout>
);
}

View File

@@ -0,0 +1,90 @@
.container {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
}
.field {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-radius: 2px;
border: 1px solid var(--l2-border);
}
// Sienna chip — matches the dashboard list-row tag badge.
.tag {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 240px;
height: 24px;
padding: 2px 4px 2px 8px;
border-radius: 50px;
border: 1px solid color-mix(in srgb, var(--bg-sienna-500) 20%, transparent);
background: color-mix(in srgb, var(--bg-sienna-500) 10%, transparent);
color: var(--bg-sienna-400);
font-size: 13px;
font-weight: var(--font-weight-normal);
line-height: 20px;
cursor: text;
}
.tagLabel {
--button-height: auto;
--button-padding: 0;
--button-gap: 0;
--button-variant-ghost-background-color: transparent;
--button-variant-ghost-hover-background-color: transparent;
--button-variant-ghost-color: inherit;
--button-variant-ghost-hover-color: inherit;
overflow: hidden;
max-width: 200px;
font-size: 13px;
font-weight: var(--font-weight-normal);
text-overflow: ellipsis;
white-space: nowrap;
cursor: text;
}
.remove {
// Size overrides to fit the chip, plus a sienna-tinted hover — the Button's
// default ghost hover is a grey that clashes with the chip. Resting color is
// left at the Button default.
--button-height: 16px;
--button-padding: 0;
--button-border-radius: 50%;
--button-variant-ghost-hover-background-color: color-mix(
in srgb,
var(--bg-sienna-500) 22%,
transparent
);
--button-variant-ghost-hover-color: var(--bg-sienna-400);
width: 16px;
min-width: 16px;
flex: none;
}
.input {
flex: 1;
min-width: 120px;
border: none;
background: transparent;
&::placeholder {
color: var(--l3-foreground);
}
}
.editInput {
width: 160px;
height: 24px;
}
.error {
color: var(--bg-cherry-500);
font-size: 12px;
}

View File

@@ -0,0 +1,164 @@
import { type ChangeEvent, type KeyboardEvent, useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import { X } from '@signozhq/icons';
import cx from 'classnames';
import { parseKeyValueTag } from './utils';
import styles from './TagKeyValueInput.module.scss';
interface TagKeyValueInputProps {
// Tags as `key:value` strings.
tags: string[];
onTagsChange: (tags: string[]) => void;
placeholder?: string;
// Override the outer container styling per host (e.g. the create modal).
className?: string;
testId?: string;
}
// Strict key:value tag editor. A tag is committed only on Enter and only when
// it parses to a valid `key:value` pair — bare values are rejected with an
// inline error. Existing chips can be edited inline (double-click), and removed.
function TagKeyValueInput({
tags,
onTagsChange,
placeholder = 'key:value',
className,
testId = 'tag-key-value-input',
}: TagKeyValueInputProps): JSX.Element {
const [inputValue, setInputValue] = useState('');
const [error, setError] = useState('');
const [editIndex, setEditIndex] = useState(-1);
const [editValue, setEditValue] = useState('');
const removeTag = (tag: string): void => {
onTagsChange(tags.filter((t) => t !== tag));
};
const commit = (): void => {
const raw = inputValue.trim();
if (!raw) {
return;
}
const normalized = parseKeyValueTag(raw);
if (!normalized) {
setError('Tags must be in key:value format (both sides required).');
return;
}
if (tags.includes(normalized)) {
setError('This tag already exists.');
return;
}
onTagsChange([...tags, normalized]);
setInputValue('');
setError('');
};
const handleChange = (e: ChangeEvent<HTMLInputElement>): void => {
setInputValue(e.target.value);
if (error) {
setError('');
}
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
if (e.key === 'Enter') {
e.preventDefault();
commit();
}
};
const startEdit = (index: number): void => {
setEditIndex(index);
setEditValue(tags[index]);
setError('');
};
const cancelEdit = (): void => {
setEditIndex(-1);
setEditValue('');
};
const commitEdit = (): void => {
const normalized = parseKeyValueTag(editValue);
// Drop into a no-op (revert) on invalid or duplicate edits rather than
// stranding the user in an un-exitable edit box.
if (normalized && !tags.some((t, i) => t === normalized && i !== editIndex)) {
onTagsChange(tags.map((t, i) => (i === editIndex ? normalized : t)));
}
cancelEdit();
};
const handleEditKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
if (e.key === 'Enter') {
e.preventDefault();
commitEdit();
} else if (e.key === 'Escape') {
e.preventDefault();
cancelEdit();
}
};
return (
<div className={cx(styles.container, className)}>
<div className={styles.field}>
{tags.map((tag, index) =>
index === editIndex ? (
<Input
key={tag}
className={styles.editInput}
value={editValue}
autoFocus
testId={`${testId}-edit`}
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
setEditValue(e.target.value)
}
onKeyDown={handleEditKeyDown}
onBlur={commitEdit}
/>
) : (
<div key={tag} className={styles.tag} data-testid={`${testId}-chip`}>
<Button
variant="ghost"
color="secondary"
className={styles.tagLabel}
title="Double-click to edit"
onDoubleClick={(): void => startEdit(index)}
>
{tag}
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
className={styles.remove}
aria-label={`Remove ${tag}`}
onClick={(): void => removeTag(tag)}
>
<X size={12} />
</Button>
</div>
),
)}
<Input
className={styles.input}
value={inputValue}
placeholder={placeholder}
testId={testId}
onChange={handleChange}
onKeyDown={handleKeyDown}
/>
</div>
{error && (
<Typography className={styles.error} data-testid={`${testId}-error`}>
{error}
</Typography>
)}
</div>
);
}
export default TagKeyValueInput;

View File

@@ -0,0 +1,31 @@
import { parseKeyValueTag } from './utils';
describe('parseKeyValueTag', () => {
it('normalizes a valid key:value pair', () => {
expect(parseKeyValueTag('env:prod')).toBe('env:prod');
});
it('trims whitespace around key and value', () => {
expect(parseKeyValueTag(' env : prod ')).toBe('env:prod');
});
it('keeps colons inside the value', () => {
expect(parseKeyValueTag('url:http://x')).toBe('url:http://x');
});
it('rejects a bare value with no colon', () => {
expect(parseKeyValueTag('prod')).toBeNull();
});
it('rejects an empty key', () => {
expect(parseKeyValueTag(':prod')).toBeNull();
});
it('rejects an empty value', () => {
expect(parseKeyValueTag('env:')).toBeNull();
});
it('rejects blank input', () => {
expect(parseKeyValueTag(' ')).toBeNull();
});
});

View File

@@ -0,0 +1,17 @@
// Tags are strictly key:value. Parse a raw input into a normalized `key:value`
// string, or null if it isn't a valid pair (both sides non-empty). The first
// colon separates key from value, so values may themselves contain colons
// (e.g. `url:http://x`).
export function parseKeyValueTag(raw: string): string | null {
const trimmed = raw.trim();
const idx = trimmed.indexOf(':');
if (idx <= 0) {
return null;
}
const key = trimmed.slice(0, idx).trim();
const value = trimmed.slice(idx + 1).trim();
if (!key || !value) {
return null;
}
return `${key}:${value}`;
}

View File

@@ -267,11 +267,10 @@ describe('createGuardedRoute', () => {
await waitFor(() => {
const heading = document.querySelector('h3');
expect(heading).toBeInTheDocument();
expect(heading?.textContent).toMatch(/permission to view/i);
expect(heading?.textContent).toMatch(/not authorized/i);
});
expect(screen.getByText('update')).toBeInTheDocument();
expect(screen.getByText('role:123')).toBeInTheDocument();
expect(screen.getByText(/update:role:123/)).toBeInTheDocument();
expect(
screen.queryByText('Test Component: test-value'),
).not.toBeInTheDocument();

View File

@@ -5,7 +5,8 @@ import {
AuthZRelation,
BrandedPermission,
} from 'hooks/useAuthZ/types';
import { parsePermission } from 'hooks/useAuthZ/utils';
import { formatPermission } from 'hooks/useAuthZ/utils';
import { useAppContext } from 'providers/App/App';
import noDataUrl from '@/assets/Icons/no-data.svg';
@@ -17,21 +18,16 @@ import './createGuardedRoute.styles.scss';
function OnNoPermissionsFallback(response: {
requiredPermissionName: BrandedPermission;
}): ReactElement {
const { relation, object } = parsePermission(response.requiredPermissionName);
const { user } = useAppContext();
return (
<div className="guard-authz-error-no-authz">
<div className="guard-authz-error-no-authz-content">
<img src={noDataUrl} alt="No permission" />
<h3>Uh-oh! You dont have permission to view this page.</h3>
<h3>Uh-oh! You are not authorized</h3>
<p>
You need the following permission to view this page:
<br />
Relation: <span>{relation}</span>
<br />
Object: <span>{object}</span>
<br />
Please ask your SigNoz administrator to grant access.
<code>user/{user.id}</code> is not authorized to perform{' '}
<code>{formatPermission(response.requiredPermissionName)}</code>
</p>
</div>
</div>

View File

@@ -105,3 +105,8 @@
height: 1px;
background: var(--l1-border);
}
.errorInPlaceContainer {
border-color: var(--callout-error-border) !important;
background: var(--callout-error-background) !important;
}

View File

@@ -15,7 +15,7 @@ import APIError from 'types/api/error';
import PermissionEditor from './components/PermissionEditor';
import { useCreateEditRolePageActions } from './useCreateEditRolePageActions';
import { useNavigationBlocker } from '../../../hooks/useNavigationBlocker';
import { useNavigationBlocker } from 'hooks/useNavigationBlocker';
import styles from './CreateEditRolePage.module.scss';
@@ -212,8 +212,10 @@ function CreateEditRolePage(): JSX.Element {
<ErrorInPlace
error={saveError}
height="auto"
bordered
data-testid="save-error-banner"
padding={0}
bordered={true}
className={styles.errorInPlaceContainer}
/>
)}

View File

@@ -216,6 +216,47 @@ describe('CreateRolePage', () => {
);
});
it('shows error banner with "Role name is required" when saving with empty name', async () => {
const user = userEvent.setup();
renderCreatePage();
const descInput = screen.getByTestId('role-description-input');
await user.type(descInput, 'Description only');
const saveBtn = screen.getByTestId('save-button');
await user.click(saveBtn);
await expect(
screen.findByTestId('save-error-banner'),
).resolves.toBeInTheDocument();
await expect(
screen.findByText('Role name is required'),
).resolves.toBeInTheDocument();
});
it('clears error banner when user starts typing in name field', async () => {
const user = userEvent.setup();
renderCreatePage();
const descInput = screen.getByTestId('role-description-input');
await user.type(descInput, 'Description only');
const saveBtn = screen.getByTestId('save-button');
await user.click(saveBtn);
await expect(
screen.findByTestId('save-error-banner'),
).resolves.toBeInTheDocument();
const nameInput = screen.getByTestId('role-name-input');
await user.type(nameInput, 'a');
await waitFor(() => {
expect(screen.queryByTestId('save-error-banner')).not.toBeInTheDocument();
});
});
it('shows error banner when API fails', async () => {
server.use(
rest.post(rolesApiBase, (_req, res, ctx) =>

View File

@@ -520,4 +520,115 @@ describe('PermissionEditor', () => {
expect(header).toHaveAttribute('aria-expanded', 'true');
});
});
describe('resource card error states', () => {
it('shows error border on collapsed card with validation error', async () => {
const user = userEvent.setup();
renderPage();
const nameInput = screen.getByTestId('role-name-input');
await user.type(nameInput, 'valid-role');
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const header = within(apiKeyCard).getByTestId(
'resource-card-header-factor-api-key',
);
await user.click(header);
const readToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
const onlySelectedBtn = await within(readToggle).findByText('Only selected');
await user.click(onlySelectedBtn);
await user.click(header);
const saveBtn = screen.getByTestId('save-button');
await user.click(saveBtn);
await waitFor(() => {
const card = screen.getByTestId('resource-card-factor-api-key');
expect(card).toHaveAttribute('data-state', 'error');
});
});
it('hides error border when card is expanded', async () => {
const user = userEvent.setup();
renderPage();
const nameInput = screen.getByTestId('role-name-input');
await user.type(nameInput, 'valid-role');
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const header = within(apiKeyCard).getByTestId(
'resource-card-header-factor-api-key',
);
await user.click(header);
const readToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
const onlySelectedBtn = await within(readToggle).findByText('Only selected');
await user.click(onlySelectedBtn);
await user.click(header);
const saveBtn = screen.getByTestId('save-button');
await user.click(saveBtn);
await waitFor(() => {
const card = screen.getByTestId('resource-card-factor-api-key');
expect(card).toHaveAttribute('data-state', 'error');
});
await user.click(header);
await waitFor(() => {
const card = screen.getByTestId('resource-card-factor-api-key');
expect(card).not.toHaveAttribute('data-state');
});
});
it('clears validation error when permission is changed', async () => {
const user = userEvent.setup();
renderPage();
const nameInput = screen.getByTestId('role-name-input');
await user.type(nameInput, 'valid-role');
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const header = within(apiKeyCard).getByTestId(
'resource-card-header-factor-api-key',
);
await user.click(header);
const readToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
const onlySelectedBtn = await within(readToggle).findByText('Only selected');
await user.click(onlySelectedBtn);
await user.click(header);
const saveBtn = screen.getByTestId('save-button');
await user.click(saveBtn);
await expect(
screen.findByTestId('save-error-banner'),
).resolves.toBeInTheDocument();
await user.click(header);
const freshCard = screen.getByTestId('resource-card-factor-api-key');
const freshToggle = within(freshCard).getByTestId(
'action-toggle-factor-api-key-read',
);
const noneBtn = await within(freshToggle).findByText('None');
await user.click(noneBtn);
await waitFor(() => {
expect(screen.queryByTestId('save-error-banner')).not.toBeInTheDocument();
});
});
});
});

View File

@@ -8,6 +8,10 @@
transition: border-color 0.15s ease;
}
.resourceCardError {
border-color: var(--destructive);
}
.resourceCardHeader {
display: flex;
align-items: center;

View File

@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import type { AuthZResource, AuthZVerb } from 'hooks/useAuthZ/types';
@@ -10,6 +10,7 @@ import ActionToggle from './ActionToggle';
import styles from './ResourceCard.module.scss';
import { PermissionScope, ResourcePermissions } from '../../types';
import cx from 'classnames';
interface ResourceCardProps {
resource: ResourcePermissions;
@@ -74,10 +75,22 @@ function ResourceCard({
const [grantedCount, totalCount] = useRoleGrantedCount(resource);
const hasErrorOnResource = useMemo(
() =>
Array.from(validationErrors ?? []).some((r) =>
r.startsWith(resource.resourceId),
),
[validationErrors, resource.resourceId],
);
return (
<div
className={styles.resourceCard}
className={cx(
styles.resourceCard,
hasErrorOnResource && !isExpanded && styles.resourceCardError,
)}
data-testid={`resource-card-${resource.resourceId}`}
data-state={hasErrorOnResource && !isExpanded ? 'error' : undefined}
>
<button
type="button"

View File

@@ -125,8 +125,10 @@ export function useCreateEditRolePageActions(
...prev,
[field]: value,
}));
clearValidationErrors();
setSaveError(null);
},
[],
[clearValidationErrors],
);
const handleModeChange = useCallback(
@@ -139,8 +141,10 @@ export function useCreateEditRolePageActions(
const handleResourcesChange = useCallback(
(resources: ResourcePermissions[]): void => {
setLocalResources(resources);
clearValidationErrors();
setSaveError(null);
},
[],
[clearValidationErrors],
);
const hasUnsavedChanges = useRoleUnsavedChanges(
@@ -153,7 +157,17 @@ export function useCreateEditRolePageActions(
const handleSave = useCallback(async (): Promise<boolean> => {
if (!formData.name.trim()) {
toast.error('Role name is required', { position: 'bottom-center' });
setSaveError(
new APIError({
httpStatusCode: 400,
error: {
code: 'VALIDATION_ERROR',
message: 'Role name is required',
url: '',
errors: [],
},
}),
);
return false;
}

View File

@@ -41,6 +41,11 @@ export function parsePermission(
return { relation: relation as AuthZRelation, object };
}
export function formatPermission(permission: BrandedPermission): string {
const { relation, object } = parsePermission(permission);
return `${relation}:${object}`;
}
const kindsByType = permissionsType.data.resources.reduce(
(acc, r) => {
if (!acc[r.type]) {

View File

@@ -68,13 +68,3 @@
border-radius: 2px;
border: 1px solid var(--l2-border);
}
// the V1 tags input ships borderless; give the field a visible box to match
.tagsField {
display: flex;
align-items: center;
padding: 6px 8px;
border-radius: 2px;
border: 1px solid var(--l2-border);
// background: var(--l3-background);
}

View File

@@ -9,7 +9,7 @@ import {
import { Typography } from '@signozhq/ui/typography';
// eslint-disable-next-line signoz/no-antd-components -- multiline TextArea has no @signozhq/ui equivalent yet
import { Input as AntdInput } from 'antd';
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddBadges';
import TagKeyValueInput from 'components/TagKeyValueInput/TagKeyValueInput';
import { Base64Icons } from '../utils';
import settingsStyles from '../../DashboardSettings.module.scss';
@@ -89,9 +89,7 @@ function DashboardInfoForm({
<div className={styles.infoItemContainer}>
<Typography className={styles.infoTitle}>Tags</Typography>
<div className={styles.tagsField}>
<AddTags tags={tags} setTags={onTagsChange} />
</div>
<TagKeyValueInput tags={tags} onTagsChange={onTagsChange} />
</div>
</div>
</div>

View File

@@ -1,6 +1,7 @@
import type { TagtypesPostableTagDTO } from 'api/generated/services/sigNoz.schemas';
export { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
export { parseKeyValueTag } from 'components/TagKeyValueInput/utils';
// tag UX, a string with no ':' is round-tripped as `{key: x, value: x}` and
// collapsed back to just `x` for display.

View File

@@ -1,11 +1,9 @@
import { Input } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type {
DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
import { resolveSignal } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
import type { EQueryType } from 'types/common/dashboard';
import type { LegendSeries } from '../hooks/useLegendSeries';
import type { TableColumnOption } from '../hooks/useTableColumns';
@@ -22,10 +20,18 @@ interface ConfigPaneProps {
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
/** Switch the panel to another visualization kind. */
onChangePanelKind: (kind: PanelKind) => void;
/**
* Active query type from the query-builder provider (the selected tab). Drives which
* panel types the visualization switcher disables — read from the provider, not the
* spec, because a new panel's spec has no query until staged.
*/
queryType: EQueryType;
/** Panel's resolved series, provided to sections that need them (legend colors). */
legendSeries: LegendSeries[];
/** Table panel's resolved value columns, for the table-only editors. */
tableColumns: TableColumnOption[];
/** Query step interval (seconds), for the chart-appearance span-gaps floor. */
stepInterval?: number;
}
/**
@@ -39,15 +45,15 @@ function ConfigPane({
spec,
onChangeSpec,
onChangePanelKind,
queryType,
legendSeries,
tableColumns,
stepInterval,
}: ConfigPaneProps): JSX.Element {
const definition = getPanelDefinition(panelKind);
const sections = definition.sections;
const signal = getBuilderQueries(spec.queries)[0]?.signal as
| TelemetrytypesSignalDTO
| undefined;
const signal = resolveSignal(spec.queries, definition.supportedSignals[0]);
// Title/description are just a slice of the spec — edit them through the same
// onChangeSpec path the sections use, so there's a single editing surface.
@@ -100,6 +106,8 @@ function ConfigPane({
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
queryType={queryType}
stepInterval={stepInterval}
/>
))}
</div>

View File

@@ -1,39 +1,50 @@
import { Typography } from '@signozhq/ui/typography';
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import { getPanelDefinition } from '../../../Panels/registry';
import type { PanelKind } from '../../../Panels/types/panelKind';
import { PANEL_TYPES } from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/constants';
import ConfigSelect from '../controls/ConfigSelect/ConfigSelect';
import styles from './PanelTypeSwitcher.module.scss';
import { getPanelTypeDisabledReason } from './utils';
interface PanelTypeSwitcherProps {
/** The current panel kind (selected value). */
panelKind: PanelKind;
/** Panel's current datasource — drives the disabled rule. */
/** Active query type — a kind that can't be authored in it is disabled (e.g. List is Query-Builder-only, so PromQL/ClickHouse disable it). Defaults to Query Builder. */
queryType?: EQueryType;
/** Panel's current signal — also gates the disabled rule (List needs logs/traces, not metrics). */
signal?: TelemetrytypesSignalDTO;
onChange: (kind: PanelKind) => void;
}
/**
* Visualization-type selector (rendered inside the Visualization section). Types whose
* supported signals exclude the panel's current datasource are disabled (V1 parity
* e.g. List needs logs/traces, not metrics). The datasource is unknown for
* PromQL/ClickHouse queries, in which case no type is disabled.
* Visualization-type selector (rendered inside the Visualization section). A type is
* disabled when the active query type or signal is incompatible with it — resolved
* through the capabilities guard. The signal is unknown for PromQL/ClickHouse, but
* those query types still disable kinds that only support Query Builder (e.g. List).
*/
function PanelTypeSwitcher({
panelKind,
queryType,
signal,
onChange,
}: PanelTypeSwitcherProps): JSX.Element {
const items = PANEL_TYPES.map(({ panelKind, label, Icon }) => {
const definition = getPanelDefinition(panelKind);
// One reason drives both the disabled flag and the tooltip, so they can't disagree.
const disabledReason = getPanelTypeDisabledReason({
kind: panelKind,
queryType: queryType ?? EQueryType.QUERY_BUILDER,
signal,
label,
});
return {
value: panelKind,
label,
icon: <Icon size={14} />,
disabled: !!signal && !definition.supportedSignals.includes(signal),
disabled: !!disabledReason,
tooltip: disabledReason,
};
});

View File

@@ -3,6 +3,7 @@ import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Pan
import PanelTypeSwitcher from '../PanelTypeSwitcher';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
jest.mock('pages/DashboardPageV2/DashboardContainer/Panels/registry', () => ({
getPanelDefinition: jest.fn(),
@@ -10,6 +11,19 @@ jest.mock('pages/DashboardPageV2/DashboardContainer/Panels/registry', () => ({
const mockGetPanelDefinition = getPanelDefinition as unknown as jest.Mock;
// Query-type support per kind: List is Query-Builder-only; Table/Pie drop PromQL.
const SUPPORTED_QUERY_TYPES: Record<string, EQueryType[]> = {
'signoz/ListPanel': [EQueryType.QUERY_BUILDER],
'signoz/TablePanel': [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
'signoz/PieChartPanel': [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
};
function disabledLabels(): (string | null)[] {
return Array.from(
document.querySelectorAll('.ant-select-item-option-disabled'),
).map((el) => el.textContent);
}
function openDropdown(): void {
fireEvent.mouseDown(screen.getByRole('combobox'));
}
@@ -18,11 +32,17 @@ describe('PanelTypeSwitcher', () => {
beforeEach(() => {
jest.clearAllMocks();
// List supports only logs/traces; every other kind also supports metrics.
// Query-type support comes from SUPPORTED_QUERY_TYPES (all three by default).
mockGetPanelDefinition.mockImplementation((kind: string) => ({
supportedSignals:
kind === 'signoz/ListPanel'
? ['logs', 'traces']
: ['metrics', 'logs', 'traces'],
supportedQueryTypes: SUPPORTED_QUERY_TYPES[kind] ?? [
EQueryType.QUERY_BUILDER,
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
}));
});
@@ -38,7 +58,7 @@ describe('PanelTypeSwitcher', () => {
expect(onChange).toHaveBeenCalledWith('signoz/ListPanel');
});
it('disables types whose supported signals exclude the current datasource', () => {
it('disables types whose supported signals exclude the current signal', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
@@ -48,16 +68,12 @@ describe('PanelTypeSwitcher', () => {
);
openDropdown();
const disabled = Array.from(
document.querySelectorAll('.ant-select-item-option-disabled'),
).map((el) => el.textContent);
// List can't render a metrics query, so it's disabled; Time Series stays enabled.
expect(disabled).toContain('List');
expect(disabled).not.toContain('Time Series');
expect(disabledLabels()).toContain('List');
expect(disabledLabels()).not.toContain('Time Series');
});
it('does not disable any type when the datasource is unknown', () => {
it('does not disable any type when the signal is unknown (builder, no signal)', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
@@ -70,4 +86,37 @@ describe('PanelTypeSwitcher', () => {
document.querySelectorAll('.ant-select-item-option-disabled'),
).toHaveLength(0);
});
it('disables Query-Builder-only kinds under PromQL even without a signal', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.PROM}
onChange={jest.fn()}
/>,
);
openDropdown();
// List/Table/Pie can't be authored in PromQL; Time Series can.
expect(disabledLabels()).toContain('List');
expect(disabledLabels()).toContain('Table');
expect(disabledLabels()).toContain('Pie Chart');
expect(disabledLabels()).not.toContain('Time Series');
});
it('disables List under ClickHouse while Table/Pie stay enabled', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TablePanel"
queryType={EQueryType.CLICKHOUSE}
onChange={jest.fn()}
/>,
);
openDropdown();
expect(disabledLabels()).toContain('List');
expect(disabledLabels()).not.toContain('Table');
expect(disabledLabels()).not.toContain('Pie Chart');
expect(disabledLabels()).not.toContain('Time Series');
});
});

View File

@@ -0,0 +1,73 @@
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import { getPanelTypeDisabledReason } from '../utils';
const { QUERY_BUILDER, CLICKHOUSE, PROM } = EQueryType;
const { logs, metrics } = TelemetrytypesSignalDTO;
describe('getPanelTypeDisabledReason', () => {
it('returns undefined for a supported combination', () => {
expect(
getPanelTypeDisabledReason({
kind: 'signoz/TimeSeriesPanel',
queryType: PROM,
label: 'Time Series',
}),
).toBeUndefined();
expect(
getPanelTypeDisabledReason({
kind: 'signoz/ListPanel',
queryType: QUERY_BUILDER,
signal: logs,
label: 'List',
}),
).toBeUndefined();
});
it('explains an unsupported query type', () => {
expect(
getPanelTypeDisabledReason({
kind: 'signoz/ListPanel',
queryType: PROM,
label: 'List',
}),
).toBe("List isn't available for PromQL queries");
expect(
getPanelTypeDisabledReason({
kind: 'signoz/ListPanel',
queryType: CLICKHOUSE,
label: 'List',
}),
).toBe("List isn't available for ClickHouse queries");
expect(
getPanelTypeDisabledReason({
kind: 'signoz/TablePanel',
queryType: PROM,
label: 'Table',
}),
).toBe("Table isn't available for PromQL queries");
});
it('explains an unsupported signal', () => {
expect(
getPanelTypeDisabledReason({
kind: 'signoz/ListPanel',
queryType: QUERY_BUILDER,
signal: metrics,
label: 'List',
}),
).toBe("List doesn't support metrics data");
});
it('prefers the query-type reason when both are incompatible', () => {
expect(
getPanelTypeDisabledReason({
kind: 'signoz/ListPanel',
queryType: PROM,
signal: metrics,
label: 'List',
}),
).toBe("List isn't available for PromQL queries");
});
});

View File

@@ -0,0 +1,46 @@
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import {
isQueryTypeSupported,
isSignalSupported,
} from '../../../Panels/capabilities';
import type { PanelKind } from '../../../Panels/types/panelKind';
const QUERY_TYPE_LABEL: Record<EQueryType, string> = {
[EQueryType.QUERY_BUILDER]: 'Query Builder',
[EQueryType.CLICKHOUSE]: 'ClickHouse',
[EQueryType.PROM]: 'PromQL',
};
const SIGNAL_LABEL: Record<TelemetrytypesSignalDTO, string> = {
[TelemetrytypesSignalDTO.logs]: 'logs',
[TelemetrytypesSignalDTO.traces]: 'traces',
[TelemetrytypesSignalDTO.metrics]: 'metrics',
};
/**
* Why a panel kind can't be selected for the current query type / signal, or
* `undefined` when it can. Drives both the type switcher's disabled state and its
* tooltip, so the two never disagree. The query-type reason takes precedence (it's the
* outer choice): query types carry no signal, so the signal only matters in builder.
*/
export function getPanelTypeDisabledReason({
kind,
queryType,
signal,
label,
}: {
kind: PanelKind;
queryType: EQueryType;
signal?: TelemetrytypesSignalDTO;
label: string;
}): string | undefined {
if (!isQueryTypeSupported(kind, queryType)) {
return `${label} isn't available for ${QUERY_TYPE_LABEL[queryType]} queries`;
}
if (signal !== undefined && !isSignalSupported(kind, signal)) {
return `${label} doesn't support ${SIGNAL_LABEL[signal]} data`;
}
return undefined;
}

View File

@@ -1,7 +1,4 @@
import type {
DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import {
type PanelFormattingSlice,
SECTION_METADATA,
@@ -9,26 +6,16 @@ import {
SectionKind,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import type { PanelKind } from '../../../Panels/types/panelKind';
import type { LegendSeries } from '../../hooks/useLegendSeries';
import type { TableColumnOption } from '../../hooks/useTableColumns';
import type { SectionEditorContext } from '../sectionContext';
import { resolveSectionEditor } from '../sectionRegistry';
import SettingsSection from '../SettingsSection/SettingsSection';
interface SectionSlotProps {
// `yAxisUnit` is derived from the spec below, not forwarded, so it's omitted.
type SectionSlotProps = {
config: SectionConfig;
spec: DashboardtypesPanelSpecDTO;
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
/** Resolved series, forwarded to editors that need them (legend colors). */
legendSeries: LegendSeries[];
/** Table panel's resolved value columns, for the table-only editors. */
tableColumns: TableColumnOption[];
/** Panel's telemetry signal, for editors that fetch field suggestions (List columns). */
signal?: TelemetrytypesSignalDTO;
/** Current panel kind + switch handler, for the visualization section's type switcher. */
panelKind: PanelKind;
onChangePanelKind: (kind: PanelKind) => void;
}
} & Omit<SectionEditorContext, 'yAxisUnit'>;
/**
* Renders one configuration section: its collapsible wrapper plus the registered editor
@@ -45,6 +32,8 @@ function SectionSlot({
signal,
panelKind,
onChangePanelKind,
queryType,
stepInterval,
}: SectionSlotProps): JSX.Element | null {
// A kind can hide a section based on current spec state (e.g. Histogram legend once
// queries are merged) — skip it before resolving the editor.
@@ -83,6 +72,8 @@ function SectionSlot({
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
queryType={queryType}
stepInterval={stepInterval}
/>
</SettingsSection>
);

View File

@@ -26,13 +26,15 @@ function SettingsSection({
}: SettingsSectionProps): JSX.Element {
const [isOpen, setIsOpen] = useState(defaultOpen);
const serializedTitle = title.toLowerCase().replace(/\s+/g, '-');
return (
<section className={styles.section}>
<button
type="button"
className={styles.header}
aria-expanded={isOpen}
data-testid={`config-section-${title}`}
data-testid={`config-section-${serializedTitle}`}
onClick={(): void => setIsOpen((prev) => !prev)}
>
{icon && (

View File

@@ -1,5 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react';
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import ConfigPane from '../ConfigPane';
@@ -22,6 +23,7 @@ function renderConfigPane(
spec: spec(),
onChangeSpec: jest.fn(),
onChangePanelKind: jest.fn(),
queryType: EQueryType.QUERY_BUILDER,
legendSeries: [],
tableColumns: [],
...overrides,
@@ -57,6 +59,8 @@ describe('ConfigPane', () => {
it('renders the Formatting section for a kind that declares it', () => {
renderConfigPane();
// The TimeSeries kind declares a Formatting section; its collapsible header shows.
expect(screen.getByTestId('config-section-Formatting')).toBeInTheDocument();
expect(
screen.getByTestId('config-section-formatting-&-units'),
).toBeInTheDocument();
});
});

View File

@@ -10,11 +10,11 @@ export interface ConfigSegmentedItem {
icon?: SegmentIconName;
}
interface ConfigSegmentedProps {
interface ConfigSegmentedProps<T extends string = string> {
testId: string;
value: string | undefined;
value: T | undefined;
items: ConfigSegmentedItem[];
onChange: (value: string) => void;
onChange: (value: T) => void;
}
/**
@@ -23,12 +23,12 @@ interface ConfigSegmentedProps {
* brightens with the selected state (it inherits the toggle's `currentColor`). Built on
* the Periscope ToggleGroup so it stays theme-faithful.
*/
function ConfigSegmented({
function ConfigSegmented<T extends string = string>({
testId,
value,
items,
onChange,
}: ConfigSegmentedProps): JSX.Element {
}: ConfigSegmentedProps<T>): JSX.Element {
return (
<ToggleGroupSimple
type="single"
@@ -47,7 +47,7 @@ function ConfigSegmented({
}))}
// Single toggle-groups emit '' when the active segment is re-clicked; ignore that
// so a required choice (e.g. scale, position) can't be cleared to an empty value.
onChange={(next: string): void => {
onChange={(next: T): void => {
if (next) {
onChange(next);
}

View File

@@ -8,3 +8,11 @@
align-items: center;
gap: 9px;
}
// Wraps a tooltip-bearing option so the hover target fills the row and still receives
// pointer events when the option is disabled (antd dims it but doesn't block events).
.tooltipTrigger {
display: block;
width: 100%;
pointer-events: auto;
}

View File

@@ -1,5 +1,5 @@
import type { ReactNode } from 'react';
import { Select } from 'antd';
import { Select, Tooltip } from 'antd';
import styles from './ConfigSelect.module.scss';
@@ -9,6 +9,8 @@ export interface ConfigSelectItem<T extends string = string> {
/** Optional leading icon node rendered before the label. */
icon?: ReactNode;
disabled?: boolean;
/** Hover hint shown on the option — typically the reason a disabled item is disabled. */
tooltip?: string;
}
interface ConfigSelectProps<T extends string = string> {
@@ -39,18 +41,27 @@ function ConfigSelect<T extends string = string>({
placeholder={placeholder}
onChange={onChange}
virtual={false}
options={items.map((item) => ({
value: item.value,
disabled: item.disabled,
label: item.icon ? (
options={items.map((item) => {
const content = item.icon ? (
<span className={styles.item}>
{item.icon}
{item.label}
</span>
) : (
item.label
),
}))}
);
return {
value: item.value,
disabled: item.disabled,
label: item.tooltip ? (
<Tooltip title={item.tooltip} placement="top">
<span className={styles.tooltipTrigger}>{content}</span>
</Tooltip>
) : (
content
),
};
})}
/>
);
}

View File

@@ -0,0 +1,22 @@
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { PanelKind } from '../../Panels/types/panelKind';
import type { LegendSeries } from '../hooks/useLegendSeries';
import type { TableColumnOption } from '../hooks/useTableColumns';
import { EQueryType } from 'types/common/dashboard';
/**
* Context `SectionSlot` forwards to every section editor (not spec-slice fields — those
* come from `SectionEditorProps<K>`); each editor `Pick`s what it consumes. All optional:
* editors resolve through the kind-erased descriptor, so receipt isn't type-guaranteed.
*/
export interface SectionEditorContext {
legendSeries?: LegendSeries[];
tableColumns?: TableColumnOption[];
signal?: TelemetrytypesSignalDTO;
panelKind?: PanelKind;
onChangePanelKind?: (kind: PanelKind) => void;
yAxisUnit?: string;
queryType?: EQueryType;
stepInterval?: number;
}

View File

@@ -16,6 +16,7 @@ import {
type SectionSpecMap,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import type { SectionEditorContext } from './sectionContext';
import AxesSection from './sections/AxesSection/AxesSection';
import BucketsSection from './sections/BucketsSection/BucketsSection';
import ChartAppearanceSection from './sections/ChartAppearanceSection/ChartAppearanceSection';
@@ -142,26 +143,13 @@ export const SECTION_REGISTRY: {
* `get` → `Component` → `update` without any further casts.
*/
export interface ErasedSectionDescriptor {
Component: ComponentType<{
value: unknown;
controls?: unknown;
onChange: (next: unknown) => void;
// Forwarded to every editor; only sections that need the panel's resolved series
// (legend colors) read it. Optional so editors can ignore it.
legendSeries?: unknown;
// The panel's formatting unit; read by editors that scope to it (thresholds).
yAxisUnit?: unknown;
// The Table panel's resolved value columns; read by the table-only editors
// (column units, per-column thresholds) to offer real columns.
tableColumns?: unknown;
// The panel's telemetry signal; read by editors that fetch field-key
// suggestions scoped to it (List column picker).
signal?: unknown;
// Current panel kind + switch handler; read by the visualization section's
// type switcher.
panelKind?: unknown;
onChangePanelKind?: unknown;
}>;
Component: ComponentType<
{
value: unknown;
controls?: unknown;
onChange: (next: unknown) => void;
} & SectionEditorContext
>;
get: (spec: PanelSpec) => unknown;
update: (spec: PanelSpec, value: unknown) => PanelSpec;
}

View File

@@ -3,3 +3,14 @@
flex-direction: column;
gap: 8px;
}
.thresholdField {
display: flex;
flex-direction: column;
gap: 8px;
}
.thresholdPrefix {
padding-right: 4px;
opacity: 0.6;
}

View File

@@ -1,6 +1,4 @@
import type { ChangeEvent } from 'react';
import { Typography } from '@signozhq/ui/typography';
import { Input } from 'antd';
import {
DashboardtypesFillModeDTO,
DashboardtypesLineInterpolationDTO,
@@ -15,6 +13,8 @@ import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
import { SegmentIcon } from '../../controls/segmentIcons';
import type { SectionEditorContext } from '../../sectionContext';
import DisconnectValuesField from './DisconnectValuesField';
import styles from './ChartAppearanceSection.module.scss';
@@ -81,16 +81,9 @@ function ChartAppearanceSection({
value,
controls,
onChange,
}: SectionEditorProps<SectionKind.ChartAppearance>): JSX.Element {
// `spanGaps.fillLessThan` is a stringified seconds threshold: empty means "connect
// every gap" (the chart default), a number means "only bridge gaps shorter than this".
const handleSpanGaps = (e: ChangeEvent<HTMLInputElement>): void => {
const raw = e.target.value;
onChange({
...value,
spanGaps: raw === '' ? undefined : { ...value?.spanGaps, fillLessThan: raw },
});
};
stepInterval,
}: SectionEditorProps<SectionKind.ChartAppearance> &
Pick<SectionEditorContext, 'stepInterval'>): JSX.Element {
return (
<>
{controls.lineStyle && (
@@ -150,16 +143,12 @@ function ChartAppearanceSection({
)}
{controls.spanGaps && (
<div className={styles.field}>
<Typography.Text>Connect gaps shorter than (s)</Typography.Text>
<Input
data-testid="panel-editor-v2-span-gaps"
type="number"
placeholder="All gaps"
value={value?.spanGaps?.fillLessThan ?? ''}
onChange={handleSpanGaps}
/>
</div>
<DisconnectValuesField
testId="panel-editor-v2-span-gaps"
value={value?.spanGaps}
stepInterval={stepInterval}
onChange={(spanGaps): void => onChange({ ...value, spanGaps })}
/>
)}
</>
);

View File

@@ -0,0 +1,97 @@
import { useEffect, useState } from 'react';
import { rangeUtil } from '@grafana/data';
import { Typography } from '@signozhq/ui/typography';
import type { DashboardtypesSpanGapsDTO } from 'api/generated/services/sigNoz.schemas';
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
import DisconnectValuesThresholdInput from './DisconnectValuesThresholdInput';
import styles from './ChartAppearanceSection.module.scss';
const DEFAULT_THRESHOLD = '1m';
enum DisconnectValuesMode {
NEVER = 'never',
THRESHOLD = 'threshold',
}
const MODE_OPTIONS = [
{ value: DisconnectValuesMode.NEVER, label: 'Never' },
{ value: DisconnectValuesMode.THRESHOLD, label: 'Threshold' },
];
interface DisconnectValuesFieldProps {
testId: string;
value: DashboardtypesSpanGapsDTO | undefined;
/** Query step interval (seconds): seeds the default threshold and floors it. */
stepInterval?: number;
onChange: (next: DashboardtypesSpanGapsDTO | undefined) => void;
}
/** Default threshold duration: the step interval (smallest meaningful), else 1m. */
function defaultDuration(stepInterval?: number): string {
return stepInterval && stepInterval > 0
? rangeUtil.secondsToHms(stepInterval)
: DEFAULT_THRESHOLD;
}
/**
* "Disconnect values": Never (span every gap — the chart default) vs Threshold
* (only bridge gaps shorter than a duration). The threshold persists as a
* duration string in `spanGaps.fillLessThan` ("10m", "5s") — the wire format the
* backend expects.
*/
function DisconnectValuesField({
testId,
value,
stepInterval,
onChange,
}: DisconnectValuesFieldProps): JSX.Element {
const duration = value?.fillLessThan || undefined;
const isThreshold = !!duration;
// Remember the last threshold so toggling Never → Threshold restores it.
const [lastDuration, setLastDuration] = useState(
duration ?? defaultDuration(stepInterval),
);
useEffect(() => {
if (duration) {
setLastDuration(duration);
}
}, [duration]);
const handleMode = (mode: DisconnectValuesMode): void => {
onChange(
mode === DisconnectValuesMode.THRESHOLD
? { ...value, fillLessThan: lastDuration }
: undefined,
);
};
return (
<>
<div className={styles.field}>
<Typography.Text>Disconnect values</Typography.Text>
<ConfigSegmented
testId={testId}
value={
isThreshold ? DisconnectValuesMode.THRESHOLD : DisconnectValuesMode.NEVER
}
items={MODE_OPTIONS}
onChange={handleMode}
/>
</div>
{isThreshold && (
<div className={styles.field}>
<Typography.Text>Threshold value</Typography.Text>
<DisconnectValuesThresholdInput
testId={`${testId}-value`}
value={lastDuration}
minValue={stepInterval}
onChange={(next): void => onChange({ ...value, fillLessThan: next })}
/>
</div>
)}
</>
);
}
export default DisconnectValuesField;

View File

@@ -0,0 +1,94 @@
import { type ChangeEvent, useEffect, useState } from 'react';
import { rangeUtil } from '@grafana/data';
import { Callout } from '@signozhq/ui/callout';
import { Input } from 'antd';
import styles from './ChartAppearanceSection.module.scss';
interface DisconnectValuesThresholdInputProps {
testId: string;
/** Current threshold as a duration string (e.g. "1m") — the stored wire value. */
value: string;
/** Smallest allowed threshold (the query step interval), in seconds. */
minValue?: number;
onChange: (duration: string) => void;
}
/**
* Duration input for the span-gaps threshold: shows/accepts and reports a human
* duration ("30s", "1m", "1h"), which is the value stored verbatim in
* `fillLessThan` (a bare number is read as seconds). It is only parsed to seconds
* to validate against the query step interval. Invalid entries, or values below
* that floor, surface an inline error and are not committed (V1 parity).
*/
function DisconnectValuesThresholdInput({
testId,
value,
minValue,
onChange,
}: DisconnectValuesThresholdInputProps): JSX.Element {
const [text, setText] = useState(value);
const [error, setError] = useState<string | null>(null);
// Resync the displayed duration when the committed value changes upstream.
useEffect(() => {
setText(value);
setError(null);
}, [value]);
const commit = (raw: string): void => {
if (!raw) {
return;
}
let seconds: number;
try {
seconds = rangeUtil.isValidTimeSpan(raw)
? rangeUtil.intervalToSeconds(raw)
: NaN;
} catch {
seconds = NaN;
}
if (!Number.isFinite(seconds) || seconds <= 0) {
setError('Enter a valid duration (e.g. 30s, 1m, 1h)');
return;
}
if (minValue !== undefined && seconds < minValue) {
setError(`Threshold should be > ${rangeUtil.secondsToHms(minValue)}`);
return;
}
setError(null);
// Store the user's duration string as-is — the wire format the backend wants.
onChange(raw);
};
return (
<div className={styles.thresholdField}>
<Input
data-testid={testId}
type="text"
status={error ? 'error' : undefined}
prefix={<span className={styles.thresholdPrefix}>&gt;</span>}
value={text}
onChange={(e: ChangeEvent<HTMLInputElement>): void => {
setText(e.target.value);
if (error) {
setError(null);
}
}}
onBlur={(e): void => commit(e.currentTarget.value)}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
commit(e.currentTarget.value);
}
}}
/>
{error && (
<Callout type="error" size="small" showIcon>
{error}
</Callout>
)}
</div>
);
}
export default DisconnectValuesThresholdInput;

View File

@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DashboardtypesLineStyleDTO } from 'api/generated/services/sigNoz.schemas';
@@ -60,7 +60,8 @@ describe('ChartAppearanceSection', () => {
).not.toBeInTheDocument();
});
it('writes the chosen fill mode through the segmented control', () => {
it('writes the chosen fill mode through the segmented control', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ChartAppearanceSection
@@ -70,7 +71,7 @@ describe('ChartAppearanceSection', () => {
/>,
);
fireEvent.click(screen.getByText('Gradient'));
await user.click(screen.getByText('Gradient'));
expect(onChange).toHaveBeenCalledWith({
lineStyle: 'solid',
@@ -93,7 +94,8 @@ describe('ChartAppearanceSection', () => {
expect(onChange).toHaveBeenCalledWith({ lineInterpolation: 'spline' });
});
it('toggles show points through onChange', () => {
it('toggles show points through onChange', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ChartAppearanceSection
@@ -103,14 +105,30 @@ describe('ChartAppearanceSection', () => {
/>,
);
fireEvent.click(screen.getByTestId('panel-editor-v2-show-points'));
await user.click(screen.getByTestId('panel-editor-v2-show-points'));
expect(onChange).toHaveBeenCalledWith({ showPoints: true });
});
it('writes a span-gaps threshold and clears it when emptied', () => {
it('defaults to "Never" (no threshold) and hides the threshold input', () => {
render(
<ChartAppearanceSection
value={undefined}
controls={{ spanGaps: true }}
onChange={jest.fn()}
/>,
);
expect(screen.getByText('Never')).toBeInTheDocument();
expect(
screen.queryByTestId('panel-editor-v2-span-gaps-value'),
).not.toBeInTheDocument();
});
it('switching to "Threshold" seeds the default 1m threshold', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
const { rerender } = render(
render(
<ChartAppearanceSection
value={undefined}
controls={{ spanGaps: true }}
@@ -118,23 +136,112 @@ describe('ChartAppearanceSection', () => {
/>,
);
fireEvent.change(screen.getByTestId('panel-editor-v2-span-gaps'), {
target: { value: '60' },
});
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillLessThan: '60' },
});
await user.click(screen.getByText('Threshold'));
rerender(
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillLessThan: '1m' },
});
});
it('stores the threshold as a duration string (not seconds)', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ChartAppearanceSection
value={{ spanGaps: { fillLessThan: '60' } }}
value={{ spanGaps: { fillLessThan: '1m' } }}
controls={{ spanGaps: true }}
onChange={onChange}
/>,
);
fireEvent.change(screen.getByTestId('panel-editor-v2-span-gaps'), {
target: { value: '' },
const input = screen.getByTestId('panel-editor-v2-span-gaps-value');
expect(input).toHaveValue('1m');
await user.clear(input);
await user.type(input, '5m');
await user.tab();
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillLessThan: '5m' },
});
});
it('stores the entry verbatim (bare number kept as typed, not converted)', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ChartAppearanceSection
value={{ spanGaps: { fillLessThan: '1m' } }}
controls={{ spanGaps: true }}
onChange={onChange}
/>,
);
const input = screen.getByTestId('panel-editor-v2-span-gaps-value');
await user.clear(input);
await user.type(input, '300');
await user.tab();
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillLessThan: '300' },
});
});
it('switching back to "Never" clears the threshold', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ChartAppearanceSection
value={{ spanGaps: { fillLessThan: '1m' } }}
controls={{ spanGaps: true }}
onChange={onChange}
/>,
);
await user.click(screen.getByText('Never'));
expect(onChange).toHaveBeenLastCalledWith({ spanGaps: undefined });
});
it('shows an error and does not commit an invalid duration', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ChartAppearanceSection
value={{ spanGaps: { fillLessThan: '1m' } }}
controls={{ spanGaps: true }}
onChange={onChange}
/>,
);
const input = screen.getByTestId('panel-editor-v2-span-gaps-value');
await user.clear(input);
await user.type(input, 'abc');
await user.tab();
expect(screen.getByText(/valid duration/i)).toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
});
it('rejects a threshold below the query step interval', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ChartAppearanceSection
value={{ spanGaps: { fillLessThan: '2m' } }}
controls={{ spanGaps: true }}
stepInterval={120}
onChange={onChange}
/>,
);
const input = screen.getByTestId('panel-editor-v2-span-gaps-value');
// 1m (60s) is below the 2m (120s) step interval.
await user.clear(input);
await user.type(input, '1m');
await user.tab();
expect(screen.getByText(/Threshold should be >/)).toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
});
});

View File

@@ -7,16 +7,14 @@ import type {
SectionKind,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import type { TableColumnOption } from '../../../hooks/useTableColumns';
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
import type { SectionEditorContext } from '../../sectionContext';
import ColumnUnits from './ColumnUnits';
import styles from './FormattingSection.module.scss';
type FormattingSectionProps = SectionEditorProps<SectionKind.Formatting> & {
/** Table panel's resolved value columns; required for the column-units editor. */
tableColumns?: TableColumnOption[];
};
type FormattingSectionProps = SectionEditorProps<SectionKind.Formatting> &
Pick<SectionEditorContext, 'tableColumns'>;
// `full` means "show the raw value, no rounding"; the digits round to that many places.
const DECIMAL_OPTIONS: {

View File

@@ -7,14 +7,12 @@ import type {
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
import LegendColors from '../../controls/LegendColors/LegendColors';
import type { LegendSeries } from '../../../hooks/useLegendSeries';
import type { SectionEditorContext } from '../../sectionContext';
import styles from './LegendSection.module.scss';
type LegendSectionProps = SectionEditorProps<SectionKind.Legend> & {
/** Panel's resolved series, forwarded by SectionSlot for the colors control. */
legendSeries?: LegendSeries[];
};
type LegendSectionProps = SectionEditorProps<SectionKind.Legend> &
Pick<SectionEditorContext, 'legendSeries'>;
const POSITION_OPTIONS = [
{

View File

@@ -14,6 +14,7 @@ import type {
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import type { TableColumnOption } from '../../../hooks/useTableColumns';
import type { SectionEditorContext } from '../../sectionContext';
import ComparisonThresholdRow from './rows/ComparisonThresholdRow';
import LabelThresholdRow from './rows/LabelThresholdRow';
import TableThresholdRow from './rows/TableThresholdRow';
@@ -61,11 +62,7 @@ type ThresholdsSectionProps = {
/** `variant` picks the row editor + element shape; defaults to `label`. */
controls?: { variant?: ThresholdVariant };
onChange: (next: AnyThreshold[]) => void;
/** Panel formatting unit; scopes each row's unit picker to its category (V1 parity). */
yAxisUnit?: string;
/** Table panel's resolved value columns (table variant only). */
tableColumns?: TableColumnOption[];
};
} & Pick<SectionEditorContext, 'yAxisUnit' | 'tableColumns'>;
/**
* Edits the `thresholds` slice for every panel kind. All variants share the same

View File

@@ -123,6 +123,25 @@ describe('ComparisonThresholdsSection', () => {
]);
});
it('lets the value input be cleared instead of snapping back to 0', async () => {
const user = userEvent.setup();
render(
<ComparisonThresholdsSection value={THRESHOLDS} onChange={jest.fn()} />,
);
await user.click(screen.getByTestId('comparison-threshold-edit-0'));
const valueInput = screen.getByTestId('comparison-threshold-value-0');
// Regression: clearing used to coerce "" → 0 and refill the field, so the
// seeded value could never be removed.
await user.clear(valueInput);
expect(valueInput).toHaveValue(null);
// And a fresh value can be typed into the now-empty field.
await user.type(valueInput, '5');
expect(valueInput).toHaveValue(5);
});
it('does not commit edits when Discard is clicked', async () => {
const user = userEvent.setup();
const onChange = jest.fn();

View File

@@ -1,3 +1,4 @@
import { useEffect, useState } from 'react';
import { Typography } from '@signozhq/ui/typography';
import { Input } from 'antd';
@@ -16,6 +17,12 @@ function ThresholdValueField({
value,
onChange,
}: ThresholdValueFieldProps): JSX.Element {
const [raw, setRaw] = useState(String(value));
useEffect(() => {
setRaw((prev) => (Number(prev) === value ? prev : String(value)));
}, [value]);
return (
<div className={styles.field}>
<Typography.Text className={styles.fieldLabel}>Value</Typography.Text>
@@ -23,8 +30,11 @@ function ThresholdValueField({
data-testid={testId}
type="number"
placeholder="Value"
value={value}
onChange={(e): void => onChange(e.target.value)}
value={raw}
onChange={(e): void => {
setRaw(e.target.value);
onChange(e.target.value);
}}
/>
</div>
);

View File

@@ -1,26 +1,22 @@
import { Typography } from '@signozhq/ui/typography';
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import type { PanelKind } from '../../../../Panels/types/panelKind';
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
import PanelTypeSwitcher from '../../PanelTypeSwitcher/PanelTypeSwitcher';
import type { SectionEditorContext } from '../../sectionContext';
import { TIME_PREFERENCE_OPTIONS } from './timePreferenceOptions';
import styles from './VisualizationSection.module.scss';
type VisualizationSectionProps =
SectionEditorProps<SectionKind.Visualization> & {
/** Current panel kind + switch handler, forwarded by SectionSlot for the type switcher. */
panelKind?: PanelKind;
onChangePanelKind?: (kind: PanelKind) => void;
/** Panel's datasource, forwarded by SectionSlot — scopes the switcher's disabled types. */
signal?: TelemetrytypesSignalDTO;
};
type VisualizationSectionProps = SectionEditorProps<SectionKind.Visualization> &
Pick<
SectionEditorContext,
'panelKind' | 'onChangePanelKind' | 'signal' | 'queryType'
>;
/**
* Edits the `visualization` slice: the panel-type switcher (`switchPanelKind`, every
@@ -34,6 +30,7 @@ function VisualizationSection({
onChange,
panelKind,
onChangePanelKind,
queryType,
signal,
}: VisualizationSectionProps): JSX.Element {
return (
@@ -41,6 +38,7 @@ function VisualizationSection({
{controls.switchPanelKind && panelKind && onChangePanelKind && (
<PanelTypeSwitcher
panelKind={panelKind}
queryType={queryType}
signal={signal}
onChange={onChangePanelKind}
/>

View File

@@ -4,11 +4,12 @@ import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.s
import VisualizationSection from '../VisualizationSection';
// The type switcher resolves each kind's supported signals; stub it so the test
// doesn't pull the whole panel registry (renderers, chart libs).
// The type switcher resolves each kind's supported signals + query types; stub it so
// the test doesn't pull the whole panel registry (renderers, chart libs).
jest.mock('pages/DashboardPageV2/DashboardContainer/Panels/registry', () => ({
getPanelDefinition: jest.fn(() => ({
supportedSignals: ['metrics', 'logs', 'traces'],
supportedQueryTypes: ['builder', 'clickhouse_sql', 'promql'],
})),
}));

View File

@@ -8,23 +8,35 @@ import { Color } from '@signozhq/design-tokens';
import { Atom, Terminal } from '@signozhq/icons';
import { Tabs } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import PromQLIcon from 'assets/Dashboard/PromQl';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import TextToolTip from 'components/TextToolTip';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ClickHouseQueryContainer from 'container/NewWidget/LeftContainer/QuerySection/QueryBuilder/ClickHouse';
import PromQLQueryContainer from 'container/NewWidget/LeftContainer/QuerySection/QueryBuilder/promQL';
import { PANEL_TYPE_TO_QUERY_TYPES } from 'container/NewWidget/utils';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { EQueryType } from 'types/common/dashboard';
import {
getHiddenQueryBuilderFields,
getSupportedQueryTypes,
} from '../../Panels/capabilities';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from '../../Panels/types/panelKind';
import styles from './PanelEditorQueryBuilder.module.scss';
interface PanelEditorQueryBuilderProps {
panelType: PANEL_TYPES;
/** The edited panel's visualization kind — drives supported query types + field visibility via the capabilities guard. */
panelKind: PanelKind;
/** The panel's current signal; selects per-signal query-builder field rules. */
signal: TelemetrytypesSignalDTO;
/** Preview fetch in flight — drives the Stage & Run button's loading/cancel state. */
isLoadingQueries: boolean;
/** Run the current query (Stage & Run button / ⌘↵). Always re-runs. */
@@ -41,12 +53,15 @@ interface PanelEditorQueryBuilderProps {
* `QueryBuilderProvider`. `usePanelEditorQuerySync` owns the panel↔provider sync.
*/
function PanelEditorQueryBuilder({
panelType,
panelKind,
signal,
isLoadingQueries,
onStageRunQuery,
onCancelQuery,
footer,
}: PanelEditorQueryBuilderProps): JSX.Element {
// The shared QueryBuilderV2 / list-view checks still speak the legacy PANEL_TYPES.
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const isDarkMode = useIsDarkMode();
@@ -74,13 +89,15 @@ function PanelEditorQueryBuilder({
[onStageRunQuery],
);
// Per-kind query-builder field rules from the guard (e.g. List hides step interval
// and having), passed to QueryBuilderV2 as its `filterConfigs`.
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(
() => ({ stepInterval: { isHidden: false, isDisabled: false } }),
[],
() => getHiddenQueryBuilderFields(panelKind, signal),
[panelKind, signal],
);
const items = useMemo(() => {
const supportedQueryTypes = PANEL_TYPE_TO_QUERY_TYPES[panelType] || [];
const supportedQueryTypes = getSupportedQueryTypes(panelKind);
const queryTypeComponents = {
[EQueryType.QUERY_BUILDER]: {
@@ -127,7 +144,7 @@ function PanelEditorQueryBuilder({
),
children: queryTypeComponents[queryType].component,
}));
}, [panelType, filterConfigs, isDarkMode]);
}, [panelKind, panelType, filterConfigs, isDarkMode]);
return (
<div

View File

@@ -0,0 +1,145 @@
import { render, screen } from '@testing-library/react';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { OPERATORS } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { EQueryType } from 'types/common/dashboard';
import PanelEditorQueryBuilder from '../PanelEditorQueryBuilder';
// Capture the props the (real-guard-fed) QueryBuilderV2 receives without rendering it.
const mockQueryBuilderV2 = jest.fn();
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
jest.mock('hooks/useDarkMode', () => ({ useIsDarkMode: (): boolean => false }));
jest.mock('components/QueryBuilderV2/QueryBuilderV2', () => ({
QueryBuilderV2: (props: unknown): null => {
mockQueryBuilderV2(props);
return null;
},
}));
jest.mock(
'container/NewWidget/LeftContainer/QuerySection/QueryBuilder/ClickHouse',
() => ({ __esModule: true, default: (): null => null }),
);
jest.mock(
'container/NewWidget/LeftContainer/QuerySection/QueryBuilder/promQL',
() => ({ __esModule: true, default: (): null => null }),
);
jest.mock('container/QueryBuilder/components/RunQueryBtn/RunQueryBtn', () => ({
__esModule: true,
default: (): null => null,
}));
jest.mock('components/TextToolTip', () => ({
__esModule: true,
default: (): null => null,
}));
jest.mock('assets/Dashboard/PromQl', () => ({
__esModule: true,
default: (): null => null,
}));
const mockUseQueryBuilder = useQueryBuilder as unknown as jest.Mock;
function renderBuilder(
panelKind: string,
signal: TelemetrytypesSignalDTO = TelemetrytypesSignalDTO.logs,
): void {
render(
<PanelEditorQueryBuilder
panelKind={panelKind as never}
signal={signal}
isLoadingQueries={false}
onStageRunQuery={jest.fn()}
onCancelQuery={jest.fn()}
/>,
);
}
function lastQueryBuilderProps(): {
panelType: string;
isListViewPanel: boolean;
filterConfigs: unknown;
} {
const calls = mockQueryBuilderV2.mock.calls;
return calls[calls.length - 1][0];
}
describe('PanelEditorQueryBuilder query-type tabs (driven by the capabilities guard)', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseQueryBuilder.mockReturnValue({
currentQuery: { queryType: EQueryType.QUERY_BUILDER },
redirectWithQueryBuilderData: jest.fn(),
});
});
it('shows only the Query Builder tab for the List kind', () => {
renderBuilder('signoz/ListPanel', TelemetrytypesSignalDTO.logs);
expect(screen.getByText('Query Builder')).toBeInTheDocument();
expect(screen.queryByText('ClickHouse Query')).not.toBeInTheDocument();
expect(screen.queryByText('PromQL')).not.toBeInTheDocument();
});
it('shows Query Builder + ClickHouse but not PromQL for the Table kind', () => {
renderBuilder('signoz/TablePanel');
expect(screen.getByText('Query Builder')).toBeInTheDocument();
expect(screen.getByText('ClickHouse Query')).toBeInTheDocument();
expect(screen.queryByText('PromQL')).not.toBeInTheDocument();
});
it('shows all three tabs for the Time Series kind', () => {
renderBuilder('signoz/TimeSeriesPanel');
expect(screen.getByText('Query Builder')).toBeInTheDocument();
expect(screen.getByText('ClickHouse Query')).toBeInTheDocument();
expect(screen.getByText('PromQL')).toBeInTheDocument();
});
});
describe('PanelEditorQueryBuilder field visibility (driven by the capabilities guard)', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseQueryBuilder.mockReturnValue({
currentQuery: { queryType: EQueryType.QUERY_BUILDER },
redirectWithQueryBuilderData: jest.fn(),
});
});
it('passes empty field config + non-list flag for a non-list kind', () => {
renderBuilder('signoz/TimeSeriesPanel', TelemetrytypesSignalDTO.metrics);
const props = lastQueryBuilderProps();
expect(props.panelType).toBe('graph');
expect(props.isListViewPanel).toBe(false);
expect(props.filterConfigs).toStrictEqual({});
});
it('hides step interval / having and sets body-contains for List + logs', () => {
renderBuilder('signoz/ListPanel', TelemetrytypesSignalDTO.logs);
const props = lastQueryBuilderProps();
expect(props.panelType).toBe('list');
expect(props.isListViewPanel).toBe(true);
expect(props.filterConfigs).toStrictEqual({
stepInterval: { isHidden: true, isDisabled: true },
having: { isHidden: true, isDisabled: true },
filters: { customKey: 'body', customOp: OPERATORS.CONTAINS },
});
});
it('additionally hides limit for List + traces', () => {
renderBuilder('signoz/ListPanel', TelemetrytypesSignalDTO.traces);
const props = lastQueryBuilderProps();
expect(props.filterConfigs).toStrictEqual({
stepInterval: { isHidden: true, isDisabled: true },
having: { isHidden: true, isDisabled: true },
limit: { isHidden: true, isDisabled: true },
filters: { customKey: 'body', customOp: OPERATORS.CONTAINS },
});
});
});

View File

@@ -5,6 +5,7 @@ import { handleQueryChange } from 'container/NewWidget/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { resolveQueryType } from '../../../Panels/capabilities';
import { getBuilderQueries } from '../../../Panels/utils/getBuilderQueries';
import { toPerses } from '../../../queryV5/persesQueryAdapters';
import { getSwitchedPluginSpec } from '../../getSwitchedPluginSpec';
@@ -15,15 +16,9 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
}));
jest.mock('container/NewWidget/utils', () => ({
handleQueryChange: jest.fn(),
PANEL_TYPE_TO_QUERY_TYPES: {
graph: ['builder', 'clickhouse', 'promql'],
table: ['builder', 'clickhouse'],
list: ['builder'],
value: ['builder', 'clickhouse', 'promql'],
bar: ['builder', 'clickhouse', 'promql'],
pie: ['builder', 'clickhouse'],
histogram: ['builder', 'clickhouse', 'promql'],
},
}));
jest.mock('../../../Panels/capabilities', () => ({
resolveQueryType: jest.fn(),
}));
jest.mock('../../../queryV5/persesQueryAdapters', () => ({
toPerses: jest.fn(),
@@ -37,6 +32,7 @@ jest.mock('../../../Panels/utils/getBuilderQueries', () => ({
const mockUseQueryBuilder = useQueryBuilder as unknown as jest.Mock;
const mockHandleQueryChange = handleQueryChange as unknown as jest.Mock;
const mockResolveQueryType = resolveQueryType as unknown as jest.Mock;
const mockToPerses = toPerses as unknown as jest.Mock;
const mockGetSwitchedPluginSpec = getSwitchedPluginSpec as unknown as jest.Mock;
const mockGetBuilderQueries = getBuilderQueries as unknown as jest.Mock;
@@ -92,6 +88,9 @@ describe('usePanelTypeSwitch', () => {
mockToPerses.mockReturnValue(CONVERTED);
mockGetSwitchedPluginSpec.mockReturnValue(SWITCHED_SPEC);
mockGetBuilderQueries.mockReturnValue([{ signal: 'logs' }]);
// The guard owns coercion (tested in capabilities.test.ts); here it always
// resolves to Query Builder so the coerced type flows into handleQueryChange.
mockResolveQueryType.mockReturnValue('builder');
});
it('does nothing when switching to the current kind', () => {
@@ -149,7 +148,12 @@ describe('usePanelTypeSwitch', () => {
);
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
// List allows only Query Builder, so the promql query is coerced to 'builder'.
// The hook asks the guard to resolve the active query type against the new kind…
expect(mockResolveQueryType).toHaveBeenCalledWith(
'signoz/ListPanel',
'promql',
);
// …and the resolved type ('builder') flows into the query rebuild.
const [, queryArg] = mockHandleQueryChange.mock.calls[0];
expect((queryArg as Query).queryType).toBe('builder');
});

View File

@@ -8,12 +8,12 @@ import type {
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
handleQueryChange,
PANEL_TYPE_TO_QUERY_TYPES,
type PartialPanelTypes,
} from 'container/NewWidget/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { resolveQueryType } from '../../Panels/capabilities';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
@@ -108,12 +108,9 @@ export function usePanelTypeSwitch({
return;
}
// First visit → coerce the query type if the new panel disallows it, then
// First visit → coerce the query type if the new kind disallows it, then
// rebuild the builder query for the new type.
const supported = PANEL_TYPE_TO_QUERY_TYPES[newPanelType] ?? [];
const queryType = supported.includes(query.queryType)
? query.queryType
: supported[0];
const queryType = resolveQueryType(newKind, query.queryType);
const transformed = handleQueryChange(
newPanelType as keyof PartialPanelTypes,
{ ...query, queryType },

View File

@@ -1,4 +1,4 @@
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import {
ResizableHandle,
ResizablePanel,
@@ -11,6 +11,7 @@ import {
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import {
PANEL_KIND_TO_PANEL_TYPE,
@@ -18,6 +19,7 @@ import {
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
import { getExecStats } from '../queryV5/v5ResponseData';
import { usePanelInteractions } from '../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions';
import ConfigPane from './ConfigPane/ConfigPane';
import Header from './Header/Header';
@@ -66,6 +68,10 @@ function PanelEditorContainer({
onSaved,
}: PanelEditorContainerProps): JSX.Element {
const { draft, spec, setSpec, isSpecDirty } = usePanelEditorDraft(panel);
// Live query type (the selected tab) — the type switcher disables kinds that can't be
// authored in it. Read from the provider, not the spec: a new panel's spec carries no
// query until staged, so the spec would lag the tab.
const { currentQuery } = useQueryBuilder();
const { save, isSaving } = usePanelEditorSave({
dashboardId,
panelId,
@@ -150,6 +156,14 @@ function PanelEditorContainer({
const legendSeries = useLegendSeries(draft, data);
const tableColumns = useTableColumns(draft, data);
// Smallest query step interval (seconds) — the floor for the span-gaps
// threshold. Undefined until results carry step metadata.
const stepInterval = useMemo((): number | undefined => {
const intervals = getExecStats(data.response)?.stepIntervals;
const values = intervals ? Object.values(intervals) : [];
return values.length ? Math.min(...values) : undefined;
}, [data.response]);
const onSave = useCallback(async (): Promise<void> => {
try {
// Bake the live query into the spec so unstaged edits are saved too.
@@ -201,7 +215,8 @@ function PanelEditorContainer({
<ResizableHandle withHandle className={styles.handle} />
<ResizablePanel minSize="35%" maxSize="45%" defaultSize="40%">
<PanelEditorQueryBuilder
panelType={panelType}
panelKind={fullKind}
signal={listSignal}
isLoadingQueries={isFetching}
onStageRunQuery={runQuery}
onCancelQuery={cancelQuery}
@@ -231,8 +246,10 @@ function PanelEditorContainer({
spec={spec}
onChangeSpec={setSpec}
onChangePanelKind={onChangePanelKind}
queryType={currentQuery.queryType}
legendSeries={legendSeries}
tableColumns={tableColumns}
stepInterval={stepInterval}
/>
</ResizablePanel>
</ResizablePanelGroup>

View File

@@ -0,0 +1,167 @@
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { OPERATORS } from 'constants/queryBuilder';
import { EQueryType } from 'types/common/dashboard';
import {
getHiddenQueryBuilderFields,
getSupportedQueryTypes,
getSupportedSignals,
isPanelCombinationValid,
isQueryTypeSupported,
isSignalSupported,
resolveQueryType,
} from '../capabilities';
import type { PanelKind } from '../types/panelKind';
const { QUERY_BUILDER, CLICKHOUSE, PROM } = EQueryType;
const { logs, traces, metrics } = TelemetrytypesSignalDTO;
const EXPECTED_QUERY_TYPES: Record<PanelKind, EQueryType[]> = {
'signoz/TimeSeriesPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
'signoz/BarChartPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
'signoz/NumberPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
'signoz/HistogramPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
'signoz/PieChartPanel': [QUERY_BUILDER, CLICKHOUSE],
'signoz/TablePanel': [QUERY_BUILDER, CLICKHOUSE],
'signoz/ListPanel': [QUERY_BUILDER],
};
const EXPECTED_SIGNALS: Record<PanelKind, TelemetrytypesSignalDTO[]> = {
'signoz/TimeSeriesPanel': [metrics, logs, traces],
'signoz/BarChartPanel': [metrics, logs, traces],
'signoz/NumberPanel': [metrics, logs, traces],
'signoz/HistogramPanel': [metrics, logs, traces],
'signoz/PieChartPanel': [metrics, logs, traces],
'signoz/TablePanel': [metrics, logs, traces],
// List renders raw rows; metrics produce no row data.
'signoz/ListPanel': [logs, traces],
};
const ALL_KINDS = Object.keys(EXPECTED_QUERY_TYPES) as PanelKind[];
describe('panel capabilities guard', () => {
describe('query type support', () => {
it.each(ALL_KINDS)('declares the expected query types for %s', (kind) => {
expect(getSupportedQueryTypes(kind)).toStrictEqual(
EXPECTED_QUERY_TYPES[kind],
);
});
it('Table and Pie do not support PromQL', () => {
expect(isQueryTypeSupported('signoz/TablePanel', PROM)).toBe(false);
expect(isQueryTypeSupported('signoz/PieChartPanel', PROM)).toBe(false);
});
it('List only supports Query Builder', () => {
expect(isQueryTypeSupported('signoz/ListPanel', QUERY_BUILDER)).toBe(true);
expect(isQueryTypeSupported('signoz/ListPanel', CLICKHOUSE)).toBe(false);
expect(isQueryTypeSupported('signoz/ListPanel', PROM)).toBe(false);
});
});
describe('signal support', () => {
it.each(ALL_KINDS)('declares the expected signals for %s', (kind) => {
expect(getSupportedSignals(kind)).toStrictEqual(EXPECTED_SIGNALS[kind]);
});
it('List excludes metrics', () => {
expect(isSignalSupported('signoz/ListPanel', metrics)).toBe(false);
expect(isSignalSupported('signoz/ListPanel', logs)).toBe(true);
expect(isSignalSupported('signoz/ListPanel', traces)).toBe(true);
});
});
describe('isPanelCombinationValid', () => {
it('accepts a supported triad', () => {
expect(
isPanelCombinationValid({
kind: 'signoz/TimeSeriesPanel',
queryType: PROM,
}),
).toBe(true);
expect(
isPanelCombinationValid({
kind: 'signoz/ListPanel',
queryType: QUERY_BUILDER,
signal: logs,
}),
).toBe(true);
});
it('rejects an unsupported query type', () => {
expect(
isPanelCombinationValid({ kind: 'signoz/ListPanel', queryType: PROM }),
).toBe(false);
expect(
isPanelCombinationValid({ kind: 'signoz/TablePanel', queryType: PROM }),
).toBe(false);
});
it('rejects an unsupported signal when one is given', () => {
expect(
isPanelCombinationValid({
kind: 'signoz/ListPanel',
queryType: QUERY_BUILDER,
signal: metrics,
}),
).toBe(false);
});
it('ignores signal when none is given (ClickHouse/PromQL have no signal)', () => {
expect(
isPanelCombinationValid({
kind: 'signoz/ListPanel',
queryType: QUERY_BUILDER,
}),
).toBe(true);
});
});
describe('resolveQueryType', () => {
it('keeps a supported query type', () => {
expect(resolveQueryType('signoz/TimeSeriesPanel', PROM)).toBe(PROM);
expect(resolveQueryType('signoz/ListPanel', QUERY_BUILDER)).toBe(
QUERY_BUILDER,
);
});
it('coerces an unsupported query type to the first supported one', () => {
// PromQL → List has no PromQL, falls back to its first (and only) type.
expect(resolveQueryType('signoz/ListPanel', PROM)).toBe(QUERY_BUILDER);
expect(resolveQueryType('signoz/TablePanel', PROM)).toBe(QUERY_BUILDER);
});
});
describe('getHiddenQueryBuilderFields', () => {
it('returns {} for kinds that declare no field rules', () => {
expect(
getHiddenQueryBuilderFields('signoz/TimeSeriesPanel', logs),
).toStrictEqual({});
expect(getHiddenQueryBuilderFields('signoz/TablePanel', logs)).toStrictEqual(
{},
);
});
// Mirrors QueryBuilderV2's internal listViewLogFilterConfigs — the guard is the
// single source of truth for these values.
it('hides step interval / having and sets body-contains for List + logs', () => {
expect(getHiddenQueryBuilderFields('signoz/ListPanel', logs)).toStrictEqual({
stepInterval: { isHidden: true, isDisabled: true },
having: { isHidden: true, isDisabled: true },
filters: { customKey: 'body', customOp: OPERATORS.CONTAINS },
});
});
// Mirrors listViewTracesFilterConfigs — traces additionally hide `limit`.
it('additionally hides limit for List + traces', () => {
expect(
getHiddenQueryBuilderFields('signoz/ListPanel', traces),
).toStrictEqual({
stepInterval: { isHidden: true, isDisabled: true },
having: { isHidden: true, isDisabled: true },
limit: { isHidden: true, isDisabled: true },
filters: { customKey: 'body', customOp: OPERATORS.CONTAINS },
});
});
});
});

View File

@@ -0,0 +1,91 @@
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import { getPanelDefinition } from './registry';
import type { FilterConfigsPartial } from './types/panelCapabilities';
import type { PanelKind } from './types/panelKind';
/**
* The single deterministic guard for V2 dashboards. Every "what works with what"
* question — panel kind × query type × signal, and which query-builder fields a kind
* hides — is answered here by reading each kind's declared capabilities from the panel
* registry. Adding a new kind means declaring its capabilities once in its definition;
* these functions then cover it automatically. Pure and side-effect free.
*/
/** Signals a kind can visualize. */
export function getSupportedSignals(
kind: PanelKind,
): TelemetrytypesSignalDTO[] {
return getPanelDefinition(kind).supportedSignals;
}
export function isSignalSupported(
kind: PanelKind,
signal: TelemetrytypesSignalDTO,
): boolean {
return getSupportedSignals(kind).includes(signal);
}
/** Query languages a kind supports (Query Builder / ClickHouse / PromQL). */
export function getSupportedQueryTypes(kind: PanelKind): EQueryType[] {
return getPanelDefinition(kind).supportedQueryTypes;
}
export function isQueryTypeSupported(
kind: PanelKind,
queryType: EQueryType,
): boolean {
return getSupportedQueryTypes(kind).includes(queryType);
}
/**
* Master guard: is this panel kind renderable with this query type (and, in builder
* mode, this signal)? ClickHouse/PromQL queries carry no signal, so the signal is
* validated only when one is given.
*/
export function isPanelCombinationValid({
kind,
queryType,
signal,
}: {
kind: PanelKind;
queryType: EQueryType;
signal?: TelemetrytypesSignalDTO;
}): boolean {
if (!isQueryTypeSupported(kind, queryType)) {
return false;
}
if (signal !== undefined && !isSignalSupported(kind, signal)) {
return false;
}
return true;
}
/**
* The query type to use for a kind given a `preferred` one: keep it if the kind
* supports it, otherwise fall back to the kind's first supported type. Used when
* switching panel kinds to coerce an unsupported active query type (e.g. PromQL → a
* List panel coerces to Query Builder).
*/
export function resolveQueryType(
kind: PanelKind,
preferred: EQueryType,
): EQueryType {
const supported = getSupportedQueryTypes(kind);
return supported.includes(preferred) ? preferred : supported[0];
}
/**
* Query-builder field visibility for a kind + signal: the kind's `default` rule with
* its per-signal overrides merged over it (signal wins). `{}` when the kind hides
* nothing, i.e. the builder shows every field.
*/
export function getHiddenQueryBuilderFields(
kind: PanelKind,
signal: TelemetrytypesSignalDTO,
): FilterConfigsPartial {
const rule = getPanelDefinition(kind).queryBuilderFields;
const perSignal = signal ? rule[signal] : undefined;
return { ...rule.default, ...perSignal };
}

View File

@@ -2,6 +2,7 @@ import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
kind: 'signoz/BarChartPanel',
@@ -13,6 +14,12 @@ export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [
EQueryType.QUERY_BUILDER,
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
queryBuilderFields: {},
actions: {
view: true,
edit: true,

View File

@@ -2,6 +2,7 @@ import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
kind: 'signoz/HistogramPanel',
@@ -13,6 +14,12 @@ export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [
EQueryType.QUERY_BUILDER,
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
queryBuilderFields: {},
actions: {
view: true,
edit: true,

View File

@@ -2,6 +2,8 @@ import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { OPERATORS } from 'constants/queryBuilder';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/ListPanel'> = {
kind: 'signoz/ListPanel',
@@ -12,6 +14,21 @@ export const definition: PanelDefinition<'signoz/ListPanel'> = {
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
// Raw rows have no aggregation, so step interval / having never apply, and the
// Where clause searches the log/span body via `body CONTAINS`. Traces additionally
// hide `limit` (the server paginates raw spans). Mirrors QueryBuilderV2's internal
// list configs — the capabilities guard is the single source for both.
supportedQueryTypes: [EQueryType.QUERY_BUILDER],
queryBuilderFields: {
default: {
stepInterval: { isHidden: true, isDisabled: true },
having: { isHidden: true, isDisabled: true },
filters: { customKey: 'body', customOp: OPERATORS.CONTAINS },
},
[TelemetrytypesSignalDTO.traces]: {
limit: { isHidden: true, isDisabled: true },
},
},
sections,
actions: {
view: true,

View File

@@ -2,6 +2,7 @@ import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/NumberPanel'> = {
kind: 'signoz/NumberPanel',
@@ -13,6 +14,12 @@ export const definition: PanelDefinition<'signoz/NumberPanel'> = {
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [
EQueryType.QUERY_BUILDER,
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
queryBuilderFields: {},
actions: {
view: true,
edit: true,

View File

@@ -2,6 +2,7 @@ import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
kind: 'signoz/PieChartPanel',
@@ -13,6 +14,8 @@ export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
queryBuilderFields: {},
actions: {
view: true,
edit: true,

View File

@@ -2,6 +2,7 @@ import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/TablePanel'> = {
kind: 'signoz/TablePanel',
@@ -13,6 +14,8 @@ export const definition: PanelDefinition<'signoz/TablePanel'> = {
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
queryBuilderFields: {},
// Tables carry tabular data worth exporting (V1 parity: download is table-only).
actions: {
view: true,

View File

@@ -2,6 +2,7 @@ import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
kind: 'signoz/TimeSeriesPanel',
@@ -13,6 +14,12 @@ export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [
EQueryType.QUERY_BUILDER,
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
queryBuilderFields: {},
actions: {
view: true,
edit: true,

View File

@@ -0,0 +1,20 @@
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
/**
* Query-builder field-visibility config a panel kind can declare, mirroring the
* shape `QueryBuilderV2` consumes via its `filterConfigs` prop. Derived from that
* prop type (the underlying `FilterConfigs` isn't exported) so the two never drift.
*/
export type FilterConfigsPartial = NonNullable<
QueryBuilderProps['filterConfigs']
>;
/**
* Per-signal query-builder field rules for a panel kind. `default` applies to every
* signal; a per-signal entry is merged over it (signal wins). The capabilities guard
* resolves this into a single `FilterConfigsPartial` via `getHiddenQueryBuilderFields`.
*/
export type QueryBuilderFieldRule = {
default?: FilterConfigsPartial;
} & Partial<Record<TelemetrytypesSignalDTO, FilterConfigsPartial>>;

View File

@@ -1,9 +1,11 @@
import type { ComponentType } from 'react';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { EQueryType } from 'types/common/dashboard';
import type { SectionConfig } from './sections';
import type { AnyPanelInteractionProps } from './interactions';
import type { PanelKind } from './panelKind';
import type { QueryBuilderFieldRule } from './panelCapabilities';
import type { BaseRendererProps, PanelRendererProps } from './rendererProps';
/**
@@ -35,7 +37,12 @@ export interface PanelDefinition<K extends PanelKind = PanelKind> {
displayName: string;
Renderer: ComponentType<PanelRendererProps<K>>;
sections: SectionConfig[];
/** Signals this kind can visualize. */
supportedSignals: TelemetrytypesSignalDTO[];
/** Query languages this kind supports (Query Builder / ClickHouse / PromQL). */
supportedQueryTypes: EQueryType[];
/** Query-builder fields this kind hides/disables, optionally per signal (`{}` hides none). */
queryBuilderFields: QueryBuilderFieldRule;
actions: PanelActionCapabilities;
}

View File

@@ -153,7 +153,7 @@ export type SectionConfig =
// Per-section title + sidebar icon. Pure data; the editor component + spec lens
// live in the ConfigPane section registry.
export const SECTION_METADATA = {
[SectionKind.Formatting]: { title: 'Formatting', icon: Hash },
[SectionKind.Formatting]: { title: 'Formatting & Units', icon: Hash },
[SectionKind.Axes]: { title: 'Axes', icon: Ruler },
[SectionKind.Legend]: { title: 'Legend', icon: Layers },
[SectionKind.ChartAppearance]: { title: 'Chart appearance', icon: Palette },

View File

@@ -0,0 +1,30 @@
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { resolveSignal } from '../getBuilderQueries';
function builderQuery(signal: string): DashboardtypesQueryDTO {
return {
spec: { plugin: { kind: 'signoz/BuilderQuery', spec: { signal } } },
} as unknown as DashboardtypesQueryDTO;
}
const promqlQuery = {
spec: { plugin: { kind: 'signoz/PromQuery', spec: { query: 'up' } } },
} as unknown as DashboardtypesQueryDTO;
describe('resolveSignal', () => {
const DEFAULT = TelemetrytypesSignalDTO.metrics;
it("uses the first builder query's signal when present", () => {
expect(resolveSignal([builderQuery('logs')], DEFAULT)).toBe('logs');
});
it('prefers the builder signal over the default', () => {
expect(resolveSignal([builderQuery('traces')], DEFAULT)).toBe('traces');
});
it('stays undefined when queries exist but none are builder queries (PromQL/ClickHouse)', () => {
expect(resolveSignal([promqlQuery], DEFAULT)).toBeUndefined();
});
});

View File

@@ -0,0 +1,22 @@
import { resolveSpanGaps } from '../resolvers';
describe('resolveSpanGaps', () => {
it('spans all gaps (true) when unset', () => {
expect(resolveSpanGaps(undefined)).toBe(true);
expect(resolveSpanGaps('')).toBe(true);
});
it('parses a duration string into seconds', () => {
expect(resolveSpanGaps('5s')).toBe(5);
expect(resolveSpanGaps('10m')).toBe(600);
expect(resolveSpanGaps('1h')).toBe(3600);
});
it('tolerates a bare seconds number (back-compat)', () => {
expect(resolveSpanGaps('600')).toBe(600);
});
it('falls back to true for unparseable input', () => {
expect(resolveSpanGaps('abc')).toBe(true);
});
});

View File

@@ -1,3 +1,4 @@
import { rangeUtil } from '@grafana/data';
import {
DashboardtypesLegendPositionDTO,
DashboardtypesPrecisionOptionDTO,
@@ -38,9 +39,10 @@ export function resolveDecimalPrecision(
}
/**
* `spec.chartAppearance.spanGaps.fillLessThan` is a stringified number on the
* wire. Empty/missing → span all gaps (default); numeric → forward the threshold
* so uPlot only bridges short runs of nulls.
* `spec.chartAppearance.spanGaps.fillLessThan` is a duration string on the wire
* ("10m", "5s"). Empty/missing → span all gaps (default); otherwise forward the
* threshold in seconds so uPlot only bridges short runs of nulls. Tolerates a
* bare seconds number for back-compat.
*/
export function resolveSpanGaps(
fillLessThan: string | undefined,
@@ -48,8 +50,10 @@ export function resolveSpanGaps(
if (!fillLessThan) {
return true;
}
const parsed = Number(fillLessThan);
return Number.isFinite(parsed) ? parsed : true;
const seconds = rangeUtil.isValidTimeSpan(fillLessThan)
? rangeUtil.intervalToSeconds(fillLessThan)
: Number(fillLessThan);
return Number.isFinite(seconds) && seconds > 0 ? seconds : true;
}
/** Legend position; missing/unknown falls back to `BOTTOM` (chart default, V1 parity). */

View File

@@ -1,4 +1,7 @@
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import type {
DashboardtypesQueryDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { BuilderQuery } from 'types/api/v5/queryRange';
/**
@@ -27,3 +30,21 @@ export function getBuilderQueries(
});
return flattened;
}
/**
* Datasource signal scoping panel-type compatibility (List needs logs/traces, not
* metrics): the builder query's signal if present; else `defaultSignal` for a new
* panel (queries empty until edited); else undefined for PromQL/ClickHouse.
*/
export function resolveSignal(
queries: DashboardtypesQueryDTO[],
defaultSignal: TelemetrytypesSignalDTO,
): TelemetrytypesSignalDTO | undefined {
const builderSignal = getBuilderQueries(queries)[0]?.signal as
| TelemetrytypesSignalDTO
| undefined;
if (builderSignal) {
return builderSignal;
}
return queries.length ? undefined : defaultSignal;
}

View File

@@ -52,6 +52,8 @@ function section(
}
const TWO_TITLED_SECTIONS = [section(0, 'Overview'), section(1, 'Latency')];
// Index 0 is the untitled root (free-flow) section; index 1 is a titled section.
const TITLED_WITH_ROOT = [section(0, undefined), section(1, 'Latency')];
const baseArgs = {
panelId: 'panel-1',
@@ -177,6 +179,49 @@ describe('usePanelActionItems', () => {
});
});
it('offers "Move out of section" for a panel in a titled section when an untitled root exists', () => {
const { result } = renderHook(() =>
usePanelActionItems({
...baseArgs,
panelActions: { currentLayoutIndex: 1, sections: TITLED_WITH_ROOT },
}),
);
expect(itemKeys(result.current)).toContain('move-to-root');
});
it('"Move out of section" moves the panel to the untitled root section', () => {
const { result } = renderHook(() =>
usePanelActionItems({
...baseArgs,
panelActions: { currentLayoutIndex: 1, sections: TITLED_WITH_ROOT },
}),
);
const moveOut = result.current.items.find(
(i) => 'key' in i && i.key === 'move-to-root',
);
(moveOut as { onClick: () => void }).onClick();
expect(mockMovePanel).toHaveBeenCalledWith({
panelId: 'panel-1',
fromLayoutIndex: 1,
toLayoutIndex: 0,
});
});
it('hides "Move out of section" when the panel already sits in the root section', () => {
const { result } = renderHook(() =>
usePanelActionItems({
...baseArgs,
panelActions: { currentLayoutIndex: 0, sections: TITLED_WITH_ROOT },
}),
);
expect(itemKeys(result.current)).not.toContain('move-to-root');
});
it('hides "Move out of section" when every section is titled (no root)', () => {
const { result } = renderHook(() => usePanelActionItems(baseArgs));
expect(itemKeys(result.current)).not.toContain('move-to-root');
});
it('delete defers to a confirmation: the item opens the dialog, confirm runs the mutation', async () => {
const { result } = renderHook(() => usePanelActionItems(baseArgs));
const del = result.current.items.find(

View File

@@ -4,6 +4,7 @@ import {
CloudDownload,
Copy,
FolderInput,
FolderOutput,
Fullscreen,
PenLine,
Trash2,
@@ -23,7 +24,10 @@ import type { DashboardSection } from '../../../utils';
import type { PanelActionsConfig } from '../Panel';
import { useClonePanel } from '../hooks/useClonePanel';
import { useDeletePanel } from '../hooks/useDeletePanel';
import { useMovePanelToSection } from '../hooks/useMovePanelToSection';
import {
type MovePanelArgs,
useMovePanelToSection,
} from '../hooks/useMovePanelToSection';
import { PANEL_ACTION_META } from './panelActionMeta';
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
@@ -37,6 +41,66 @@ function notImplementedYet(feature: string): void {
alert(`${feature} option clicked`);
}
interface MoveItemsArgs {
sections: DashboardSection[];
currentLayoutIndex: number;
panelId: string;
movePanel: (args: MovePanelArgs) => Promise<void>;
}
/**
* The "Move to section" submenu (other titled sections) plus a direct "Move out
* of section" to the untitled root, shown only when the panel sits in a titled
* section and a root section exists to receive it.
*/
function buildMoveItems({
sections,
currentLayoutIndex,
panelId,
movePanel,
}: MoveItemsArgs): MenuItem[] {
const targets = sections.filter(
(s) => s.title && s.layoutIndex !== currentLayoutIndex,
);
const items: MenuItem[] = [
{
key: 'move',
label: 'Move to section',
icon: <FolderInput size={14} />,
...(targets.length === 0
? { disabled: true }
: {
children: targets.map((s) => ({
key: `move-${s.layoutIndex}`,
label: s.title,
onClick: (): void =>
void movePanel({
panelId,
fromLayoutIndex: currentLayoutIndex,
toLayoutIndex: s.layoutIndex,
}),
})),
}),
},
];
const rootSection = sections.find((s) => !s.title);
if (rootSection && rootSection.layoutIndex !== currentLayoutIndex) {
items.push({
key: 'move-to-root',
label: 'Move out of section',
icon: <FolderOutput size={14} />,
onClick: (): void =>
void movePanel({
panelId,
fromLayoutIndex: currentLayoutIndex,
toLayoutIndex: rootSection.layoutIndex,
}),
});
}
return items;
}
interface UsePanelActionItemsArgs {
panelId: string;
/** Full plugin kind (e.g. `signoz/TimeSeriesPanel`); */
@@ -155,31 +219,15 @@ export function usePanelActionItems({
});
}
const moveGroup: MenuItem[] = [];
if (canMove && panelActions) {
const targets = sections.filter(
(s) => s.title && s.layoutIndex !== panelActions.currentLayoutIndex,
);
moveGroup.push({
key: 'move',
label: 'Move to section',
icon: <FolderInput size={14} />,
...(targets.length === 0
? { disabled: true }
: {
children: targets.map((s) => ({
key: `move-${s.layoutIndex}`,
label: s.title,
onClick: (): void =>
void movePanel({
panelId,
fromLayoutIndex: panelActions.currentLayoutIndex,
toLayoutIndex: s.layoutIndex,
}),
})),
}),
});
}
const moveGroup: MenuItem[] =
canMove && panelActions
? buildMoveItems({
sections,
currentLayoutIndex: panelActions.currentLayoutIndex,
panelId,
movePanel,
})
: [];
const deleteGroup: MenuItem[] =
canDelete && panelActions

View File

@@ -107,7 +107,7 @@ describe('createPanelOps', () => {
expect(value.y).toBe(6);
});
it('falls back to the last section when no index is requested', () => {
it('falls back to the root (first) section when no index is requested', () => {
const layouts = [section([]), section([item(0, 6)])];
const ops = createPanelOps({
layouts,
@@ -116,11 +116,11 @@ describe('createPanelOps', () => {
panel,
});
expect(ops[1].path).toBe('/spec/layouts/1/spec/items/-');
expect(ops[1].path).toBe('/spec/layouts/0/spec/items/-');
});
it('falls back to the last section when the requested index is out of range', () => {
const layouts = [section([])];
it('falls back to the root (first) section when the requested index is out of range', () => {
const layouts = [section([item(0, 6)]), section([])];
const ops = createPanelOps({ layouts, layoutIndex: 5, panelId: 'p1', panel });
expect(ops[1].path).toBe('/spec/layouts/0/spec/items/-');
});

View File

@@ -121,7 +121,7 @@ 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. */
/** Preferred section (from a section's "Add panel" trigger); falls back to the root (first) section. */
layoutIndex: number | undefined;
panelId: string;
panel: DashboardtypesPanelDTO;
@@ -166,8 +166,8 @@ export function findFreeSlot(
/**
* 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`.
* section (requested index if valid, else the root/first section, else a
* freshly-created one) and place the panel via `findFreeSlot`.
*/
export function createPanelOps({
layouts,
@@ -177,14 +177,17 @@ export function createPanelOps({
}: 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) {
let targetIndex: number;
let items: DashboardGridItemDTO[];
if (layoutIndex !== undefined && layouts[layoutIndex] !== undefined) {
// Explicit section — a section's own "New Panel" trigger.
targetIndex = layoutIndex;
items = layouts[layoutIndex]?.spec.items ?? [];
} else if (layouts.length > 0) {
// No section specified (toolbar "New Panel") → the root (first) section.
targetIndex = 0;
items = layouts[0]?.spec.items ?? [];
} else {
// No sections yet — create an untitled one and target it.
ops.push(addSectionOp(''));
targetIndex = 0;

View File

@@ -137,13 +137,3 @@ export function layoutsToSections(
})
.filter((s): s is DashboardSection => s !== null);
}
export function getPanelKindLabel(
panel: DashboardtypesPanelDTO | undefined,
): string {
const kind = panel?.spec?.plugin?.kind;
if (!kind) {
return 'unknown';
}
return kind.replace(/^signoz\//, '');
}

View File

@@ -1,5 +1,3 @@
import { useState } from 'react';
import { AnnouncementBanner } from '@signozhq/ui/announcement-banner';
import { LayoutGrid } from '@signozhq/icons';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
@@ -9,19 +7,8 @@ import styles from './DashboardsListPageV2.module.scss';
import { BreadcrumbLink } from '@signozhq/ui/breadcrumb';
function DashboardsListPageV2(): JSX.Element {
const [showBanner, setShowBanner] = useState(true);
return (
<div className={styles.page}>
{showBanner && (
<AnnouncementBanner
type="warning"
onClose={(): void => setShowBanner(false)}
>
You&apos;re on the V2 dashboards page. If you landed here unintentionally,
please reach out to Ashwin.
</AnnouncementBanner>
)}
<div className={styles.header}>
<div className={styles.headerLeft}>
<BreadcrumbLink icon={<LayoutGrid size={14} />}>Dashboard</BreadcrumbLink>

View File

@@ -78,6 +78,11 @@ function DeleteActionItem({
runDelete(undefined, { onSettled: () => destroy() });
},
},
cancelButtonProps: {
onClick: (e): void => {
e.stopPropagation();
},
},
centered: true,
});
}, [modal, dashboardName, runDelete]);

View File

@@ -62,7 +62,7 @@
justify-content: flex-end;
}
.favBtn {
.pinBtn {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -79,16 +79,16 @@
color 0.12s;
}
.row:hover .favBtn {
.row:hover .pinBtn {
color: var(--l3-foreground);
}
.favBtn:hover {
.pinBtn:hover {
background: var(--l1-background);
color: var(--bg-amber-500);
}
.favBtnOn {
.pinBtnOn {
color: var(--bg-amber-500);
svg {

View File

@@ -1,7 +1,7 @@
import { Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { Badge } from '@signozhq/ui/badge';
import { CalendarClock, Star } from '@signozhq/icons';
import { CalendarClock, Pin, PinOff } from '@signozhq/icons';
import cx from 'classnames';
import logEvent from 'api/common/logEvent';
import { generatePath } from 'react-router-dom';
@@ -12,9 +12,10 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useTimezone } from 'providers/Timezone';
import { isModifierKeyPressed } from 'utils/app';
import { usePinDashboard } from '../../hooks/usePinDashboard';
import { useDashboardViewsStore } from '../../store/useDashboardViewsStore';
import type { DashboardListItem } from '../../utils';
import { lastUpdatedLabel, tagsToStrings } from '../../utils';
import type { DashboardListItem } from '../../utils/helpers';
import { lastUpdatedLabel, tagsToStrings } from '../../utils/helpers';
import ActionsPopover from '../ActionsPopover/ActionsPopover';
import styles from './DashboardRow.module.scss';
@@ -37,12 +38,10 @@ function DashboardRow({
const { safeNavigate } = useSafeNavigate();
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const isFavorite = useDashboardViewsStore((s) =>
s.favorites.includes(dashboard.id),
);
const toggleFavorite = useDashboardViewsStore((s) => s.toggleFavorite);
const markViewed = useDashboardViewsStore((s) => s.markViewed);
const { togglePin, isUpdating } = usePinDashboard();
const isPinned = !!dashboard.pinned;
const id = dashboard.id;
const name = dashboard.spec?.display?.name ?? '';
const image = dashboard.image || Base64Icons[0];
@@ -69,9 +68,9 @@ function DashboardRow({
});
};
const onToggleFavorite = (event: React.MouseEvent<HTMLElement>): void => {
const onTogglePin = (event: React.MouseEvent<HTMLElement>): void => {
event.stopPropagation();
toggleFavorite(id);
togglePin(id, isPinned);
};
return (
@@ -105,7 +104,7 @@ function DashboardRow({
))}
{tags.length > 3 && (
<Badge className={styles.tag} key={tags[3]}>
+ <span> {tags.length - 3} </span>
+ <Typography.Text> {tags.length - 3} </Typography.Text>
</Badge>
)}
</div>
@@ -114,13 +113,14 @@ function DashboardRow({
<button
type="button"
className={cx(styles.favBtn, { [styles.favBtnOn]: isFavorite })}
aria-label={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
title={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
data-testid={`dashboard-favorite-${index}`}
onClick={onToggleFavorite}
className={cx(styles.pinBtn, { [styles.pinBtnOn]: isPinned })}
aria-label={isPinned ? 'Unpin dashboard' : 'Pin dashboard'}
title={isPinned ? 'Unpin dashboard' : 'Pin dashboard'}
data-testid={`dashboard-pin-${index}`}
disabled={isUpdating}
onClick={onTogglePin}
>
<Star size={14} />
{isPinned ? <PinOff size={14} /> : <Pin size={14} />}
</button>
{canAct && (

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import logEvent from 'api/common/logEvent';
import { useListDashboardsV2 } from 'api/generated/services/dashboard';
import { useListDashboardsForUserV2 } from 'api/generated/services/dashboard';
import {
DashboardtypesListOrderDTO,
DashboardtypesListSortDTO,
@@ -10,7 +10,8 @@ import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useAppContext } from 'providers/App/App';
import { toAPIError } from 'utils/errorUtils';
import { combineQueries } from '../../filterQuery';
import { combineQueries } from '../../utils/filterQuery';
import { useAccumulatedTags } from '../../hooks/useAccumulatedTags';
import { useActiveView } from '../../hooks/useActiveView';
import { useDashboardFilters } from '../../hooks/useDashboardFilters';
import {
@@ -20,9 +21,10 @@ import {
} from '../../hooks/useDashboardsListQueryParams';
import { useDashboardViewsStore } from '../../store/useDashboardViewsStore';
import { useDashboardsListVisibleColumnsStore } from '../../store/useVisibleColumnsStore';
import type { UpdatedWindow } from '../../types';
import type { DashboardListItem } from '../../utils';
import { applyClientView } from '../../views';
import { BuiltinViewId } from '../../types';
import type { SelectedTag, UpdatedWindow } from '../../types';
import type { DashboardListItem } from '../../utils/helpers';
import { applyClientView } from '../../utils/views';
import type { CreatorOption } from '../FilterZone/FilterChips';
import FilterZone from '../FilterZone/FilterZone';
import NewDashboardModal from '../NewDashboardModal/NewDashboardModal';
@@ -55,6 +57,7 @@ function DashboardsList(): JSX.Element {
setSearch,
setCreatedBy,
setUpdated,
setTags,
applyFilters,
clearAll,
} = useDashboardFilters();
@@ -66,6 +69,7 @@ function DashboardsList(): JSX.Element {
activeViewId,
builtinViews,
customViews,
customViewsLoading,
isCustomActive,
isModified,
viewQuery,
@@ -75,11 +79,18 @@ function DashboardsList(): JSX.Element {
saveActiveView,
resetView,
removeView,
} = useActiveView({ filters, applyFilters, userEmail: user.email });
} = useActiveView({
filters,
applyFilters,
userEmail: user.email,
sortColumn,
sortOrder,
setSortColumn,
setSortOrder,
});
const railCollapsed = useDashboardViewsStore((s) => s.railCollapsed);
const setRailCollapsed = useDashboardViewsStore((s) => s.setRailCollapsed);
const favorites = useDashboardViewsStore((s) => s.favorites);
const recent = useDashboardViewsStore((s) => s.recent);
// Any filter change resets to the first page so the user isn't stranded on a
@@ -105,6 +116,13 @@ function DashboardsList(): JSX.Element {
},
[setUpdated, setPage],
);
const handleTagsChange = useCallback(
(tags: SelectedTag[]): void => {
setTags(tags);
void setPage(1);
},
[setTags, setPage],
);
const handleClearAll = useCallback((): void => {
clearAll();
void setPage(1);
@@ -150,7 +168,9 @@ function DashboardsList(): JSX.Element {
isFetching,
error,
refetch,
} = useListDashboardsV2(listParams, { query: { keepPreviousData: true } });
} = useListDashboardsForUserV2(listParams, {
query: { keepPreviousData: true },
});
const apiError = useMemo(
() => (error ? toAPIError(error) : undefined),
@@ -169,9 +189,9 @@ function DashboardsList(): JSX.Element {
const dashboards = useMemo<DashboardListItem[]>(
() =>
clientView
? applyClientView(rawDashboards, activeViewId, favorites, recent)
? applyClientView(rawDashboards, activeViewId, recent)
: rawDashboards,
[clientView, rawDashboards, activeViewId, favorites, recent],
[clientView, rawDashboards, activeViewId, recent],
);
const total = clientView ? dashboards.length : (response?.data?.total ?? 0);
@@ -194,6 +214,16 @@ function DashboardsList(): JSX.Element {
}));
}, [rawDashboards, user.email]);
// All key:value tags the API reports for the org's dashboards, powering the
// Tags filter chip and DSL key suggestions. Accumulated across refetches so
// previously-seen tags stay selectable even when a filtered page omits them.
const responseTags = useMemo<SelectedTag[]>(
() =>
(response?.data?.tags ?? []).map((t) => ({ key: t.key, value: t.value })),
[response],
);
const availableTags = useAccumulatedTags(responseTags);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const visibleColumns = useDashboardsListVisibleColumnsStore(
(s) => s.visibleColumns,
@@ -239,7 +269,7 @@ function DashboardsList(): JSX.Element {
const showWorkspaceEmpty =
!error &&
dashboards.length === 0 &&
activeViewId === 'all' &&
activeViewId === BuiltinViewId.All &&
filtersEmpty &&
page === 1;
@@ -251,6 +281,7 @@ function DashboardsList(): JSX.Element {
activeViewId={activeViewId}
builtinViews={builtinViews}
customViews={customViews}
customViewsLoading={customViewsLoading}
isCustomActive={isCustomActive}
isModified={isModified}
collapsed={railCollapsed}
@@ -281,11 +312,14 @@ function DashboardsList(): JSX.Element {
search={filters.search}
createdBy={filters.createdBy}
updated={filters.updated}
tags={filters.tags}
availableTags={availableTags}
creatorOptions={creatorOptions}
isEmpty={filtersEmpty}
onSearchChange={handleSearchChange}
onCreatedByChange={handleCreatedByChange}
onUpdatedChange={handleUpdatedChange}
onTagsChange={handleTagsChange}
onClearAll={handleClearAll}
/>
</div>

View File

@@ -2,7 +2,7 @@ import { useMemo } from 'react';
import { Table } from 'antd';
import type { TableProps } from 'antd/lib';
import type { DashboardListItem } from '../../utils';
import type { DashboardListItem } from '../../utils/helpers';
import DashboardRow from '../DashboardRow/DashboardRow';
interface Props {

View File

@@ -3,8 +3,8 @@ import {
DashboardtypesListSortDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { DashboardListItem } from '../../utils';
import { noResultsCopy } from '../../views';
import type { DashboardListItem } from '../../utils/helpers';
import { noResultsCopy } from '../../utils/views';
import ListHeader from '../ListHeader/ListHeader';
import ErrorState from '../states/ErrorState/ErrorState';
import LoadingState from '../states/LoadingState/LoadingState';

View File

@@ -1,10 +1,19 @@
import { type ReactNode, useCallback, useEffect, useState } from 'react';
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { X } from '@signozhq/icons';
import type { UpdatedWindow } from '../../types';
import { buildSuggestionKeys } from '../../utils/dslSuggestions';
import type { SelectedTag, UpdatedWindow } from '../../types';
import SearchBar from '../SearchBar/SearchBar';
import FilterChips, { type CreatorOption } from './FilterChips';
import TagsFilterChip from './TagsFilterChip';
import styles from './FilterZone.module.scss';
@@ -12,11 +21,14 @@ interface Props {
search: string;
createdBy: string[];
updated: UpdatedWindow;
tags: SelectedTag[];
availableTags: SelectedTag[];
creatorOptions: CreatorOption[];
isEmpty: boolean;
onSearchChange: (value: string) => void;
onCreatedByChange: (emails: string[]) => void;
onUpdatedChange: (window: UpdatedWindow) => void;
onTagsChange: (tags: SelectedTag[]) => void;
onClearAll: () => void;
// Rendered at the end of the search row (e.g. the New Dashboard action).
rightSlot?: ReactNode;
@@ -29,16 +41,24 @@ function FilterZone({
search,
createdBy,
updated,
tags,
availableTags,
creatorOptions,
isEmpty,
onSearchChange,
onCreatedByChange,
onUpdatedChange,
onTagsChange,
onClearAll,
rightSlot,
}: Props): JSX.Element {
const [searchInput, setSearchInput] = useState(search);
const suggestionKeys = useMemo(
() => buildSuggestionKeys(availableTags),
[availableTags],
);
// Keep the local input in sync with external search changes (applying a view,
// clear-all, back/forward). User typing only mutates the local copy.
useEffect(() => {
@@ -58,7 +78,8 @@ function FilterZone({
<div className={styles.searchInput}>
<SearchBar
value={searchInput}
placeholder="Search dashboards by name"
placeholder={`Search with DSL — e.g. name contains "prod" AND env = "staging"`}
suggestionKeys={suggestionKeys}
onChange={setSearchInput}
onSubmit={handleSubmit}
/>
@@ -66,7 +87,7 @@ function FilterZone({
{rightSlot}
</div>
<div className={styles.filtersRow}>
<span className={styles.filtersLabel}>Filters</span>
<Typography.Text className={styles.filtersLabel}>Filters</Typography.Text>
<FilterChips
createdBy={createdBy}
updated={updated}
@@ -74,6 +95,11 @@ function FilterZone({
onCreatedByChange={onCreatedByChange}
onUpdatedChange={onUpdatedChange}
/>
<TagsFilterChip
availableTags={availableTags}
tags={tags}
onTagsChange={onTagsChange}
/>
{!isEmpty && (
<Button
variant="ghost"

View File

@@ -0,0 +1,80 @@
import { useMemo } from 'react';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import { ChevronDown, Tag } from '@signozhq/icons';
import cx from 'classnames';
import type { SelectedTag } from '../../types';
import styles from './FilterZone.module.scss';
interface Props {
// All key:value tags the list API reports across the org's dashboards.
availableTags: SelectedTag[];
tags: SelectedTag[];
onTagsChange: (tags: SelectedTag[]) => void;
}
const tagId = (tag: SelectedTag): string => `${tag.key}:${tag.value}`;
function TagsFilterChip({
availableTags,
tags,
onTagsChange,
}: Props): JSX.Element {
const selectedIds = useMemo(() => new Set(tags.map(tagId)), [tags]);
const label = useMemo((): string => {
if (tags.length === 0) {
return 'Any';
}
if (tags.length === 1) {
return tagId(tags[0]);
}
return `${tags.length} tags`;
}, [tags]);
const items = useMemo<MenuItem[]>(() => {
const options: MenuItem[] = availableTags.map((tag) => {
const id = tagId(tag);
return {
type: 'checkbox',
key: id,
label: id,
checked: selectedIds.has(id),
onCheckedChange: (checked: boolean): void =>
onTagsChange(
checked ? [...tags, tag] : tags.filter((t) => tagId(t) !== id),
),
};
});
if (tags.length > 0) {
options.push({ type: 'divider', key: 'sep' });
options.push({
key: 'clear',
label: 'Clear selection',
onClick: (): void => onTagsChange([]),
});
}
return options;
}, [availableTags, selectedIds, tags, onTagsChange]);
return (
<DropdownMenuSimple menu={{ items }} align="start">
<Button
variant="outlined"
color="secondary"
size="sm"
prefix={<Tag size={12} />}
suffix={<ChevronDown size={12} />}
className={cx(styles.chip, { [styles.chipActive]: tags.length > 0 })}
disabled={availableTags.length === 0}
testId="dashboards-filter-tags"
>
Tags: {label}
</Button>
</DropdownMenuSimple>
);
}
export default TagsFilterChip;

View File

@@ -54,7 +54,7 @@ function ListHeader({
const metadataContent = (
<div className={styles.metaPanel}>
<Typography.Text className={styles.sortHeading}>Metadata</Typography.Text>
<Typography.Text className={styles.sortHeading}>Columns</Typography.Text>
{METADATA_COLUMNS.map((col) => (
<div key={col.key} className={styles.metaRow}>
<Typography.Text className={styles.metaLabel}>{col.label}</Typography.Text>
@@ -171,7 +171,7 @@ function ListHeader({
)
}
>
<span className={styles.sortPrefix}>Sort:</span>{' '}
<Typography.Text className={styles.sortPrefix}>Sort:</Typography.Text>{' '}
{SORT_LABELS[sortColumn]}{' '}
</Button>
</Popover>
@@ -183,13 +183,13 @@ function ListHeader({
placement="bottomRight"
arrow={false}
>
<Tooltip title="Metadata">
<Tooltip title="Columns">
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="Metadata"
testId="configure-metadata-trigger"
aria-label="Columns"
testId="configure-columns-trigger"
>
<HdmiPort size={14} />
</Button>

View File

@@ -13,8 +13,9 @@ import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import TagKeyValueInput from 'components/TagKeyValueInput/TagKeyValueInput';
import { toPostableTags } from '../../utils';
import { keyValueStringsToTags } from '../../utils/helpers';
import styles from './NewDashboardModal.module.scss';
@@ -30,7 +31,7 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
const [name, setName] = useState(DEFAULT_NAME);
const [description, setDescription] = useState('');
const [tags, setTags] = useState('');
const [tags, setTags] = useState<string[]>([]);
const [submitting, setSubmitting] = useState(false);
const canSubmit = name.trim().length > 0 && !submitting;
@@ -42,7 +43,7 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
try {
setSubmitting(true);
logEvent('Dashboard List: Create dashboard clicked', {});
const postableTags = toPostableTags(tags);
const postableTags = keyValueStringsToTags(tags);
const created = await createDashboardV2({
schemaVersion: 'v6',
generateName: true,
@@ -72,7 +73,7 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
<div className={styles.form}>
<div className={styles.field}>
<Typography.Text className={styles.label}>
Title <span className={styles.required}>*</span>
Title <Typography.Text className={styles.required}>*</Typography.Text>
</Typography.Text>
<Input
value={name}
@@ -104,16 +105,14 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
<div className={styles.field}>
<Typography.Text className={styles.label}>Tags</Typography.Text>
<Input
value={tags}
placeholder="team:jarvis, prod"
<TagKeyValueInput
tags={tags}
onTagsChange={setTags}
placeholder="team:jarvis (press Enter)"
testId="create-dashboard-tags"
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
setTags(e.target.value)
}
/>
<Typography.Text className={styles.hint}>
Comma-separated. Use key:value (e.g. team:jarvis) or a single label.
Use key:value (e.g. team:jarvis) and press Enter to add.
</Typography.Text>
</div>
</div>

View File

@@ -1,24 +1,58 @@
.submit {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
background: transparent;
border: none;
border-radius: 3px;
color: inherit;
cursor: pointer;
transition: background 120ms ease;
&:hover,
&:focus-visible {
background: color-mix(in srgb, var(--l1-foreground) 12%, transparent);
outline: none;
}
&:active {
background: color-mix(in srgb, var(--l1-foreground) 20%, transparent);
}
.wrapper {
position: relative;
width: 100%;
}
.input::placeholder {
color: var(--l3-foreground);
}
// Flatten the input's bottom corners while the dropdown is attached below it.
.inputOpen {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 2px;
max-height: 260px;
overflow-y: auto;
padding: 4px;
border: 1px solid var(--l2-border);
border-top: none;
border-radius: 0 0 6px 6px;
background: var(--l1-background);
/* stylelint-disable-next-line local/prefer-css-variables -- matches the V2 dashboard dropdown shadow */
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
}
.suggestion {
display: flex;
align-items: center;
padding: 8px 10px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--l2-foreground);
font-family: 'Space Mono', monospace;
font-size: var(--font-size-sm);
text-align: left;
cursor: pointer;
transition: background 0.1s;
}
.suggestionActive {
background: var(--l2-background);
color: var(--l1-foreground);
}
.submit {
color: var(--bg-vanilla-400);
}

View File

@@ -1,7 +1,21 @@
import { ChangeEvent, KeyboardEvent, MouseEvent } from 'react';
import {
ChangeEvent,
KeyboardEvent,
MouseEvent,
useMemo,
useState,
} from 'react';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Color } from '@signozhq/design-tokens';
import { CornerDownLeft, Search } from '@signozhq/icons';
import cx from 'classnames';
import {
applyKeySuggestion,
getActiveKeyToken,
matchKeys,
} from '../../utils/dslSuggestions';
import styles from './SearchBar.module.scss';
@@ -10,6 +24,8 @@ interface Props {
onChange: (value: string) => void;
onSubmit: () => void;
placeholder?: string;
// Keys offered as you type (reserved DSL columns + tag keys from the API).
suggestionKeys?: string[];
}
function SearchBar({
@@ -17,38 +33,116 @@ function SearchBar({
onChange,
onSubmit,
placeholder = "Search with DSL (e.g. name CONTAINS 'foo')",
suggestionKeys = [],
}: Props): JSX.Element {
const [focused, setFocused] = useState(false);
// -1 means nothing is highlighted, so Enter submits the typed query rather
// than picking a suggestion (arrow keys engage selection).
const [highlighted, setHighlighted] = useState(-1);
const active = useMemo(() => getActiveKeyToken(value), [value]);
const suggestions = useMemo(
() => (active ? matchKeys(suggestionKeys, active.token) : []),
[active, suggestionKeys],
);
const showSuggestions = focused && suggestions.length > 0;
const pickSuggestion = (key: string): void => {
if (active) {
onChange(applyKeySuggestion(value, active, key));
}
setHighlighted(-1);
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
if (showSuggestions && e.key === 'ArrowDown') {
e.preventDefault();
setHighlighted((h) => Math.min(h + 1, suggestions.length - 1));
return;
}
if (showSuggestions && e.key === 'ArrowUp') {
e.preventDefault();
setHighlighted((h) => Math.max(h - 1, 0));
return;
}
if (e.key === 'Enter') {
if (showSuggestions && highlighted >= 0) {
e.preventDefault();
pickSuggestion(suggestions[highlighted]);
} else {
onSubmit();
}
return;
}
if (e.key === 'Escape') {
setFocused(false);
setHighlighted(-1);
}
};
return (
<Input
placeholder={placeholder}
prefix={<Search size={12} color={Color.BG_VANILLA_400} />}
suffix={
<button
type="button"
className={styles.submit}
aria-label="Run search"
data-testid="dashboards-list-search-submit"
onMouseDown={(e: MouseEvent<HTMLButtonElement>): void => {
// Prevent the input's blur from firing first and double-submitting.
e.preventDefault();
}}
onClick={onSubmit}
>
<CornerDownLeft size={12} color={Color.BG_VANILLA_400} />
</button>
}
value={value}
testId="dashboards-list-search"
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
onChange(e.target.value)
}
onBlur={onSubmit}
onKeyDown={(e: KeyboardEvent<HTMLInputElement>): void => {
if (e.key === 'Enter') {
onSubmit();
<div className={styles.wrapper}>
<Input
className={cx(styles.input, { [styles.inputOpen]: showSuggestions })}
placeholder={placeholder}
prefix={<Search size={12} color={Color.BG_VANILLA_400} />}
suffix={
<Button
variant="ghost"
color="secondary"
size="icon"
className={styles.submit}
aria-label="Run search"
testId="dashboards-list-search-submit"
onMouseDown={(e: MouseEvent<HTMLButtonElement>): void => {
// Prevent the input's blur from firing first and double-submitting.
e.preventDefault();
}}
onClick={onSubmit}
>
<CornerDownLeft size={12} color={Color.BG_VANILLA_400} />
</Button>
}
}}
/>
value={value}
testId="dashboards-list-search"
onChange={(e: ChangeEvent<HTMLInputElement>): void => {
onChange(e.target.value);
setHighlighted(-1);
}}
onFocus={(): void => setFocused(true)}
onBlur={(): void => {
setFocused(false);
setHighlighted(-1);
onSubmit();
}}
onKeyDown={handleKeyDown}
/>
{showSuggestions && (
<div
className={styles.suggestions}
data-testid="dashboards-list-search-suggestions"
>
{suggestions.map((key, index) => (
<button
key={key}
type="button"
className={cx(styles.suggestion, {
[styles.suggestionActive]: index === highlighted,
})}
data-testid={`dashboards-list-search-suggestion-${key}`}
onMouseEnter={(): void => setHighlighted(index)}
onMouseDown={(e: MouseEvent<HTMLButtonElement>): void => {
// Keep focus on the input so blur doesn't submit before we update.
e.preventDefault();
}}
onClick={(): void => pickSuggestion(key)}
>
{key}
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -2,21 +2,17 @@ import { type ChangeEvent, type ReactNode, useEffect, useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { PopoverSimple } from '@signozhq/ui/popover';
import cx from 'classnames';
import { VIEW_ICON_OPTIONS } from '../../views';
import { Typography } from '@signozhq/ui/typography';
import styles from './ViewsRail.module.scss';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (name: string, icon: string) => void;
onSave: (name: string) => void;
trigger: ReactNode;
}
const DEFAULT_ICON = VIEW_ICON_OPTIONS[0].name;
function SaveViewPopover({
open,
onOpenChange,
@@ -24,12 +20,10 @@ function SaveViewPopover({
trigger,
}: Props): JSX.Element {
const [name, setName] = useState('');
const [icon, setIcon] = useState(DEFAULT_ICON);
useEffect(() => {
if (open) {
setName('');
setIcon(DEFAULT_ICON);
}
}, [open]);
@@ -37,7 +31,7 @@ function SaveViewPopover({
const handleSave = (): void => {
if (canSave) {
onSave(name, icon);
onSave(name);
onOpenChange(false);
}
};
@@ -51,7 +45,7 @@ function SaveViewPopover({
>
<div className={styles.savePopover}>
<div className={styles.saveTitle}>Save as view</div>
<span className={styles.saveLabel}>Name</span>
<Typography.Text className={styles.saveLabel}>Name</Typography.Text>
<Input
value={name}
autoFocus
@@ -66,22 +60,6 @@ function SaveViewPopover({
}
}}
/>
<span className={styles.saveLabel}>Icon</span>
<div className={styles.iconGrid}>
{VIEW_ICON_OPTIONS.map(({ name: iconName, Icon }) => (
<button
key={iconName}
type="button"
aria-label={iconName}
className={cx(styles.iconCell, {
[styles.iconCellOn]: icon === iconName,
})}
onClick={(): void => setIcon(iconName)}
>
<Icon size={14} />
</button>
))}
</div>
<div className={styles.saveActions}>
<Button
variant="ghost"

View File

@@ -28,6 +28,7 @@
font-weight: var(--font-weight-semibold);
letter-spacing: -0.01em;
color: var(--l1-foreground);
text-align: left;
}
.search {
@@ -76,7 +77,13 @@
padding: 1px 6px;
}
.deleteName {
color: var(--danger-background);
font-weight: var(--font-weight-medium);
}
.row {
position: relative;
display: flex;
align-items: center;
margin: 3px 0;
@@ -92,27 +99,27 @@
background: var(--l2-background);
}
// A left accent bar reads the active row more clearly than background alone.
.rowActive::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 18px;
border-radius: 0 3px 3px 0;
background: var(--primary-background);
}
.item {
// Neutralise the signoz Button defaults so it reads as a full-width,
// left-aligned list row; the row coordinates hover/active colours below.
--button-display: flex;
--button-justify-content: flex-start;
--button-height: auto;
--button-padding: 9px 10px;
--button-gap: 10px;
--button-variant-ghost-background-color: transparent;
--button-variant-ghost-hover-background-color: transparent;
--button-variant-ghost-color: var(--l2-foreground);
--button-variant-ghost-hover-color: var(--l1-foreground);
flex: 1;
min-width: 0;
width: 100%;
font-size: var(--font-size-sm);
}
.row:hover .item,
.rowActive .item {
--button-variant-ghost-color: var(--l1-foreground);
font-size: var(--font-size-base);
}
.itemIcon {
@@ -125,7 +132,6 @@
}
.itemLabel {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -140,24 +146,31 @@
}
.itemAction {
// Square icon button that surfaces on row hover and turns red on its own
// hover; colours flow through the signoz Button tokens.
--button-height: auto;
// Blended ghost icon overlaid on the row's right edge — absolutely positioned
// so it never reserves layout space or affects the row height. Transparent so
// the row's hover background shows through; turns red only on its own hover.
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
--button-height: 20px;
--button-padding: 0;
--button-border-radius: 4px;
--button-variant-ghost-background-color: transparent;
--button-variant-ghost-color: var(--l3-foreground);
--button-variant-ghost-hover-background-color: var(--danger-background);
--button-variant-ghost-hover-color: var(--danger-color, #fff);
width: 20px;
height: 20px;
margin-right: 8px;
opacity: 0;
// Hidden until row hover, and inert while hidden so it can't intercept clicks
// meant for the row.
pointer-events: none;
transition: opacity 0.1s;
}
.row:hover .itemAction {
opacity: 1;
pointer-events: auto;
}
.empty {
@@ -217,37 +230,6 @@
margin-top: 2px;
}
.iconGrid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 6px;
}
.iconCell {
display: flex;
align-items: center;
justify-content: center;
height: 34px;
border: 1px solid var(--l2-border);
border-radius: 6px;
background: var(--l2-background);
color: var(--l2-foreground);
cursor: pointer;
transition:
border-color 0.12s,
color 0.12s;
}
.iconCell:hover {
color: var(--l1-foreground);
border-color: var(--l3-foreground);
}
.iconCellOn {
border-color: var(--primary-background);
color: var(--primary-background);
}
.saveActions {
display: flex;
justify-content: flex-end;

View File

@@ -3,11 +3,11 @@ import { Modal } from 'antd';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import { CircleAlert, Plus, Search, Trash2 } from '@signozhq/icons';
import { Bookmark, CircleAlert, Plus, Search, Trash2 } from '@signozhq/icons';
import cx from 'classnames';
import type { SavedView } from '../../types';
import { type BuiltinView, iconByName } from '../../views';
import { type BuiltinView } from '../../utils/views';
import SaveViewPopover from './SaveViewPopover';
import styles from './ViewsRail.module.scss';
@@ -16,11 +16,12 @@ interface Props {
activeViewId: string;
builtinViews: BuiltinView[];
customViews: SavedView[];
customViewsLoading: boolean;
isCustomActive: boolean;
isModified: boolean;
collapsed?: boolean;
onSelect: (id: string) => void;
onSave: (name: string, icon: string) => void;
onSave: (name: string) => void;
onSaveChanges: () => void;
onReset: () => void;
onClearFilters: () => void;
@@ -40,6 +41,7 @@ function ViewsRail({
activeViewId,
builtinViews,
customViews,
customViewsLoading,
isCustomActive,
isModified,
collapsed = false,
@@ -73,11 +75,8 @@ function ViewsRail({
const { destroy } = modal.confirm({
title: (
<Typography.Title level={5}>
Delete the
<span style={{ color: 'var(--danger-background)', fontWeight: 500 }}>
{' '}
{label}{' '}
</span>
Delete the{' '}
<Typography.Text className={styles.deleteName}>{label}</Typography.Text>{' '}
view?
</Typography.Title>
),
@@ -116,12 +115,10 @@ function ViewsRail({
onClick={(): void => onSelect(row.id)}
testId={`dashboards-view-${row.id}`}
>
<span className={styles.itemIcon}>
<Icon size={14} />
</span>
<span className={styles.itemLabel}>{row.label}</span>
<Icon size={16} className={styles.itemIcon} />
<Typography.Text className={styles.itemLabel}>{row.label}</Typography.Text>
{active && isModified && (
<span className={styles.dirtyDot} title="Unsaved changes" />
<div className={styles.dirtyDot} title="Unsaved changes" />
)}
</Button>
{row.deletable && (
@@ -132,7 +129,10 @@ function ViewsRail({
className={styles.itemAction}
aria-label="Delete view"
title="Delete view"
onClick={(): void => confirmDelete(row.id, row.label)}
onClick={(e): void => {
e.stopPropagation();
confirmDelete(row.id, row.label);
}}
>
<Trash2 size={12} />
</Button>
@@ -166,7 +166,7 @@ function ViewsRail({
<div className={styles.search}>
<Input
value={query}
placeholder="Search views"
placeholder="Filter views by name"
prefix={<Search size={12} />}
testId="dashboards-view-search"
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
@@ -196,9 +196,13 @@ function ViewsRail({
<>
<div className={cx(styles.groupLabel, styles.groupLabelSpaced)}>
My views
<span className={styles.groupCount}>{customViews.length}</span>
<Typography.Text className={styles.groupCount}>
{customViews.length}
</Typography.Text>
</div>
{customViews.length === 0 ? (
{customViewsLoading ? (
<div className={styles.empty}>Loading views</div>
) : customViews.length === 0 ? (
<div className={styles.empty}>
No saved views yet. Filter the list, then save it as a view.
</div>
@@ -207,7 +211,7 @@ function ViewsRail({
renderItem({
id: v.id,
label: v.name,
icon: iconByName(v.icon),
icon: Bookmark,
deletable: true,
}),
)

View File

@@ -5,7 +5,7 @@ import { handleContactSupport } from 'container/Integrations/utils';
import awwSnapUrl from '@/assets/Icons/awwSnap.svg';
import { formatQueryErrorMessage } from '../../../utils';
import { formatQueryErrorMessage } from '../../../utils/helpers';
import styles from './ErrorState.module.scss';
interface Props {

View File

@@ -0,0 +1,32 @@
import { useEffect, useState } from 'react';
import type { SelectedTag } from '../types';
const tagId = (tag: SelectedTag): string => `${tag.key}:${tag.value}`;
// The list response only reports the tags present in the current (filtered) page,
// so tags vanish from the filter options as results narrow. Accumulate every tag
// we've ever seen so previously-surfaced tags stay selectable across refetches.
export function useAccumulatedTags(responseTags: SelectedTag[]): SelectedTag[] {
const [tags, setTags] = useState<SelectedTag[]>([]);
useEffect(() => {
if (responseTags.length === 0) {
return;
}
setTags((prev) => {
const merged = new Map(prev.map((t) => [tagId(t), t]));
let changed = false;
responseTags.forEach((t) => {
const id = tagId(t);
if (!merged.has(id)) {
merged.set(id, t);
changed = true;
}
});
return changed ? Array.from(merged.values()) : prev;
});
}, [responseTags]);
return tags;
}

View File

@@ -1,8 +1,17 @@
import { useCallback, useMemo } from 'react';
import { parseAsString, useQueryState, type Options } from 'nuqs';
import type {
DashboardtypesListOrderDTO,
DashboardtypesListSortDTO,
} from 'api/generated/services/sigNoz.schemas';
import { DEFAULT_FILTER_STATE, areFilterStatesEqual } from '../filterQuery';
import { useDashboardViewsStore } from '../store/useDashboardViewsStore';
import {
areFilterStatesEqual,
combineQueries,
DEFAULT_FILTER_STATE,
filterStateToQuery,
} from '../utils/filterQuery';
import { BuiltinViewId } from '../types';
import type { DashboardFilterState, SavedView } from '../types';
import {
BUILTIN_VIEWS,
@@ -10,7 +19,8 @@ import {
builtinViewSnapshot,
type BuiltinView,
isClientView,
} from '../views';
} from '../utils/views';
import { useSavedViews } from './useSavedViews';
const opts: Options = { history: 'push' };
@@ -18,43 +28,62 @@ interface UseActiveViewArgs {
filters: DashboardFilterState;
applyFilters: (next: DashboardFilterState) => void;
userEmail: string;
sortColumn: DashboardtypesListSortDTO;
sortOrder: DashboardtypesListOrderDTO;
setSortColumn: (column: DashboardtypesListSortDTO) => void;
setSortOrder: (order: DashboardtypesListOrderDTO) => void;
}
export interface UseActiveViewResult {
activeViewId: string;
builtinViews: BuiltinView[];
customViews: SavedView[];
customViewsLoading: boolean;
isCustomActive: boolean;
// Current filters diverge from the active view's canonical snapshot.
isModified: boolean;
// Extra server-query fragment the active view contributes, and whether it
// constrains the list client-side (favorites/recent).
// constrains the list client-side (pinned/recent).
viewQuery: string;
clientView: boolean;
selectView: (id: string) => void;
saveView: (name: string, icon: string) => void;
saveView: (name: string) => void;
saveActiveView: () => void;
resetView: () => void;
removeView: (id: string) => void;
}
// The canonical filter snapshot a saved view "is": the backend stores a flat
// query, so a view folds entirely into the search box with empty chips.
const customSnapshot = (view: SavedView): DashboardFilterState => ({
...DEFAULT_FILTER_STATE,
search: view.query,
});
// Orchestrates the active view: which view is selected (URL `view` param),
// merging built-in + persisted custom views, applying a view's snapshot on
// select, dirty detection, and save/reset/delete.
// merging built-in + org-shared saved views, applying a view's snapshot on
// select, dirty detection, and save/reset/delete via the Views API.
export function useActiveView({
filters,
applyFilters,
userEmail,
sortColumn,
sortOrder,
setSortColumn,
setSortOrder,
}: UseActiveViewArgs): UseActiveViewResult {
const [activeViewId, setActiveViewId] = useQueryState(
'view',
parseAsString.withDefault('all').withOptions(opts),
parseAsString.withDefault(BuiltinViewId.All).withOptions(opts),
);
const customViews = useDashboardViewsStore((s) => s.customViews);
const addView = useDashboardViewsStore((s) => s.addView);
const updateView = useDashboardViewsStore((s) => s.updateView);
const deleteView = useDashboardViewsStore((s) => s.deleteView);
const {
views: customViews,
isLoading: customViewsLoading,
createView,
updateView,
deleteView,
} = useSavedViews();
const activeCustom = useMemo(
() => customViews.find((v) => v.id === activeViewId),
@@ -65,7 +94,7 @@ export function useActiveView({
const canonicalSnapshot = useMemo<DashboardFilterState | null>(
() =>
activeCustom
? activeCustom.filters
? customSnapshot(activeCustom)
: builtinViewSnapshot(activeViewId, userEmail),
[activeCustom, activeViewId, userEmail],
);
@@ -78,47 +107,93 @@ export function useActiveView({
(id: string): void => {
void setActiveViewId(id);
const custom = customViews.find((v) => v.id === id);
applyFilters(
custom?.filters ??
builtinViewSnapshot(id, userEmail) ??
DEFAULT_FILTER_STATE,
);
if (custom) {
applyFilters(customSnapshot(custom));
setSortColumn(custom.sort);
setSortOrder(custom.order);
return;
}
applyFilters(builtinViewSnapshot(id, userEmail) ?? DEFAULT_FILTER_STATE);
},
[setActiveViewId, customViews, applyFilters, userEmail],
[
setActiveViewId,
customViews,
applyFilters,
userEmail,
setSortColumn,
setSortOrder,
],
);
const saveView = useCallback(
(name: string, icon: string): void => {
const id = `cv_${Date.now()}`;
addView({
id,
name: name.trim(),
icon,
filters: { ...filters },
createdAt: Date.now(),
});
void setActiveViewId(id);
(name: string): void => {
// Fold the current built-in clause + chips into a single query string.
const query = combineQueries(
builtinViewQuery(activeViewId),
filterStateToQuery(filters),
);
void (async (): Promise<void> => {
const created = await createView({
name,
query,
sort: sortColumn,
order: sortOrder,
});
if (created) {
void setActiveViewId(created.id);
// Re-apply the folded representation so the new view isn't
// immediately flagged as modified.
applyFilters(customSnapshot(created));
}
})();
},
[addView, filters, setActiveViewId],
[
activeViewId,
filters,
createView,
sortColumn,
sortOrder,
setActiveViewId,
applyFilters,
],
);
const saveActiveView = useCallback((): void => {
if (activeCustom) {
updateView(activeCustom.id, { filters: { ...filters } });
if (!activeCustom) {
return;
}
}, [activeCustom, updateView, filters]);
const query = filterStateToQuery(filters);
updateView(activeCustom.id, {
name: activeCustom.name,
query,
sort: sortColumn,
order: sortOrder,
});
applyFilters({ ...DEFAULT_FILTER_STATE, search: query });
}, [activeCustom, filters, updateView, sortColumn, sortOrder, applyFilters]);
const resetView = useCallback((): void => {
if (canonicalSnapshot) {
applyFilters(canonicalSnapshot);
if (!canonicalSnapshot) {
return;
}
}, [canonicalSnapshot, applyFilters]);
applyFilters(canonicalSnapshot);
if (activeCustom) {
setSortColumn(activeCustom.sort);
setSortOrder(activeCustom.order);
}
}, [
canonicalSnapshot,
applyFilters,
activeCustom,
setSortColumn,
setSortOrder,
]);
const removeView = useCallback(
(id: string): void => {
deleteView(id);
if (activeViewId === id) {
void setActiveViewId('all');
void setActiveViewId(BuiltinViewId.All);
applyFilters(DEFAULT_FILTER_STATE);
}
},
@@ -129,6 +204,7 @@ export function useActiveView({
activeViewId,
builtinViews: BUILTIN_VIEWS,
customViews,
customViewsLoading,
isCustomActive: !!activeCustom,
isModified,
viewQuery: builtinViewQuery(activeViewId),

View File

@@ -11,13 +11,28 @@ import {
DEFAULT_FILTER_STATE,
filterStateToQuery,
isFilterStateEmpty,
} from '../filterQuery';
import type { DashboardFilterState, UpdatedWindow } from '../types';
} from '../utils/filterQuery';
import type {
DashboardFilterState,
SelectedTag,
UpdatedWindow,
} from '../types';
const UPDATED_WINDOWS: UpdatedWindow[] = ['any', 'today', '7d', '30d'];
const opts: Options = { history: 'push' };
// Tags are carried in the URL as `key:value` strings; split on the first colon.
const parseTag = (raw: string): SelectedTag | null => {
const idx = raw.indexOf(':');
if (idx <= 0) {
return null;
}
return { key: raw.slice(0, idx), value: raw.slice(idx + 1) };
};
const serializeTag = (tag: SelectedTag): string => `${tag.key}:${tag.value}`;
export interface UseDashboardFiltersResult {
filters: DashboardFilterState;
// The backend list-filter `query` string derived from the current filters.
@@ -26,6 +41,7 @@ export interface UseDashboardFiltersResult {
setSearch: (value: string) => void;
setCreatedBy: (emails: string[]) => void;
setUpdated: (window: UpdatedWindow) => void;
setTags: (tags: SelectedTag[]) => void;
// Replace the whole filter state at once — used when applying a saved view.
applyFilters: (next: DashboardFilterState) => void;
clearAll: () => void;
@@ -47,10 +63,19 @@ export function useDashboardFilters(): UseDashboardFiltersResult {
'updated',
parseAsStringLiteral(UPDATED_WINDOWS).withDefault('any').withOptions(opts),
);
const [tagStrings, setTagStringsState] = useQueryState(
'tags',
parseAsArrayOf(parseAsString).withDefault([]).withOptions(opts),
);
const tags = useMemo<SelectedTag[]>(
() => tagStrings.map(parseTag).filter((t): t is SelectedTag => t !== null),
[tagStrings],
);
const filters = useMemo<DashboardFilterState>(
() => ({ search, createdBy, updated }),
[search, createdBy, updated],
() => ({ search, createdBy, updated, tags }),
[search, createdBy, updated, tags],
);
const query = useMemo(() => filterStateToQuery(filters), [filters]);
@@ -76,13 +101,23 @@ export function useDashboardFilters(): UseDashboardFiltersResult {
[setUpdatedState],
);
const setTags = useCallback(
(next: SelectedTag[]): void => {
void setTagStringsState(next.length ? next.map(serializeTag) : null);
},
[setTagStringsState],
);
const applyFilters = useCallback(
(next: DashboardFilterState): void => {
void setSearchState(next.search || null);
void setCreatedByState(next.createdBy.length ? next.createdBy : null);
void setUpdatedState(next.updated);
void setTagStringsState(
next.tags.length ? next.tags.map(serializeTag) : null,
);
},
[setSearchState, setCreatedByState, setUpdatedState],
[setSearchState, setCreatedByState, setUpdatedState, setTagStringsState],
);
const clearAll = useCallback((): void => {
@@ -96,6 +131,7 @@ export function useDashboardFilters(): UseDashboardFiltersResult {
setSearch,
setCreatedBy,
setUpdated,
setTags,
applyFilters,
clearAll,
};

View File

@@ -0,0 +1,63 @@
import { useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { toast } from '@signozhq/ui/sonner';
import {
invalidateListDashboardsForUserV2,
usePinDashboardV2,
useUnpinDashboardV2,
} from 'api/generated/services/dashboard';
import { getHttpStatusCode } from 'utils/errorUtils';
const PIN_LIMIT_MESSAGE =
'You can pin up to 10 dashboards. Unpin one to add another.';
export interface UsePinDashboardResult {
// Toggle the pin for a dashboard given its current pinned state.
togglePin: (id: string, pinned: boolean) => void;
isUpdating: boolean;
}
// Wraps the per-user pin/unpin mutations: refreshes the personalized list on
// success and surfaces the 10-pin limit (HTTP 409) as a toast.
export function usePinDashboard(): UsePinDashboardResult {
const queryClient = useQueryClient();
const invalidate = useCallback((): void => {
void invalidateListDashboardsForUserV2(queryClient);
}, [queryClient]);
const pin = usePinDashboardV2({
mutation: {
onSuccess: invalidate,
onError: (error): void => {
toast.error(
getHttpStatusCode(error) === 409
? PIN_LIMIT_MESSAGE
: 'Failed to pin dashboard.',
);
},
},
});
const unpin = useUnpinDashboardV2({
mutation: {
onSuccess: invalidate,
onError: (): void => {
toast.error('Failed to unpin dashboard.');
},
},
});
const togglePin = useCallback(
(id: string, pinned: boolean): void => {
if (pinned) {
unpin.mutate({ pathParams: { id } });
} else {
pin.mutate({ pathParams: { id } });
}
},
[pin, unpin],
);
return { togglePin, isUpdating: pin.isLoading || unpin.isLoading };
}

View File

@@ -0,0 +1,117 @@
import { useCallback, useMemo } from 'react';
import { useQueryClient } from 'react-query';
import { toast } from '@signozhq/ui/sonner';
import {
invalidateListDashboardViews,
useCreateDashboardView,
useDeleteDashboardView,
useListDashboardViews,
useUpdateDashboardView,
} from 'api/generated/services/dashboard';
import {
type DashboardtypesDashboardViewDTO,
DashboardtypesListOrderDTO,
DashboardtypesListSortDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { SavedView, SavedViewInput } from '../types';
// Schema version stamped on the view's data envelope (the backend requires it).
const VIEW_DATA_VERSION = 'v1';
const toSavedView = (dto: DashboardtypesDashboardViewDTO): SavedView => ({
id: dto.id,
name: dto.name,
query: dto.data.query ?? '',
sort: dto.data.sort ?? DashboardtypesListSortDTO.updated_at,
order: dto.data.order ?? DashboardtypesListOrderDTO.desc,
});
const toPostable = (
input: SavedViewInput,
): { name: string; data: DashboardtypesDashboardViewDTO['data'] } => ({
name: input.name.trim(),
data: {
version: VIEW_DATA_VERSION,
query: input.query,
sort: input.sort,
order: input.order,
},
});
export interface UseSavedViewsResult {
views: SavedView[];
isLoading: boolean;
createView: (input: SavedViewInput) => Promise<SavedView | null>;
updateView: (id: string, input: SavedViewInput) => void;
deleteView: (id: string) => void;
}
// Org-shared saved views, backed by the Views API. Exposes the list plus
// create/update/delete that invalidate the list on success.
export function useSavedViews(): UseSavedViewsResult {
const queryClient = useQueryClient();
const { data, isLoading } = useListDashboardViews();
const views = useMemo<SavedView[]>(
() => (data?.data?.views ?? []).map(toSavedView),
[data],
);
const invalidate = useCallback((): void => {
void invalidateListDashboardViews(queryClient);
}, [queryClient]);
const createMutation = useCreateDashboardView({
mutation: {
onSuccess: invalidate,
onError: (): void => {
toast.error('Failed to save view.');
},
},
});
const updateMutation = useUpdateDashboardView({
mutation: {
onSuccess: invalidate,
onError: (): void => {
toast.error('Failed to update view.');
},
},
});
const deleteMutation = useDeleteDashboardView({
mutation: {
onSuccess: invalidate,
onError: (): void => {
toast.error('Failed to delete view.');
},
},
});
const createView = useCallback(
async (input: SavedViewInput): Promise<SavedView | null> => {
try {
const res = await createMutation.mutateAsync({ data: toPostable(input) });
return res?.data ? toSavedView(res.data) : null;
} catch {
return null;
}
},
[createMutation],
);
const updateView = useCallback(
(id: string, input: SavedViewInput): void => {
updateMutation.mutate({ pathParams: { id }, data: toPostable(input) });
},
[updateMutation],
);
const deleteView = useCallback(
(id: string): void => {
deleteMutation.mutate({ pathParams: { id } });
},
[deleteMutation],
);
return { views, isLoading, createView, updateView, deleteView };
}

View File

@@ -2,30 +2,21 @@ import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { LOCALSTORAGE } from 'constants/localStorage';
import type { SavedView } from '../types';
// Most-recently-viewed list is capped so it stays a useful shortlist.
const RECENT_LIMIT = 20;
// Client-side persistence for everything the views feature owns until the views
// API lands: user-saved views, favorite/recently-viewed dashboard ids, and the
// rail collapse preference. Mirrors `useDashboardsListVisibleColumnsStore`.
// Client-side persistence for the parts of the views feature that aren't backed
// by an API: recently-viewed dashboard ids and the rail collapse preference.
// (Saved views are org-shared via the Views API — see `useSavedViews`; pinning
// is server-side per-user — see `usePinDashboard`.)
interface DashboardViewsState {
customViews: SavedView[];
favorites: string[]; // dashboard ids
recent: string[]; // dashboard ids, most-recent first
railCollapsed: boolean;
addView: (view: SavedView) => void;
updateView: (id: string, patch: Partial<Omit<SavedView, 'id'>>) => void;
deleteView: (id: string) => void;
toggleFavorite: (id: string) => void;
markViewed: (id: string) => void;
setRailCollapsed: (collapsed: boolean) => void;
}
const DEFAULT_STATE = {
customViews: [] as SavedView[],
favorites: [] as string[],
recent: [] as string[],
railCollapsed: false,
};
@@ -34,26 +25,6 @@ export const useDashboardViewsStore = create<DashboardViewsState>()(
persist(
(set) => ({
...DEFAULT_STATE,
addView: (view): void => {
set((s) => ({ customViews: [...s.customViews, view] }));
},
updateView: (id, patch): void => {
set((s) => ({
customViews: s.customViews.map((v) =>
v.id === id ? { ...v, ...patch } : v,
),
}));
},
deleteView: (id): void => {
set((s) => ({ customViews: s.customViews.filter((v) => v.id !== id) }));
},
toggleFavorite: (id): void => {
set((s) => ({
favorites: s.favorites.includes(id)
? s.favorites.filter((f) => f !== id)
: [...s.favorites, id],
}));
},
markViewed: (id): void => {
set((s) => ({
recent: [id, ...s.recent.filter((r) => r !== id)].slice(0, RECENT_LIMIT),

View File

@@ -1,27 +1,52 @@
import type {
DashboardtypesListOrderDTO,
DashboardtypesListSortDTO,
} from 'api/generated/services/sigNoz.schemas';
// Relative "updated within" windows offered by the Updated filter chip.
export type UpdatedWindow = 'any' | 'today' | '7d' | '30d';
// The user-controllable filter state a view captures. (Tags are intentionally
// excluded for now — the tag filter UI is deferred.) Sort/order are handled
// separately via URL query params and are not part of a view snapshot.
// A tag selected in the Tags filter chip — a concrete key:value pair drawn from
// the tags the list API reports across the org's dashboards.
export interface SelectedTag {
key: string;
value: string;
}
// The user-controllable filter state a view captures. `search` is a raw filter
// DSL fragment the user types; the structured chips (created-by, updated, tags)
// are AND-ed onto it. Sort/order are handled separately via URL query params and
// are not part of a view snapshot.
export interface DashboardFilterState {
search: string;
createdBy: string[]; // emails (created_by)
updated: UpdatedWindow;
tags: SelectedTag[];
}
// A saved view: a named, iconed snapshot of filter state. Persisted client-side
// (localStorage) until the views API lands.
// A saved view: a named filter the org shares, persisted via the backend Views
// API. The backend stores a flat `{ query, sort, order }` (no structured chips),
// so a view captures the fully-combined DSL query plus the sort/order to apply.
export interface SavedView {
id: string;
name: string;
icon: string; // @signozhq/icons icon name
filters: DashboardFilterState;
createdAt: number;
query: string;
sort: DashboardtypesListSortDTO;
order: DashboardtypesListOrderDTO;
}
// The payload for creating or updating a saved view (everything but the id).
export type SavedViewInput = Omit<SavedView, 'id'>;
// Built-in views rendered above the user's saved views. Their result set is
// derived (a fixed query fragment or a client-side id set), never persisted.
export type BuiltinViewId = 'mine' | 'favorites' | 'recent' | 'all' | 'locked';
// String values double as the URL `view` param, so they must stay stable.
export enum BuiltinViewId {
Mine = 'mine',
Pinned = 'pinned',
Recent = 'recent',
All = 'all',
Locked = 'locked',
}
export type ViewSection = 'personal' | 'system' | 'custom';

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