mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-30 11:50:43 +01:00
Compare commits
3 Commits
platform-p
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2f048770e | ||
|
|
0711786701 | ||
|
|
aeda0a5144 |
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,3 +2,11 @@
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.permission {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.permissionCode {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
164
frontend/src/components/TagKeyValueInput/TagKeyValueInput.tsx
Normal file
164
frontend/src/components/TagKeyValueInput/TagKeyValueInput.tsx
Normal 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;
|
||||
31
frontend/src/components/TagKeyValueInput/utils.test.ts
Normal file
31
frontend/src/components/TagKeyValueInput/utils.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
17
frontend/src/components/TagKeyValueInput/utils.ts
Normal file
17
frontend/src/components/TagKeyValueInput/utils.ts
Normal 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}`;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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 don’t 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>
|
||||
|
||||
@@ -105,3 +105,8 @@
|
||||
height: 1px;
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
.errorInPlaceContainer {
|
||||
border-color: var(--callout-error-border) !important;
|
||||
background: var(--callout-error-background) !important;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.resourceCardError {
|
||||
border-color: var(--destructive);
|
||||
}
|
||||
|
||||
.resourceCardHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -3,3 +3,14 @@
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.thresholdField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.thresholdPrefix {
|
||||
padding-right: 4px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@@ -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 })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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}>></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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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'],
|
||||
})),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>>;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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). */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/-');
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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\//, '');
|
||||
}
|
||||
|
||||
@@ -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'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>
|
||||
|
||||
@@ -78,6 +78,11 @@ function DeleteActionItem({
|
||||
runDelete(undefined, { onSettled: () => destroy() });
|
||||
},
|
||||
},
|
||||
cancelButtonProps: {
|
||||
onClick: (e): void => {
|
||||
e.stopPropagation();
|
||||
},
|
||||
},
|
||||
centered: true,
|
||||
});
|
||||
}, [modal, dashboardName, runDelete]);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
117
frontend/src/pages/DashboardsListPageV2/hooks/useSavedViews.ts
Normal file
117
frontend/src/pages/DashboardsListPageV2/hooks/useSavedViews.ts
Normal 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 };
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
applyKeySuggestion,
|
||||
buildSuggestionKeys,
|
||||
getActiveKeyToken,
|
||||
matchKeys,
|
||||
RESERVED_DSL_KEYS,
|
||||
} from './dslSuggestions';
|
||||
|
||||
describe('getActiveKeyToken', () => {
|
||||
it('returns the partial key at the start', () => {
|
||||
expect(getActiveKeyToken('nam')).toStrictEqual({ token: 'nam', start: 0 });
|
||||
});
|
||||
|
||||
it('returns the partial key after AND', () => {
|
||||
const value = 'name = "x" AND en';
|
||||
expect(getActiveKeyToken(value)).toStrictEqual({ token: 'en', start: 15 });
|
||||
});
|
||||
|
||||
it('is null once an operator (space) has been typed', () => {
|
||||
expect(getActiveKeyToken('name contains')).toBeNull();
|
||||
});
|
||||
|
||||
it('is null for an empty trailing segment', () => {
|
||||
expect(getActiveKeyToken('name = "x" AND ')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSuggestionKeys', () => {
|
||||
it('lists reserved keys plus distinct tag keys', () => {
|
||||
const keys = buildSuggestionKeys([
|
||||
{ key: 'env', value: 'prod' },
|
||||
{ key: 'env', value: 'dev' },
|
||||
{ key: 'team', value: 'core' },
|
||||
]);
|
||||
expect(keys).toStrictEqual([...RESERVED_DSL_KEYS, 'env', 'team']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchKeys', () => {
|
||||
it('matches case-insensitively and excludes exact matches', () => {
|
||||
expect(matchKeys(['name', 'created_by', 'env'], 'NAM')).toStrictEqual([
|
||||
'name',
|
||||
]);
|
||||
expect(matchKeys(['name'], 'name')).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyKeySuggestion', () => {
|
||||
it('replaces the partial key with the chosen key and a trailing space', () => {
|
||||
const value = 'name = "x" AND en';
|
||||
const active = getActiveKeyToken(value);
|
||||
if (!active) {
|
||||
throw new Error('expected an active key token');
|
||||
}
|
||||
expect(applyKeySuggestion(value, active, 'env')).toBe('name = "x" AND env ');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
// Key-name suggestions for the dashboards-list DSL search box. The reserved keys
|
||||
// mirror the backend filter DSL (pkg/.../listfilter_visitor.go); any other key is
|
||||
// treated as a tag key, so we also surface the tag keys the list API reports.
|
||||
import type { SelectedTag } from '../types';
|
||||
|
||||
// Reserved DSL keys the backend recognises as dashboard columns.
|
||||
export const RESERVED_DSL_KEYS: string[] = [
|
||||
'name',
|
||||
'description',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'created_by',
|
||||
'locked',
|
||||
'source',
|
||||
];
|
||||
|
||||
export interface ActiveKeyToken {
|
||||
token: string;
|
||||
// Index in the value string where the partial key begins.
|
||||
start: number;
|
||||
}
|
||||
|
||||
// The partial key the user is currently typing: the trailing segment after the
|
||||
// last top-level AND/OR (or the start), provided it hasn't yet reached an
|
||||
// operator (no whitespace). Returns null once the key is complete.
|
||||
export const getActiveKeyToken = (value: string): ActiveKeyToken | null => {
|
||||
const boundaryRe = /\b(?:AND|OR)\b/gi;
|
||||
let lastEnd = 0;
|
||||
let match = boundaryRe.exec(value);
|
||||
while (match !== null) {
|
||||
lastEnd = match.index + match[0].length;
|
||||
match = boundaryRe.exec(value);
|
||||
}
|
||||
const segment = value.slice(lastEnd);
|
||||
const leading = segment.length - segment.trimStart().length;
|
||||
const partial = segment.slice(leading);
|
||||
if (partial.length === 0 || /[\s(]/.test(partial)) {
|
||||
return null;
|
||||
}
|
||||
return { token: partial, start: lastEnd + leading };
|
||||
};
|
||||
|
||||
// Build the de-duplicated, ordered list of keys to offer: reserved columns plus
|
||||
// distinct tag keys from the list response.
|
||||
export const buildSuggestionKeys = (availableTags: SelectedTag[]): string[] => {
|
||||
const tagKeys = availableTags.map((t) => t.key);
|
||||
return Array.from(new Set([...RESERVED_DSL_KEYS, ...tagKeys]));
|
||||
};
|
||||
|
||||
// Keys matching the partial token (case-insensitive), excluding an exact match.
|
||||
export const matchKeys = (
|
||||
keys: string[],
|
||||
token: string,
|
||||
limit = 8,
|
||||
): string[] => {
|
||||
const lower = token.toLowerCase();
|
||||
return keys
|
||||
.filter((k) => k.toLowerCase().includes(lower) && k.toLowerCase() !== lower)
|
||||
.slice(0, limit);
|
||||
};
|
||||
|
||||
// Replace the active partial key in `value` with the chosen key + a space, ready
|
||||
// for the user to type an operator.
|
||||
export const applyKeySuggestion = (
|
||||
value: string,
|
||||
active: ActiveKeyToken,
|
||||
key: string,
|
||||
): string => `${value.slice(0, active.start)}${key} `;
|
||||
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
areFilterStatesEqual,
|
||||
combineQueries,
|
||||
DEFAULT_FILTER_STATE,
|
||||
filterStateToQuery,
|
||||
isFilterStateEmpty,
|
||||
} from './filterQuery';
|
||||
import type { DashboardFilterState } from '../types';
|
||||
|
||||
const state = (patch: Partial<DashboardFilterState>): DashboardFilterState => ({
|
||||
...DEFAULT_FILTER_STATE,
|
||||
...patch,
|
||||
});
|
||||
|
||||
describe('filterStateToQuery', () => {
|
||||
it('passes the raw search through, wrapped in parentheses', () => {
|
||||
expect(filterStateToQuery(state({ search: 'name contains "prod"' }))).toBe(
|
||||
'(name contains "prod")',
|
||||
);
|
||||
});
|
||||
|
||||
it('emits an equality clause for a single creator', () => {
|
||||
expect(filterStateToQuery(state({ createdBy: ['a@b.com'] }))).toBe(
|
||||
"created_by = 'a@b.com'",
|
||||
);
|
||||
});
|
||||
|
||||
it('emits an IN clause for multiple creators', () => {
|
||||
expect(filterStateToQuery(state({ createdBy: ['a@b.com', 'c@d.com'] }))).toBe(
|
||||
"created_by IN ['a@b.com', 'c@d.com']",
|
||||
);
|
||||
});
|
||||
|
||||
it('emits an exact equality clause per selected tag', () => {
|
||||
expect(
|
||||
filterStateToQuery(
|
||||
state({
|
||||
tags: [
|
||||
{ key: 'env', value: 'prod' },
|
||||
{ key: 'team', value: 'core' },
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toBe("env = 'prod' AND team = 'core'");
|
||||
});
|
||||
|
||||
it('ANDs raw search with the structured chips', () => {
|
||||
expect(
|
||||
filterStateToQuery(
|
||||
state({
|
||||
search: 'name contains "x"',
|
||||
createdBy: ['a@b.com'],
|
||||
tags: [{ key: 'env', value: 'prod' }],
|
||||
}),
|
||||
),
|
||||
).toBe("(name contains \"x\") AND created_by = 'a@b.com' AND env = 'prod'");
|
||||
});
|
||||
|
||||
it('returns an empty string for the default state', () => {
|
||||
expect(filterStateToQuery(DEFAULT_FILTER_STATE)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFilterStateEmpty', () => {
|
||||
it('is true for the default state', () => {
|
||||
expect(isFilterStateEmpty(DEFAULT_FILTER_STATE)).toBe(true);
|
||||
});
|
||||
|
||||
it('is false when any tag is selected', () => {
|
||||
expect(
|
||||
isFilterStateEmpty(state({ tags: [{ key: 'env', value: 'prod' }] })),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('areFilterStatesEqual', () => {
|
||||
it('ignores tag ordering', () => {
|
||||
const a = state({
|
||||
tags: [
|
||||
{ key: 'env', value: 'prod' },
|
||||
{ key: 'team', value: 'core' },
|
||||
],
|
||||
});
|
||||
const b = state({
|
||||
tags: [
|
||||
{ key: 'team', value: 'core' },
|
||||
{ key: 'env', value: 'prod' },
|
||||
],
|
||||
});
|
||||
expect(areFilterStatesEqual(a, b)).toBe(true);
|
||||
});
|
||||
|
||||
it('distinguishes differing tag selections', () => {
|
||||
expect(
|
||||
areFilterStatesEqual(
|
||||
state({ tags: [{ key: 'env', value: 'prod' }] }),
|
||||
state({ tags: [{ key: 'env', value: 'dev' }] }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('combineQueries', () => {
|
||||
it('drops empty fragments and ANDs the rest', () => {
|
||||
expect(combineQueries('locked = true', '', undefined, 'name = "x"')).toBe(
|
||||
'locked = true AND name = "x"',
|
||||
);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user