mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-07 23:20:37 +01:00
Compare commits
9 Commits
ci/better-
...
fix/dashbo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7136a51fdc | ||
|
|
3b1c1c401f | ||
|
|
58aec92161 | ||
|
|
4e1c77cab1 | ||
|
|
14694f55e1 | ||
|
|
db4219e0f1 | ||
|
|
165c945511 | ||
|
|
ff19aedaa8 | ||
|
|
ef8319585b |
@@ -79,6 +79,7 @@
|
||||
"event-source-polyfill": "1.0.31",
|
||||
"eventemitter3": "5.0.1",
|
||||
"history": "4.10.1",
|
||||
"html-to-image": "1.11.13",
|
||||
"http-status-codes": "2.3.0",
|
||||
"i18next": "^21.6.12",
|
||||
"i18next-browser-languagedetector": "^6.1.3",
|
||||
|
||||
8
frontend/pnpm-lock.yaml
generated
8
frontend/pnpm-lock.yaml
generated
@@ -164,6 +164,9 @@ importers:
|
||||
history:
|
||||
specifier: 4.10.1
|
||||
version: 4.10.1
|
||||
html-to-image:
|
||||
specifier: 1.11.13
|
||||
version: 1.11.13
|
||||
http-status-codes:
|
||||
specifier: 2.3.0
|
||||
version: 2.3.0
|
||||
@@ -5451,6 +5454,9 @@ packages:
|
||||
resolution: {integrity: sha512-n6l5uca7/y5joxZ3LUePhzmBFUJ+U2YWzhMa8XUTecSeSlQiZdF5XAd/Q3/WUl0VsXgUwWi8I7CNIwdI5WN1SQ==}
|
||||
engines: {node: '>=20.10'}
|
||||
|
||||
html-to-image@1.11.13:
|
||||
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
|
||||
|
||||
html-void-elements@3.0.0:
|
||||
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
|
||||
|
||||
@@ -14495,6 +14501,8 @@ snapshots:
|
||||
|
||||
html-tags@5.1.0: {}
|
||||
|
||||
html-to-image@1.11.13: {}
|
||||
|
||||
html-void-elements@3.0.0: {}
|
||||
|
||||
http-proxy-agent@5.0.0:
|
||||
|
||||
@@ -62,6 +62,6 @@
|
||||
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
|
||||
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
|
||||
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
|
||||
"LLM_OBSERVABILITY_BASE": "SigNoz | LLM Observability",
|
||||
"LLM_OBSERVABILITY_MODEL_PRICING": "SigNoz | Model Pricing"
|
||||
}
|
||||
"LLM_OBSERVABILITY_OVERVIEW": "SigNoz | LLM Observability Overview",
|
||||
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration"
|
||||
}
|
||||
@@ -87,6 +87,6 @@
|
||||
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
|
||||
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
|
||||
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
|
||||
"LLM_OBSERVABILITY_BASE": "SigNoz | LLM Observability",
|
||||
"LLM_OBSERVABILITY_MODEL_PRICING": "SigNoz | Model Pricing"
|
||||
}
|
||||
"LLM_OBSERVABILITY_OVERVIEW": "SigNoz | LLM Observability Overview",
|
||||
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration"
|
||||
}
|
||||
@@ -329,10 +329,3 @@ export const LLMObservabilityPage = Loadable(
|
||||
/* webpackChunkName: "LLM Observability Page" */ 'pages/LLMObservability'
|
||||
),
|
||||
);
|
||||
|
||||
export const LLMObservabilityModelPricingPage = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "LLM Observability Model Pricing Page" */ 'pages/LLMObservabilityModelPricing'
|
||||
),
|
||||
);
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
LicensePage,
|
||||
ListAllALertsPage,
|
||||
LLMObservabilityPage,
|
||||
LLMObservabilityModelPricingPage,
|
||||
LiveLogs,
|
||||
Login,
|
||||
Logs,
|
||||
@@ -515,17 +514,17 @@ const routes: AppRoutes[] = [
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.LLM_OBSERVABILITY_BASE,
|
||||
path: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
|
||||
exact: true,
|
||||
component: LLMObservabilityPage,
|
||||
key: 'LLM_OBSERVABILITY_BASE',
|
||||
key: 'LLM_OBSERVABILITY_OVERVIEW',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.LLM_OBSERVABILITY_MODEL_PRICING,
|
||||
path: ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
|
||||
exact: true,
|
||||
component: LLMObservabilityModelPricingPage,
|
||||
key: 'LLM_OBSERVABILITY_MODEL_PRICING',
|
||||
component: LLMObservabilityPage,
|
||||
key: 'LLM_OBSERVABILITY_CONFIGURATION',
|
||||
isPrivate: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -90,7 +90,8 @@ const ROUTES = {
|
||||
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
|
||||
MCP_SERVER: '/settings/mcp-server',
|
||||
LLM_OBSERVABILITY_BASE: '/llm-observability',
|
||||
LLM_OBSERVABILITY_MODEL_PRICING: '/llm-observability/settings/model-pricing',
|
||||
LLM_OBSERVABILITY_OVERVIEW: '/llm-observability/overview',
|
||||
LLM_OBSERVABILITY_CONFIGURATION: '/llm-observability/configuration',
|
||||
} as const;
|
||||
|
||||
export default ROUTES;
|
||||
|
||||
@@ -1,27 +1,7 @@
|
||||
.llmObservability {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12) var(--spacing-16);
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.pageHeaderTitle {
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: var(--spacing-2) 0 0;
|
||||
color: var(--text-vanilla-400);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
height: 100%;
|
||||
margin-top: var(--spacing-2);
|
||||
margin-left: var(--spacing-2);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { Tabs } from '@signozhq/ui/tabs';
|
||||
|
||||
import { useLLMObservabilityTabs } from './hooks/useLLMObservabilityTabs';
|
||||
import styles from './LLMObservability.module.scss';
|
||||
|
||||
// Shell for the LLM Observability page: renders the top-level tab bar
|
||||
// (Overview / Configuration) using the SigNoz design-system Tabs, with
|
||||
// route-driven active state from useLLMObservabilityTabs.
|
||||
function LLMObservability(): JSX.Element {
|
||||
const { items, activeTab, onTabChange } = useLLMObservabilityTabs();
|
||||
|
||||
return (
|
||||
<div className={styles.llmObservability} data-testid="llm-observability-page">
|
||||
<header className={styles.pageHeader}>
|
||||
<div className={styles.pageHeaderTitle}>
|
||||
<h1 className={styles.title}>LLM Observability</h1>
|
||||
<p className={styles.subtitle}>
|
||||
Monitor and analyze your LLM usage, costs, and performance
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
<Tabs
|
||||
items={items}
|
||||
value={activeTab}
|
||||
onChange={onTabChange}
|
||||
testId="llm-observability-tabs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
.overview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12) var(--spacing-16);
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.pageHeaderTitle {
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: var(--spacing-2) 0 0;
|
||||
color: var(--text-vanilla-400);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import styles from './Overview.module.scss';
|
||||
|
||||
// Overview tab content for LLM Observability. Currently the feature's landing
|
||||
// surface; usage/cost/performance widgets land in later PRs.
|
||||
function Overview(): JSX.Element {
|
||||
return (
|
||||
<div className={styles.overview} data-testid="llm-observability-overview">
|
||||
<header className={styles.pageHeader}>
|
||||
<div className={styles.pageHeaderTitle}>
|
||||
<h1 className={styles.title}>LLM Observability</h1>
|
||||
<p className={styles.subtitle}>
|
||||
Monitor and analyze your LLM usage, costs, and performance
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Overview;
|
||||
@@ -2,29 +2,4 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12) var(--spacing-16);
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.pageHeaderTitle {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: var(--spacing-2) 0 0;
|
||||
color: var(--text-vanilla-400);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Tabs } from '@signozhq/ui/tabs';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import ModelCostTabPanel from './ModelCostTabPanel';
|
||||
import styles from './LLMObservabilityModelPricing.module.scss';
|
||||
@@ -10,20 +9,9 @@ function LLMObservabilityModelPricing(): JSX.Element {
|
||||
className={styles.llmObservabilityModelPricing}
|
||||
data-testid="llm-observability-model-pricing-page"
|
||||
>
|
||||
<header className={styles.pageHeader}>
|
||||
<div className={styles.pageHeaderTitle}>
|
||||
<Typography.Text as="h1" size="large" weight="semibold">
|
||||
Configuration
|
||||
</Typography.Text>
|
||||
<Typography.Text color="muted">
|
||||
Model pricing and cost estimation settings
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Tabs
|
||||
// Model costs is the only enabled tab for now, so default to it. When
|
||||
// the unpriced-models tab lands, this can become a URL-backed param.
|
||||
// the unpriced-models tab lands in a later PR.
|
||||
defaultValue="model-costs"
|
||||
items={[
|
||||
{
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useMemo } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Plus, Search, X } from '@signozhq/icons';
|
||||
import { useListLLMPricingRules } from 'api/generated/services/llmpricingrules';
|
||||
import { type ListLLMPricingRulesParams } from 'api/generated/services/sigNoz.schemas';
|
||||
@@ -161,12 +160,6 @@ function ModelCostTabPanel(): JSX.Element {
|
||||
onDelete={deletion.requestDelete}
|
||||
/>
|
||||
|
||||
<footer>
|
||||
<Typography.Text color="muted" size="small">
|
||||
All prices per 1M tokens (USD)
|
||||
</Typography.Text>
|
||||
</footer>
|
||||
|
||||
{drawer.isOpen && (
|
||||
<ModelCostDrawer
|
||||
isOpen={drawer.isOpen}
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
import { LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
TOAST_MODEL_COST_DELETED,
|
||||
TOAST_MODEL_COST_UPDATED,
|
||||
} from 'container/LLMObservability/Settings/ModelPricing/constants';
|
||||
import {
|
||||
LLM_PRICING_ENDPOINT,
|
||||
LLM_PRICING_RULE_ENDPOINT,
|
||||
makeListResponse,
|
||||
mockRules,
|
||||
} from 'container/LLMObservability/Settings/ModelPricing/__tests__/fixtures';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
|
||||
import ModelCostTabPanel from '../ModelCostTabPanel';
|
||||
|
||||
const toastSuccess = jest.fn();
|
||||
const toastError = jest.fn();
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: {
|
||||
success: (...args: unknown[]): void => toastSuccess(...args),
|
||||
error: (...args: unknown[]): void => toastError(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
function setupList(items = mockRules, total = items.length): void {
|
||||
server.use(
|
||||
rest.get(LLM_PRICING_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(makeListResponse(items, total))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function resetUrl(): void {
|
||||
window.history.pushState(null, '', '/');
|
||||
}
|
||||
|
||||
async function openRowMenu(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
ruleId: string,
|
||||
): Promise<void> {
|
||||
const row = screen.getByTestId(`model-cell-name-${ruleId}`).closest('tr');
|
||||
await user.click(within(row as HTMLElement).getByRole('button'));
|
||||
}
|
||||
|
||||
describe('ModelCostTabPanel (integration)', () => {
|
||||
beforeEach(() => {
|
||||
resetUrl();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders pricing rules returned by the list API', async () => {
|
||||
setupList();
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
const openaiCell = await screen.findByTestId('model-cell-name-rule-openai');
|
||||
expect(openaiCell).toHaveTextContent('gpt-4o');
|
||||
expect(
|
||||
screen.getByTestId('model-cell-name-rule-anthropic'),
|
||||
).toHaveTextContent('claude-3-5-sonnet');
|
||||
// Canonical id under the model name + provider column.
|
||||
expect(screen.getByText('openai:gpt-4o')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('OpenAI').length).toBeGreaterThan(0);
|
||||
// Source badges reflect the override flag.
|
||||
expect(screen.getByTestId('source-badge-rule-openai')).toHaveTextContent(
|
||||
'User override',
|
||||
);
|
||||
expect(screen.getByTestId('source-badge-rule-anthropic')).toHaveTextContent(
|
||||
'Auto',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the empty state when there are no rules', async () => {
|
||||
setupList([], 0);
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
const empty = await screen.findByTestId('model-costs-empty');
|
||||
expect(empty).toHaveTextContent('No model costs yet.');
|
||||
});
|
||||
|
||||
it('shows an error message when the list request fails', async () => {
|
||||
server.use(
|
||||
rest.get(LLM_PRICING_ENDPOINT, (_req, res, ctx) => res(ctx.status(500))),
|
||||
);
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(alert).toHaveTextContent(
|
||||
'Failed to load pricing rules. Please try again.',
|
||||
);
|
||||
});
|
||||
|
||||
it('sends the debounced search term as the q param', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
let lastParams: URLSearchParams | null = null;
|
||||
server.use(
|
||||
rest.get(LLM_PRICING_ENDPOINT, (req, res, ctx) => {
|
||||
lastParams = req.url.searchParams;
|
||||
return res(ctx.status(200), ctx.json(makeListResponse(mockRules)));
|
||||
}),
|
||||
);
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
await screen.findByTestId('model-cell-name-rule-openai');
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('Search by model or provider'),
|
||||
'claude',
|
||||
);
|
||||
|
||||
await waitFor(() => expect(lastParams?.get('q')).toBe('claude'));
|
||||
});
|
||||
|
||||
it('clears the search via the clear button', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupList();
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
const input = screen.getByPlaceholderText(
|
||||
'Search by model or provider',
|
||||
) as HTMLInputElement;
|
||||
await user.type(input, 'gpt');
|
||||
expect(input.value).toBe('gpt');
|
||||
|
||||
await user.click(screen.getByTestId('model-cost-search-clear'));
|
||||
await waitFor(() => expect(input.value).toBe(''));
|
||||
});
|
||||
|
||||
it('sends isOverride=true when the source filter is set to User override', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
let lastParams: URLSearchParams | null = null;
|
||||
server.use(
|
||||
rest.get(LLM_PRICING_ENDPOINT, (req, res, ctx) => {
|
||||
lastParams = req.url.searchParams;
|
||||
return res(ctx.status(200), ctx.json(makeListResponse(mockRules)));
|
||||
}),
|
||||
);
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
await screen.findByTestId('model-cell-name-rule-openai');
|
||||
await user.click(screen.getByTestId('source-filter'));
|
||||
// Scope to the listbox option — "User override" also appears as a row badge.
|
||||
await user.click(
|
||||
await screen.findByRole('option', { name: 'User override' }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(lastParams?.get('isOverride')).toBe('true'));
|
||||
});
|
||||
|
||||
it('opens the add drawer for a manager (ADMIN)', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupList();
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
await screen.findByTestId('model-cell-name-rule-openai');
|
||||
await user.click(screen.getByTestId('add-model-cost-btn'));
|
||||
|
||||
const modelInput = await screen.findByTestId('drawer-model-id-input');
|
||||
expect(modelInput).toBeInTheDocument();
|
||||
expect(screen.getByTestId('drawer-save-btn')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the add button and row actions for a viewer', async () => {
|
||||
setupList();
|
||||
render(<ModelCostTabPanel />, undefined, { role: 'VIEWER' });
|
||||
|
||||
const row = (
|
||||
await screen.findByTestId('model-cell-name-rule-openai')
|
||||
).closest('tr') as HTMLElement;
|
||||
expect(screen.queryByTestId('add-model-cost-btn')).not.toBeInTheDocument();
|
||||
// View-only rows render no action menu (no buttons in the row).
|
||||
expect(within(row).queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the edit drawer prefilled from the row action menu', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupList();
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
await screen.findByTestId('model-cell-name-rule-openai');
|
||||
await openRowMenu(user, 'rule-openai');
|
||||
await user.click(await screen.findByText('Edit'));
|
||||
|
||||
const drawerTitle = await screen.findByText('Edit model cost');
|
||||
expect(drawerTitle).toBeInTheDocument();
|
||||
const modelInput = screen.getByTestId(
|
||||
'drawer-model-id-input',
|
||||
) as HTMLInputElement;
|
||||
expect(modelInput.value).toBe('gpt-4o');
|
||||
expect(modelInput).toBeDisabled();
|
||||
});
|
||||
|
||||
it('deletes a rule through the confirm dialog', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
let deletedId: string | null = null;
|
||||
setupList();
|
||||
server.use(
|
||||
rest.delete(LLM_PRICING_RULE_ENDPOINT, (req, res, ctx) => {
|
||||
deletedId = req.params.id as string;
|
||||
return res(ctx.status(200), ctx.json({ status: 'success' }));
|
||||
}),
|
||||
);
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
await screen.findByTestId('model-cell-name-rule-openai');
|
||||
await openRowMenu(user, 'rule-openai');
|
||||
await user.click(await screen.findByText('Delete'));
|
||||
|
||||
await user.click(await screen.findByTestId('drawer-delete-confirm-btn'));
|
||||
|
||||
await waitFor(() => expect(deletedId).toBe('rule-openai'));
|
||||
await waitFor(() =>
|
||||
expect(toastSuccess).toHaveBeenCalledWith(TOAST_MODEL_COST_DELETED),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders cache buckets for rules that have cache pricing', async () => {
|
||||
setupList();
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
const anthropicRow = (
|
||||
await screen.findByTestId('model-cell-name-rule-anthropic')
|
||||
).closest('tr') as HTMLElement;
|
||||
expect(within(anthropicRow).getByText(/Cache Read/i)).toBeInTheDocument();
|
||||
expect(within(anthropicRow).getByText(/Cache Write/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('formats per-million prices in the row', async () => {
|
||||
setupList();
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
const openaiRow = (
|
||||
await screen.findByTestId('model-cell-name-rule-openai')
|
||||
).closest('tr') as HTMLElement;
|
||||
// mockRules gpt-4o has input cost 3 → rendered as $3.00.
|
||||
expect(within(openaiRow).getByText('$3.00')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends a normalized create payload when adding a rule', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
let body: Record<string, unknown> | null = null;
|
||||
setupList();
|
||||
server.use(
|
||||
rest.put(LLM_PRICING_ENDPOINT, async (req, res, ctx) => {
|
||||
body = await req.json();
|
||||
return res(ctx.status(200), ctx.json({ status: 'success' }));
|
||||
}),
|
||||
);
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
await screen.findByTestId('model-cell-name-rule-openai');
|
||||
await user.click(screen.getByTestId('add-model-cost-btn'));
|
||||
|
||||
// Leading/trailing whitespace should be trimmed off the model id.
|
||||
await user.type(
|
||||
await screen.findByTestId('drawer-model-id-input'),
|
||||
' gpt-4o-mini ',
|
||||
);
|
||||
await user.type(screen.getByTestId('drawer-input-cost'), '3');
|
||||
await user.type(screen.getByTestId('drawer-output-cost'), '9');
|
||||
await user.click(screen.getByTestId('drawer-save-btn'));
|
||||
|
||||
await waitFor(() => expect(body).not.toBeNull());
|
||||
// The create call submits a bulk `rules` array of normalized payloads.
|
||||
const [payload] = (
|
||||
body as unknown as {
|
||||
rules: Record<string, unknown>[];
|
||||
}
|
||||
).rules;
|
||||
expect(payload).toMatchObject({
|
||||
modelName: 'gpt-4o-mini',
|
||||
provider: 'OpenAI',
|
||||
isOverride: true,
|
||||
enabled: true,
|
||||
unit: UnitDTO.per_million_tokens,
|
||||
pricing: { input: 3, output: 9 },
|
||||
});
|
||||
});
|
||||
|
||||
it('sends an updated payload when editing a rule', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
let body: Record<string, unknown> | null = null;
|
||||
setupList();
|
||||
server.use(
|
||||
rest.put(LLM_PRICING_ENDPOINT, async (req, res, ctx) => {
|
||||
body = await req.json();
|
||||
return res(ctx.status(200), ctx.json({ status: 'success' }));
|
||||
}),
|
||||
);
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
await screen.findByTestId('model-cell-name-rule-openai');
|
||||
await openRowMenu(user, 'rule-openai');
|
||||
await user.click(await screen.findByText('Edit'));
|
||||
|
||||
// Model id + provider are locked in edit mode; change the prefilled input cost.
|
||||
const inputCost = await screen.findByTestId('drawer-input-cost');
|
||||
await user.clear(inputCost);
|
||||
await user.type(inputCost, '5');
|
||||
await user.click(screen.getByTestId('drawer-save-btn'));
|
||||
|
||||
await waitFor(() => expect(body).not.toBeNull());
|
||||
const [payload] = (
|
||||
body as unknown as {
|
||||
rules: Record<string, unknown>[];
|
||||
}
|
||||
).rules;
|
||||
// Edit carries the rule id; disabled model/provider are still submitted and
|
||||
// the edited price flows through, while output keeps its prefilled value.
|
||||
expect(payload).toMatchObject({
|
||||
id: 'rule-openai',
|
||||
modelName: 'gpt-4o',
|
||||
provider: 'OpenAI',
|
||||
isOverride: true,
|
||||
enabled: true,
|
||||
unit: UnitDTO.per_million_tokens,
|
||||
pricing: { input: 5, output: 9 },
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(toastSuccess).toHaveBeenCalledWith(TOAST_MODEL_COST_UPDATED),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
import { makePricingRule } from 'container/LLMObservability/Settings/ModelPricing/__tests__/fixtures';
|
||||
import { EMPTY_DRAFT } from 'container/LLMObservability/Settings/ModelPricing/constants';
|
||||
import { draftFromRule } from 'container/LLMObservability/Settings/ModelPricing/utils';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import ModelCostDrawer from '../ModelCostDrawer';
|
||||
|
||||
const editDraft = draftFromRule(
|
||||
makePricingRule({
|
||||
id: 'rule-openai',
|
||||
modelName: 'gpt-4o',
|
||||
provider: 'OpenAI',
|
||||
}),
|
||||
);
|
||||
|
||||
describe('ModelCostDrawer (integration)', () => {
|
||||
it('renders the add title and a save button for a manager', () => {
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="add"
|
||||
initialDraft={EMPTY_DRAFT}
|
||||
onClose={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Add model cost')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('drawer-save-btn')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables save until the form is dirty', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="add"
|
||||
initialDraft={EMPTY_DRAFT}
|
||||
onClose={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('drawer-save-btn')).toBeDisabled();
|
||||
|
||||
await user.type(screen.getByTestId('drawer-model-id-input'), 'openai:gpt-4o');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('drawer-save-btn')).toBeEnabled(),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the model id required error and does not call onSave when the name is empty', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onSave = jest.fn();
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="add"
|
||||
initialDraft={EMPTY_DRAFT}
|
||||
onClose={jest.fn()}
|
||||
onSave={onSave}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
// Make the form dirty without touching the model id: add a pattern, which
|
||||
// mutates the `patterns` form field while leaving the name empty.
|
||||
await user.type(screen.getByTestId('drawer-pattern-input'), 'gpt');
|
||||
await user.click(screen.getByTestId('drawer-pattern-add-btn'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('drawer-save-btn')).toBeEnabled(),
|
||||
);
|
||||
await user.click(screen.getByTestId('drawer-save-btn'));
|
||||
|
||||
const error = await screen.findByText('Billing model ID is required.');
|
||||
expect(error).toBeInTheDocument();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onSave once on the happy path with valid model id and pricing', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onSave = jest.fn();
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="add"
|
||||
initialDraft={EMPTY_DRAFT}
|
||||
onClose={jest.fn()}
|
||||
onSave={onSave}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.type(screen.getByTestId('drawer-model-id-input'), 'openai:gpt-4o');
|
||||
await user.type(screen.getByTestId('drawer-input-cost'), '3');
|
||||
await user.type(screen.getByTestId('drawer-output-cost'), '9');
|
||||
|
||||
await user.click(screen.getByTestId('drawer-save-btn'));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('renders the edit title with disabled, prefilled model id and disabled provider', () => {
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="edit"
|
||||
initialDraft={editDraft}
|
||||
onClose={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Edit model cost')).toBeInTheDocument();
|
||||
const modelInput = screen.getByTestId(
|
||||
'drawer-model-id-input',
|
||||
) as HTMLInputElement;
|
||||
expect(modelInput.value).toBe('gpt-4o');
|
||||
expect(modelInput).toBeDisabled();
|
||||
expect(screen.getByTestId('drawer-provider-select')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('renders a read-only view with a Close button and no save for a viewer', () => {
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="edit"
|
||||
initialDraft={editDraft}
|
||||
onClose={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('View model cost')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('drawer-save-btn')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('drawer-cancel-btn')).toHaveTextContent('Close');
|
||||
});
|
||||
|
||||
it('renders the save error text', () => {
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="add"
|
||||
initialDraft={EMPTY_DRAFT}
|
||||
onClose={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
isSaving={false}
|
||||
saveError="boom"
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('boom')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds and removes a model pattern from the editor', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="add"
|
||||
initialDraft={EMPTY_DRAFT}
|
||||
onClose={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.type(screen.getByTestId('drawer-pattern-input'), 'gpt-5');
|
||||
await user.click(screen.getByTestId('drawer-pattern-add-btn'));
|
||||
// The added pattern renders as a removable chip.
|
||||
const removeChip = screen.getByRole('button', {
|
||||
name: 'Remove pattern gpt-5',
|
||||
});
|
||||
expect(removeChip).toBeInTheDocument();
|
||||
|
||||
await user.click(removeChip);
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Remove pattern gpt-5' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds a cache pricing bucket via the picker and removes it', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="add"
|
||||
initialDraft={EMPTY_DRAFT}
|
||||
onClose={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('drawer-add-bucket-btn'));
|
||||
await user.click(screen.getByTestId('drawer-add-bucket-cache-read'));
|
||||
|
||||
// Adding the bucket reveals its cost input and the shared cache-mode select.
|
||||
expect(screen.getByTestId('drawer-cache-read-cost')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('drawer-cache-mode')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('drawer-remove-cache-read'));
|
||||
expect(
|
||||
screen.queryByTestId('drawer-cache-read-cost'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('blocks save with a pricing error when an override rule has no input cost', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onSave = jest.fn();
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="add"
|
||||
initialDraft={EMPTY_DRAFT}
|
||||
onClose={jest.fn()}
|
||||
onSave={onSave}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
// EMPTY_DRAFT defaults to an override with empty pricing. Fill only the
|
||||
// model id + output cost so the form is dirty but the input cost is missing.
|
||||
await user.type(screen.getByTestId('drawer-model-id-input'), 'openai:gpt-4o');
|
||||
await user.type(screen.getByTestId('drawer-output-cost'), '9');
|
||||
await user.click(screen.getByTestId('drawer-save-btn'));
|
||||
|
||||
await expect(
|
||||
screen.findByText('Input cost must be greater than 0.'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires confirmation to reset an override rule back to auto', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="edit"
|
||||
initialDraft={editDraft}
|
||||
onClose={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
// Pricing is editable while the rule is an override.
|
||||
expect(screen.getByTestId('drawer-input-cost')).toBeEnabled();
|
||||
|
||||
// Picking "auto" surfaces a confirm step instead of applying immediately.
|
||||
await user.click(screen.getByTestId('drawer-source-auto'));
|
||||
expect(screen.getByTestId('drawer-reset-confirm-btn')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('drawer-input-cost')).toBeEnabled();
|
||||
|
||||
// Keep backs out of the reset.
|
||||
await user.click(screen.getByTestId('drawer-reset-keep-btn'));
|
||||
expect(
|
||||
screen.queryByTestId('drawer-reset-confirm-btn'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Confirming the reset flips the rule to auto and locks pricing.
|
||||
await user.click(screen.getByTestId('drawer-source-auto'));
|
||||
await user.click(screen.getByTestId('drawer-reset-confirm-btn'));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('drawer-input-cost')).toBeDisabled(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,11 @@ import {
|
||||
useCreateOrUpdateLLMPricingRules,
|
||||
} from 'api/generated/services/llmpricingrules';
|
||||
|
||||
import { EMPTY_DRAFT } from '../../../../constants';
|
||||
import {
|
||||
EMPTY_DRAFT,
|
||||
TOAST_MODEL_COST_ADDED,
|
||||
TOAST_MODEL_COST_UPDATED,
|
||||
} from '../../../../constants';
|
||||
import type { DrawerDraft, DrawerMode, PricingRule } from '../../../../types';
|
||||
import { buildRulePayload, draftFromRule } from '../../../../utils';
|
||||
|
||||
@@ -76,7 +80,9 @@ export function useModelCostDrawer(): UseModelCostDrawerResult {
|
||||
await invalidateList();
|
||||
setIsOpen(false);
|
||||
setSelectedRuleId(null);
|
||||
toast.success(mode === 'edit' ? 'Model cost updated' : 'Model cost added');
|
||||
toast.success(
|
||||
mode === 'edit' ? TOAST_MODEL_COST_UPDATED : TOAST_MODEL_COST_ADDED,
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Save failed';
|
||||
setSaveError(message);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.modelCostsTable {
|
||||
margin-top: var(--spacing-8);
|
||||
--tanstack-table-row-height: 48px;
|
||||
height: calc(100vh - 250px);
|
||||
height: calc(100vh - 170px);
|
||||
overflow-y: auto;
|
||||
|
||||
:global(table) tbody tr {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useDeleteLLMPricingRule,
|
||||
} from 'api/generated/services/llmpricingrules';
|
||||
|
||||
import { TOAST_MODEL_COST_DELETED } from '../../constants';
|
||||
import type { PricingRule } from '../../types';
|
||||
|
||||
// The minimal slice of a rule the delete-confirm flow needs: the id to delete
|
||||
@@ -49,7 +50,7 @@ export function useModelCostDelete(): UseModelCostDeleteResult {
|
||||
queryKey: getListLLMPricingRulesQueryKey(),
|
||||
});
|
||||
setPendingDelete(null);
|
||||
toast.success('Model cost deleted');
|
||||
toast.success(TOAST_MODEL_COST_DELETED);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Delete failed';
|
||||
toast.error(message);
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
|
||||
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
|
||||
type ListLLMPricingRules200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { PricingRule } from '../types';
|
||||
|
||||
// Endpoint glob used by MSW handlers. The generated client hits a relative
|
||||
// `/api/v1/llm_pricing_rules`, so the `*` prefix matches regardless of base URL.
|
||||
export const LLM_PRICING_ENDPOINT = '*/api/v1/llm_pricing_rules';
|
||||
export const LLM_PRICING_RULE_ENDPOINT = '*/api/v1/llm_pricing_rules/:id';
|
||||
|
||||
// Builds a valid pricing rule, with overrides merged shallowly. Pricing is
|
||||
// replaced wholesale when provided so callers can shape cache buckets freely.
|
||||
export function makePricingRule(
|
||||
overrides: Partial<PricingRule> = {},
|
||||
): PricingRule {
|
||||
const { pricing, ...rest } = overrides;
|
||||
return {
|
||||
id: 'rule-1',
|
||||
enabled: true,
|
||||
isOverride: true,
|
||||
modelName: 'gpt-4o',
|
||||
modelPattern: ['gpt-4o'],
|
||||
orgId: 'org-1',
|
||||
provider: 'OpenAI',
|
||||
sourceId: 'source-1',
|
||||
unit: UnitDTO.per_million_tokens,
|
||||
createdAt: '2023-10-01T00:00:00.000Z',
|
||||
updatedAt: '2023-10-10T00:00:00.000Z',
|
||||
syncedAt: '2023-10-10T00:00:00.000Z',
|
||||
pricing: {
|
||||
input: 3,
|
||||
output: 9,
|
||||
...pricing,
|
||||
},
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
export const mockRules: PricingRule[] = [
|
||||
makePricingRule({
|
||||
id: 'rule-openai',
|
||||
modelName: 'gpt-4o',
|
||||
provider: 'OpenAI',
|
||||
isOverride: true,
|
||||
pricing: { input: 3, output: 9 },
|
||||
}),
|
||||
makePricingRule({
|
||||
id: 'rule-anthropic',
|
||||
modelName: 'claude-3-5-sonnet',
|
||||
provider: 'Anthropic',
|
||||
isOverride: false,
|
||||
pricing: {
|
||||
input: 2,
|
||||
output: 30,
|
||||
cache: { mode: CacheModeDTO.additive, read: 3, write: 6 },
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
// Wraps items in the list response envelope the list query reads
|
||||
// (`data.data.items` / `data.data.total`).
|
||||
export function makeListResponse(
|
||||
items: PricingRule[],
|
||||
total = items.length,
|
||||
offset = 0,
|
||||
limit = 20,
|
||||
): ListLLMPricingRules200 {
|
||||
return {
|
||||
status: 'success',
|
||||
data: {
|
||||
items,
|
||||
total,
|
||||
offset,
|
||||
limit,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,10 @@ import type { CacheBucketDef, DrawerDraft } from './types';
|
||||
|
||||
export const PAGE_SIZE = 20;
|
||||
|
||||
export const TOAST_MODEL_COST_ADDED = 'Model cost added';
|
||||
export const TOAST_MODEL_COST_UPDATED = 'Model cost updated';
|
||||
export const TOAST_MODEL_COST_DELETED = 'Model cost deleted';
|
||||
|
||||
export const PAGE_KEY = 'page';
|
||||
export const LIMIT_KEY = 'limit';
|
||||
export const SEARCH_KEY = 'search';
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { safeNavigateMock } from '__tests__/safeNavigateMock';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
LLM_PRICING_ENDPOINT,
|
||||
makeListResponse,
|
||||
mockRules,
|
||||
} from 'container/LLMObservability/Settings/ModelPricing/__tests__/fixtures';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import LLMObservability from '../LLMObservability';
|
||||
|
||||
function setupList(items = mockRules): void {
|
||||
server.use(
|
||||
rest.get(LLM_PRICING_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(makeListResponse(items))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe('LLMObservability (integration)', () => {
|
||||
beforeEach(() => {
|
||||
window.history.pushState(null, '', '/');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders the overview panel and the tab bar on the overview route', () => {
|
||||
render(<LLMObservability />, undefined, {
|
||||
initialRoute: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('llm-observability-tabs')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('llm-observability-overview')).toBeInTheDocument();
|
||||
expect(screen.getByText('LLM Observability')).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('tab', { name: 'Model pricing' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates to the configuration route when the Model pricing tab is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<LLMObservability />, undefined, {
|
||||
initialRoute: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Model pricing' }));
|
||||
|
||||
expect(safeNavigateMock).toHaveBeenCalledWith(
|
||||
ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the model-pricing page on the configuration route', async () => {
|
||||
setupList();
|
||||
render(<LLMObservability />, undefined, {
|
||||
initialRoute: ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByTestId('llm-observability-model-pricing-page'),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { type TabItemProps } from '@signozhq/ui/tabs';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
|
||||
import Overview from '../Overview/Overview';
|
||||
import LLMObservabilityModelPricing from '../Settings/ModelPricing/LLMObservabilityModelPricing';
|
||||
|
||||
const OVERVIEW_KEY = ROUTES.LLM_OBSERVABILITY_OVERVIEW;
|
||||
const CONFIGURATION_KEY = ROUTES.LLM_OBSERVABILITY_CONFIGURATION;
|
||||
|
||||
interface UseLLMObservabilityTabsResult {
|
||||
items: TabItemProps[];
|
||||
activeTab: string;
|
||||
onTabChange: (key: string) => void;
|
||||
}
|
||||
|
||||
// Drives the top-level LLM Observability tabs. Route-driven: the active tab is
|
||||
// derived from the pathname (each tab owns a URL) and changing tabs navigates,
|
||||
// so tabs stay shareable/back-button friendly while rendering with the SigNoz
|
||||
// design-system Tabs.
|
||||
export function useLLMObservabilityTabs(): UseLLMObservabilityTabsResult {
|
||||
const { pathname } = useLocation();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const activeTab = pathname.startsWith(CONFIGURATION_KEY)
|
||||
? CONFIGURATION_KEY
|
||||
: OVERVIEW_KEY;
|
||||
|
||||
const onTabChange = useCallback(
|
||||
(key: string): void => {
|
||||
safeNavigate(key);
|
||||
},
|
||||
[safeNavigate],
|
||||
);
|
||||
|
||||
const items: TabItemProps[] = [
|
||||
{
|
||||
key: OVERVIEW_KEY,
|
||||
label: 'Overview',
|
||||
children: <Overview />,
|
||||
},
|
||||
{
|
||||
key: CONFIGURATION_KEY,
|
||||
label: 'Model pricing',
|
||||
children: <LLMObservabilityModelPricing />,
|
||||
},
|
||||
];
|
||||
|
||||
return { items, activeTab, onTabChange };
|
||||
}
|
||||
@@ -203,8 +203,8 @@ export const routesToSkip = [
|
||||
ROUTES.METER_EXPLORER_VIEWS,
|
||||
ROUTES.METRICS_EXPLORER_VOLUME_CONTROL,
|
||||
ROUTES.SOMETHING_WENT_WRONG,
|
||||
ROUTES.LLM_OBSERVABILITY_BASE,
|
||||
ROUTES.LLM_OBSERVABILITY_MODEL_PRICING,
|
||||
ROUTES.LLM_OBSERVABILITY_OVERVIEW,
|
||||
ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
|
||||
];
|
||||
|
||||
export const routesToDisable = [ROUTES.LOGS_EXPLORER, ROUTES.LIVE_LOGS];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { type ReactNode, useCallback, useMemo, useState } from 'react';
|
||||
import { FullScreenHandle } from 'react-full-screen';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import {
|
||||
@@ -17,6 +17,7 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
|
||||
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { cloneDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
@@ -29,6 +30,7 @@ import APIError from 'types/api/error';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import ConfirmDeleteDialog from '../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
|
||||
import DisabledMenuItemLabel from '../../components/DisabledMenuItemLabel/DisabledMenuItemLabel';
|
||||
import DashboardSettings from '../../DashboardSettings';
|
||||
import { useAddSection } from '../../PanelsAndSectionsLayout/Section/hooks/useAddSection';
|
||||
import SectionTitleModal from '../../PanelsAndSectionsLayout/Section/SectionTitleModal';
|
||||
@@ -58,7 +60,8 @@ function DashboardActions({
|
||||
onLockToggle,
|
||||
onOpenRename,
|
||||
}: DashboardActionsProps): JSX.Element {
|
||||
const canEdit = useDashboardStore((s) => s.isEditable);
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const editDisabledReason = useDashboardStore((s) => s.editDisabledReason);
|
||||
const { user } = useAppContext();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
@@ -111,23 +114,37 @@ function DashboardActions({
|
||||
});
|
||||
}, [deleteDashboardMutation]);
|
||||
|
||||
// Edit-action label: plain text when editable, else a disabled row whose label
|
||||
// still shows a hover tooltip explaining why (locked / no permission).
|
||||
const editLabel = useCallback(
|
||||
(text: string): ReactNode =>
|
||||
isEditable ? (
|
||||
text
|
||||
) : (
|
||||
<DisabledMenuItemLabel reason={editDisabledReason}>
|
||||
{text}
|
||||
</DisabledMenuItemLabel>
|
||||
),
|
||||
[isEditable, editDisabledReason],
|
||||
);
|
||||
|
||||
const menuItems = useMemo<MenuItem[]>(() => {
|
||||
const dashboardGroup: MenuItem[] = [];
|
||||
if (canEdit) {
|
||||
dashboardGroup.push({
|
||||
const dashboardGroup: MenuItem[] = [
|
||||
{
|
||||
key: 'rename',
|
||||
label: 'Rename',
|
||||
label: editLabel('Rename'),
|
||||
icon: <PenLine size={14} />,
|
||||
disabled: !isEditable,
|
||||
onClick: onOpenRename,
|
||||
});
|
||||
}
|
||||
dashboardGroup.push({
|
||||
key: 'clone',
|
||||
label: 'Clone dashboard',
|
||||
icon: <Copy size={14} />,
|
||||
disabled: isCloning,
|
||||
onClick: (): void => void handleClone(),
|
||||
});
|
||||
},
|
||||
{
|
||||
key: 'clone',
|
||||
label: 'Clone dashboard',
|
||||
icon: <Copy size={14} />,
|
||||
disabled: isCloning,
|
||||
onClick: (): void => void handleClone(),
|
||||
},
|
||||
];
|
||||
if (isAuthor || user.role === USER_ROLES.ADMIN) {
|
||||
dashboardGroup.push({
|
||||
key: 'lock',
|
||||
@@ -144,45 +161,40 @@ function DashboardActions({
|
||||
onClick: handle.enter,
|
||||
});
|
||||
|
||||
const layoutGroup: MenuItem[] = [];
|
||||
if (canEdit) {
|
||||
layoutGroup.push({
|
||||
key: 'new-section',
|
||||
label: 'New section',
|
||||
icon: <SquareStack size={14} />,
|
||||
onClick: (): void => setIsNewSectionOpen(true),
|
||||
});
|
||||
}
|
||||
|
||||
const items: MenuItem[] = [
|
||||
return [
|
||||
{
|
||||
type: 'group',
|
||||
key: 'group-dashboard',
|
||||
label: 'Dashboard',
|
||||
children: dashboardGroup,
|
||||
},
|
||||
];
|
||||
if (layoutGroup.length > 0) {
|
||||
items.push({
|
||||
{
|
||||
type: 'group',
|
||||
key: 'group-layout',
|
||||
label: 'Layout',
|
||||
children: layoutGroup,
|
||||
});
|
||||
}
|
||||
items.push(
|
||||
children: [
|
||||
{
|
||||
key: 'new-section',
|
||||
label: editLabel('New section'),
|
||||
icon: <SquareStack size={14} />,
|
||||
disabled: !isEditable,
|
||||
onClick: (): void => setIsNewSectionOpen(true),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: 'divider', key: 'divider-danger' },
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete dashboard',
|
||||
label: editLabel('Delete dashboard'),
|
||||
icon: <Trash2 size={14} />,
|
||||
danger: true,
|
||||
disabled: !isEditable,
|
||||
onClick: (): void => setIsDeleteOpen(true),
|
||||
},
|
||||
);
|
||||
return items;
|
||||
];
|
||||
}, [
|
||||
canEdit,
|
||||
editLabel,
|
||||
isEditable,
|
||||
isCloning,
|
||||
isAuthor,
|
||||
user.role,
|
||||
@@ -194,6 +206,16 @@ function DashboardActions({
|
||||
handle.enter,
|
||||
]);
|
||||
|
||||
// A disabled edit control stays visible with a tooltip explaining why.
|
||||
const withDisabledTooltip = (node: JSX.Element): JSX.Element =>
|
||||
isEditable ? (
|
||||
node
|
||||
) : (
|
||||
<TooltipSimple title={editDisabledReason} disableHoverableContent>
|
||||
{node}
|
||||
</TooltipSimple>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.dashboardActionsContainer}>
|
||||
<DropdownMenuSimple menu={{ items: menuItems }}>
|
||||
@@ -207,27 +229,26 @@ function DashboardActions({
|
||||
Actions
|
||||
</Button>
|
||||
</DropdownMenuSimple>
|
||||
{canEdit && (
|
||||
<>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<Configure size="md" />}
|
||||
testId="show-drawer"
|
||||
onClick={(): void => setIsSettingsDrawerOpen(true)}
|
||||
size="md"
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
<SettingsDrawer
|
||||
drawerTitle="Dashboard Configuration"
|
||||
isOpen={isSettingsDrawerOpen}
|
||||
onClose={(): void => setIsSettingsDrawerOpen(false)}
|
||||
>
|
||||
<DashboardSettings dashboard={dashboard} />
|
||||
</SettingsDrawer>
|
||||
</>
|
||||
{withDisabledTooltip(
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<Configure size="md" />}
|
||||
testId="show-drawer"
|
||||
disabled={!isEditable}
|
||||
onClick={(): void => setIsSettingsDrawerOpen(true)}
|
||||
size="md"
|
||||
>
|
||||
Configure
|
||||
</Button>,
|
||||
)}
|
||||
<SettingsDrawer
|
||||
drawerTitle="Dashboard Configuration"
|
||||
isOpen={isSettingsDrawerOpen}
|
||||
onClose={(): void => setIsSettingsDrawerOpen(false)}
|
||||
>
|
||||
<DashboardSettings dashboard={dashboard} />
|
||||
</SettingsDrawer>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
@@ -238,17 +259,18 @@ function DashboardActions({
|
||||
>
|
||||
JSON
|
||||
</Button>
|
||||
{!isDashboardLocked && (
|
||||
{withDisabledTooltip(
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onAddPanel}
|
||||
prefix={<Plus size="md" />}
|
||||
testId="add-panel-header"
|
||||
disabled={!isEditable}
|
||||
size="md"
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
</Button>,
|
||||
)}
|
||||
<JsonEditorDrawer
|
||||
dashboard={dashboard}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { defineJsonEditorTheme, JSON_EDITOR_THEME } from './editorTheme';
|
||||
import styles from './JsonEditorDrawer.module.scss';
|
||||
import JsonEditorToolbar from './JsonEditorToolbar';
|
||||
import { useJsonEditor } from './useJsonEditor';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
|
||||
interface JsonEditorDrawerProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
@@ -28,6 +29,12 @@ function JsonEditorDrawer({
|
||||
}: JsonEditorDrawerProps): JSX.Element {
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const readOnlyReason = useDashboardStore((s) => s.editDisabledReason);
|
||||
// Locked/no-permission dashboards open the JSON for inspection only — Apply,
|
||||
// Format and Reset are disabled and edits can't be saved.
|
||||
const readOnly = !isEditable;
|
||||
|
||||
const {
|
||||
draft,
|
||||
setDraft,
|
||||
@@ -39,7 +46,7 @@ function JsonEditorDrawer({
|
||||
format,
|
||||
reset,
|
||||
apply,
|
||||
} = useJsonEditor({ dashboard, isOpen, onApplied: onClose });
|
||||
} = useJsonEditor({ dashboard, isOpen, readOnly, onApplied: onClose });
|
||||
|
||||
const onCopy = useCallback((): void => {
|
||||
copyToClipboard(draft);
|
||||
@@ -63,13 +70,15 @@ function JsonEditorDrawer({
|
||||
event.stopPropagation();
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void apply();
|
||||
if (!readOnly) {
|
||||
void apply();
|
||||
}
|
||||
}
|
||||
},
|
||||
[apply],
|
||||
[apply, readOnly],
|
||||
);
|
||||
|
||||
const applyDisabled = !isDirty || !validity.valid || isSaving;
|
||||
const applyDisabled = readOnly || !isDirty || !validity.valid || isSaving;
|
||||
const validationText = validity.valid
|
||||
? `Valid JSON · ${validity.lineCount} lines`
|
||||
: `Line ${validity.errorLine ?? '?'} · ${validity.message ?? 'Invalid JSON'}`;
|
||||
@@ -150,16 +159,30 @@ function JsonEditorDrawer({
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
testId="json-editor-apply"
|
||||
disabled={applyDisabled}
|
||||
onClick={(): void => void apply()}
|
||||
>
|
||||
Apply changes
|
||||
</Button>
|
||||
{readOnly ? (
|
||||
<TooltipSimple title={readOnlyReason} disableHoverableContent>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
testId="json-editor-apply"
|
||||
disabled
|
||||
>
|
||||
Apply changes
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
) : (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
testId="json-editor-apply"
|
||||
disabled={applyDisabled}
|
||||
onClick={(): void => void apply()}
|
||||
>
|
||||
Apply changes
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -168,6 +191,7 @@ function JsonEditorDrawer({
|
||||
<div className={styles.body} onKeyDown={onKeyDown}>
|
||||
<JsonEditorToolbar
|
||||
isDirty={isDirty}
|
||||
readOnly={readOnly}
|
||||
onFormat={format}
|
||||
onCopy={onCopy}
|
||||
onDownload={onDownload}
|
||||
@@ -180,6 +204,7 @@ function JsonEditorDrawer({
|
||||
value={draft}
|
||||
onChange={(value): void => setDraft(value ?? '')}
|
||||
options={{
|
||||
readOnly,
|
||||
scrollbar: { alwaysConsumeMouseWheel: false },
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
|
||||
@@ -5,6 +5,8 @@ import styles from './JsonEditorToolbar.module.scss';
|
||||
|
||||
interface JsonEditorToolbarProps {
|
||||
isDirty: boolean;
|
||||
/** Locked/no-permission — Format and Reset (draft mutators) are disabled. */
|
||||
readOnly?: boolean;
|
||||
onFormat: () => void;
|
||||
onCopy: () => void;
|
||||
onDownload: () => void;
|
||||
@@ -13,6 +15,7 @@ interface JsonEditorToolbarProps {
|
||||
|
||||
function JsonEditorToolbar({
|
||||
isDirty,
|
||||
readOnly = false,
|
||||
onFormat,
|
||||
onCopy,
|
||||
onDownload,
|
||||
@@ -26,6 +29,7 @@ function JsonEditorToolbar({
|
||||
size="sm"
|
||||
prefix={<AlignLeft size={14} />}
|
||||
testId="json-editor-format"
|
||||
disabled={readOnly}
|
||||
onClick={onFormat}
|
||||
>
|
||||
Format
|
||||
@@ -57,7 +61,7 @@ function JsonEditorToolbar({
|
||||
size="sm"
|
||||
prefix={<RotateCcw size={14} />}
|
||||
testId="json-editor-reset"
|
||||
disabled={!isDirty}
|
||||
disabled={readOnly || !isDirty}
|
||||
onClick={onReset}
|
||||
>
|
||||
Reset
|
||||
|
||||
@@ -7,6 +7,13 @@ import { useJsonEditor } from '../useJsonEditor';
|
||||
|
||||
jest.mock('../useJsonEditor', () => ({ useJsonEditor: jest.fn() }));
|
||||
|
||||
// Editable by default so the drawer renders in its editable (non-read-only) mode.
|
||||
jest.mock('../../../store/useDashboardStore', () => ({
|
||||
useDashboardStore: (
|
||||
selector: (s: { isEditable: boolean; editDisabledReason: string }) => unknown,
|
||||
): unknown => selector({ isEditable: true, editDisabledReason: '' }),
|
||||
}));
|
||||
|
||||
jest.mock('@monaco-editor/react', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface JsonValidity {
|
||||
interface Params {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
isOpen: boolean;
|
||||
/** Locked/no-permission — `apply` is a no-op so edits can never be saved. */
|
||||
readOnly?: boolean;
|
||||
onApplied: () => void;
|
||||
}
|
||||
|
||||
@@ -77,6 +79,7 @@ function errorLineFromMessage(
|
||||
export function useJsonEditor({
|
||||
dashboard,
|
||||
isOpen,
|
||||
readOnly = false,
|
||||
onApplied,
|
||||
}: Params): Result {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
@@ -147,7 +150,7 @@ export function useJsonEditor({
|
||||
}, [appliedText]);
|
||||
|
||||
const apply = useCallback(async (): Promise<void> => {
|
||||
if (!validity.valid || !isDirty) {
|
||||
if (readOnly || !validity.valid || !isDirty) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -173,6 +176,7 @@ export function useJsonEditor({
|
||||
validity.valid,
|
||||
isDirty,
|
||||
draft,
|
||||
readOnly,
|
||||
refetch,
|
||||
onApplied,
|
||||
showErrorModal,
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { FullScreenHandle } from 'react-full-screen';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
getGetDashboardV2QueryKey,
|
||||
lockDashboardV2,
|
||||
unlockDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import type {
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
GetDashboardV2200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
@@ -32,13 +35,13 @@ import styles from './DashboardPageToolbar.module.scss';
|
||||
interface DashboardPageToolbarProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
handle: FullScreenHandle;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
const { dashboard, handle, refetch } = props;
|
||||
const { dashboard, handle } = props;
|
||||
|
||||
const id = dashboard.id;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Session-local lock state: the toggle appears once locked and persists for the page.
|
||||
const [isDashboardLocked, setIsDashboardLocked] = useState(!!dashboard.locked);
|
||||
@@ -101,12 +104,21 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
await unlockDashboardV2({ id });
|
||||
toast.success('Dashboard unlocked');
|
||||
}
|
||||
refetch();
|
||||
// Patch just the `locked` flag in the cache — a full refetch would reload
|
||||
// every panel's chart data for a metadata-only change.
|
||||
const key = getGetDashboardV2QueryKey({ id });
|
||||
const cached = queryClient.getQueryData<GetDashboardV2200>(key);
|
||||
if (cached) {
|
||||
queryClient.setQueryData<GetDashboardV2200>(key, {
|
||||
...cached,
|
||||
data: { ...cached.data, locked: next },
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setIsDashboardLocked(!next);
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
}, [id, isDashboardLocked, refetch, showErrorModal]);
|
||||
}, [id, isDashboardLocked, queryClient, showErrorModal]);
|
||||
|
||||
const onNameSave = useCallback(
|
||||
async (next: string): Promise<void> => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { SolidAlertTriangle, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { useConfirmableAction } from 'hooks/useConfirmableAction';
|
||||
|
||||
@@ -11,14 +12,23 @@ import styles from './Header.module.scss';
|
||||
interface HeaderProps {
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
showSwitchToView?: boolean;
|
||||
/** Locked/no-permission dashboard — Save is disabled with a reason. */
|
||||
readOnly?: boolean;
|
||||
readOnlyReason?: string;
|
||||
onSave: () => void;
|
||||
onSwitchToView?: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function Header({
|
||||
isDirty,
|
||||
isSaving,
|
||||
showSwitchToView = false,
|
||||
readOnly = false,
|
||||
readOnlyReason,
|
||||
onSave,
|
||||
onSwitchToView,
|
||||
onClose,
|
||||
}: HeaderProps): JSX.Element {
|
||||
const discard = useConfirmableAction(
|
||||
@@ -49,16 +59,39 @@ function Header({
|
||||
<Typography.Text>Configure panel</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
data-testid="panel-editor-v2-save"
|
||||
disabled={!isDirty || isSaving}
|
||||
loading={isSaving}
|
||||
onClick={onSave}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
{showSwitchToView && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
data-testid="panel-editor-v2-switch-to-view"
|
||||
onClick={onSwitchToView}
|
||||
>
|
||||
Switch to View Mode
|
||||
</Button>
|
||||
)}
|
||||
{readOnly ? (
|
||||
<TooltipSimple title={readOnlyReason} disableHoverableContent>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
data-testid="panel-editor-v2-save"
|
||||
disabled
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
) : (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
data-testid="panel-editor-v2-save"
|
||||
disabled={!isDirty || isSaving}
|
||||
loading={isSaving}
|
||||
onClick={onSave}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogWrapper
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PanelMode } from 'container/DashboardContainer/visualization/panels/typ
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import PanelBody from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody';
|
||||
import PanelHeader from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
|
||||
import type { AnyPanelInteractionProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/interactions';
|
||||
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
|
||||
@@ -42,6 +43,10 @@ interface PreviewPaneProps {
|
||||
dashboardPreference?: DashboardPreference;
|
||||
/** Close the standalone View modal — forwarded to the time-series/bar graph manager. */
|
||||
onCloseStandaloneView?: () => void;
|
||||
/** Opens the drill-down context menu; only the View modal wires it (the editor preview omits it). */
|
||||
onClick?: AnyPanelInteractionProps['onClick'];
|
||||
/** Arms the drill-down click on interactive renderers — the View modal enables it, the editor doesn't. */
|
||||
enableDrillDown?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,6 +69,8 @@ function PreviewPane({
|
||||
hideHeader = false,
|
||||
dashboardPreference,
|
||||
onCloseStandaloneView,
|
||||
onClick,
|
||||
enableDrillDown,
|
||||
}: PreviewPaneProps): JSX.Element {
|
||||
const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind];
|
||||
const queryType = getPanelQueryType(panel);
|
||||
@@ -96,6 +103,7 @@ function PreviewPane({
|
||||
<PanelHeader
|
||||
panelId={panelId}
|
||||
panel={panel}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
warning={data.response?.data?.warning}
|
||||
@@ -119,6 +127,8 @@ function PreviewPane({
|
||||
searchTerm={searchable ? searchTerm : undefined}
|
||||
pagination={pagination}
|
||||
onCloseStandaloneView={onCloseStandaloneView}
|
||||
onClick={onClick}
|
||||
enableDrillDown={enableDrillDown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -48,6 +48,10 @@ jest.mock('../hooks/usePanelEditorSave', () => ({
|
||||
jest.mock('../hooks/useSwitchColumnsOnSignalChange', () => ({
|
||||
useSwitchColumnsOnSignalChange: jest.fn(),
|
||||
}));
|
||||
const mockOnSwitchToView = jest.fn();
|
||||
jest.mock('../hooks/useSwitchToViewMode', () => ({
|
||||
useSwitchToViewMode: (): (() => void) => mockOnSwitchToView,
|
||||
}));
|
||||
jest.mock('../hooks/useSeedNewListColumns', () => ({
|
||||
useSeedNewListColumns: jest.fn(),
|
||||
}));
|
||||
@@ -138,13 +142,16 @@ jest.mock('../ListColumnsEditor/ListColumnsEditor', () => ({
|
||||
default: (): JSX.Element => <div data-testid="list-columns" />,
|
||||
}));
|
||||
|
||||
function makePanel(kind: string): DashboardtypesPanelDTO {
|
||||
function makePanel(
|
||||
kind: string,
|
||||
queries: unknown[] = [],
|
||||
): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'CPU' },
|
||||
plugin: { kind, spec: {} },
|
||||
queries: [],
|
||||
queries,
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
@@ -152,6 +159,8 @@ function makePanel(kind: string): DashboardtypesPanelDTO {
|
||||
const baseProps = {
|
||||
dashboardId: 'dash-1',
|
||||
panelId: 'panel-1',
|
||||
isEditable: true,
|
||||
editDisabledReason: '',
|
||||
onClose: jest.fn(),
|
||||
onSaved: jest.fn(),
|
||||
};
|
||||
@@ -159,12 +168,13 @@ const baseProps = {
|
||||
function setup(
|
||||
panel: DashboardtypesPanelDTO,
|
||||
overrides?: Partial<React.ComponentProps<typeof PanelEditorContainer>>,
|
||||
draftOverrides?: { isSpecDirty?: boolean },
|
||||
): void {
|
||||
mockUseDraft.mockReturnValue({
|
||||
draft: panel,
|
||||
spec: panel.spec,
|
||||
setSpec: mockSetSpec,
|
||||
isSpecDirty: false,
|
||||
isSpecDirty: draftOverrides?.isSpecDirty ?? false,
|
||||
});
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: { response: undefined },
|
||||
@@ -240,12 +250,38 @@ describe('PanelEditorContainer composition', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('marks a new panel dirty and always serializes its query', () => {
|
||||
it('keeps a query-less new panel unsaveable but still serializes its seed query', () => {
|
||||
setup(makePanel('signoz/TimeSeriesPanel'), { isNew: true });
|
||||
|
||||
expect(mockUseQuerySync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ alwaysSerializeQuery: true }),
|
||||
);
|
||||
// No query and no edits yet → nothing to save, so Save stays disabled.
|
||||
expect(mockHeaderProps).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isDirty: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('marks a new panel that already has a query saveable (e.g. list auto-runs one)', () => {
|
||||
const seededQuery = {
|
||||
spec: { plugin: { kind: 'signoz/BuilderQuery', spec: { signal: 'logs' } } },
|
||||
};
|
||||
setup(makePanel('signoz/ListPanel', [seededQuery]), { isNew: true });
|
||||
|
||||
expect(mockHeaderProps).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isDirty: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('marks a new panel dirty once the user edits its spec', () => {
|
||||
setup(
|
||||
makePanel('signoz/TimeSeriesPanel'),
|
||||
{ isNew: true },
|
||||
{
|
||||
isSpecDirty: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(mockHeaderProps).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isDirty: true }),
|
||||
);
|
||||
@@ -258,10 +294,30 @@ describe('PanelEditorContainer composition', () => {
|
||||
await userEvent.click(screen.getByTestId('editor-save'));
|
||||
|
||||
await waitFor(() => expect(baseProps.onSaved).toHaveBeenCalled());
|
||||
|
||||
expect(mockBuildSaveSpec).toHaveBeenCalledWith(panel.spec);
|
||||
expect(mockSave).toHaveBeenCalledWith(panel.spec);
|
||||
});
|
||||
|
||||
it('offers Switch to View Mode for an existing panel', () => {
|
||||
setup(makePanel('signoz/TimeSeriesPanel'));
|
||||
|
||||
expect(mockHeaderProps).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
showSwitchToView: true,
|
||||
onSwitchToView: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('hides Switch to View Mode for a new (unsaved) panel', () => {
|
||||
setup(makePanel('signoz/TimeSeriesPanel'), { isNew: true });
|
||||
|
||||
expect(mockHeaderProps).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ showSwitchToView: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the list-columns editor only for list panels', () => {
|
||||
setup(makePanel('signoz/ListPanel'));
|
||||
expect(screen.getByTestId('list-columns')).toBeInTheDocument();
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { useSwitchToViewMode } from '../useSwitchToViewMode';
|
||||
|
||||
const mockSafeNavigate = jest.fn();
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
let mockSearch = '';
|
||||
jest.mock('hooks/useUrlQuery', () => ({
|
||||
__esModule: true,
|
||||
default: (): URLSearchParams => new URLSearchParams(mockSearch),
|
||||
}));
|
||||
|
||||
const query = { queryType: 'builder' } as unknown as Query;
|
||||
|
||||
describe('useSwitchToViewMode', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockSearch = '';
|
||||
});
|
||||
|
||||
function invoke(): void {
|
||||
const { result } = renderHook(() =>
|
||||
useSwitchToViewMode({
|
||||
dashboardId: 'dash-1',
|
||||
panelId: 'panel-1',
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
query,
|
||||
}),
|
||||
);
|
||||
result.current();
|
||||
}
|
||||
|
||||
it('opens the dashboard with the View modal seeded from the live query', () => {
|
||||
invoke();
|
||||
|
||||
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
|
||||
const target = new URL(mockSafeNavigate.mock.calls[0][0], 'http://x');
|
||||
expect(target.pathname).toBe('/dashboard/dash-1');
|
||||
expect(target.searchParams.get('expandedWidgetId')).toBe('panel-1');
|
||||
expect(target.searchParams.get('graphType')).toBe(PANEL_TYPES.TIME_SERIES);
|
||||
expect(
|
||||
JSON.parse(
|
||||
decodeURIComponent(target.searchParams.get('compositeQuery') || ''),
|
||||
),
|
||||
).toStrictEqual(query);
|
||||
});
|
||||
|
||||
it('carries dashboard variables through and drops other editor URL state', () => {
|
||||
mockSearch = 'variables=%7B%22a%22%3A1%7D&compositeQuery=stale';
|
||||
invoke();
|
||||
|
||||
const target = new URL(mockSafeNavigate.mock.calls[0][0], 'http://x');
|
||||
expect(target.searchParams.get('variables')).toBe('{"a":1}');
|
||||
// The stale editor query is replaced with the live one, not the URL leftover.
|
||||
expect(target.searchParams.get('compositeQuery')).not.toBe('stale');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useCallback } from 'react';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
interface UseSwitchToViewModeArgs {
|
||||
dashboardId: string;
|
||||
panelId: string;
|
||||
panelType: PANEL_TYPES;
|
||||
query: Query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback that leaves the editor for the dashboard with this panel expanded in the
|
||||
* View modal, seeded with the live (un-saved) query — V1's "Switch to View Mode".
|
||||
*/
|
||||
export function useSwitchToViewMode({
|
||||
dashboardId,
|
||||
panelId,
|
||||
panelType,
|
||||
query,
|
||||
}: UseSwitchToViewModeArgs): () => void {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
return useCallback((): void => {
|
||||
const params = new URLSearchParams();
|
||||
const variables = urlQuery.get(QueryParams.variables);
|
||||
if (variables) {
|
||||
params.set(QueryParams.variables, variables);
|
||||
}
|
||||
params.set(QueryParams.expandedWidgetId, panelId);
|
||||
params.set(QueryParams.graphType, panelType);
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(query)),
|
||||
);
|
||||
safeNavigate(
|
||||
`${generatePath(ROUTES.DASHBOARD, { dashboardId })}?${params.toString()}`,
|
||||
);
|
||||
}, [safeNavigate, urlQuery, dashboardId, panelId, panelType, query]);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
|
||||
import { getExecStats } from '../queryV5/v5ResponseData';
|
||||
@@ -28,6 +29,7 @@ import { usePanelEditSession } from './hooks/usePanelEditSession';
|
||||
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
|
||||
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
|
||||
import { useSwitchColumnsOnSignalChange } from './hooks/useSwitchColumnsOnSignalChange';
|
||||
import { useSwitchToViewMode } from './hooks/useSwitchToViewMode';
|
||||
import { useTableColumns } from './hooks/useTableColumns';
|
||||
import ListColumnsEditor from './ListColumnsEditor/ListColumnsEditor';
|
||||
|
||||
@@ -41,6 +43,10 @@ interface PanelEditorContainerProps {
|
||||
isNew?: boolean;
|
||||
/** Target section for a new panel; falls back to the last/new section. */
|
||||
layoutIndex?: number;
|
||||
/** The dashboard can be edited (unlocked + permission); gates Save. */
|
||||
isEditable: boolean;
|
||||
/** Why Save is disabled (locked / no permission); '' when editable. */
|
||||
editDisabledReason: string;
|
||||
/** Leave the editor (navigate back to the dashboard) without saving. */
|
||||
onClose: () => void;
|
||||
/** Called after a successful save — navigates back to the dashboard. */
|
||||
@@ -58,6 +64,8 @@ function PanelEditorContainer({
|
||||
panel,
|
||||
isNew = false,
|
||||
layoutIndex,
|
||||
isEditable,
|
||||
editDisabledReason,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: PanelEditorContainerProps): JSX.Element {
|
||||
@@ -143,9 +151,13 @@ function PanelEditorContainer({
|
||||
onSelectUnit: seedFormattingUnit,
|
||||
});
|
||||
|
||||
// Spec and query dirtiness are tracked independently so query re-serialization
|
||||
// never false-dirties. A new panel is always savable (you're creating it).
|
||||
const isDirty = isNew || isSpecDirty || isQueryDirty;
|
||||
// A new panel is savable once it has a query to run — List auto-seeds one; other
|
||||
// kinds open query-less, so there's nothing to save until the user builds one.
|
||||
const isDirty = useMemo(
|
||||
() => isSpecDirty || isQueryDirty || (isNew && draft.spec.queries.length > 0),
|
||||
[isSpecDirty, isQueryDirty, isNew, draft.spec.queries.length],
|
||||
);
|
||||
|
||||
const isListPanel = panelKind === 'signoz/ListPanel';
|
||||
// The builder-query `signal` literal matches the TelemetrytypesSignalDTO enum
|
||||
// values; cast at this boundary (as ConfigPane does) so the columns editor's
|
||||
@@ -184,7 +196,17 @@ function PanelEditorContainer({
|
||||
return values.length ? Math.min(...values) : undefined;
|
||||
}, [data.response]);
|
||||
|
||||
const onSwitchToView = useSwitchToViewMode({
|
||||
dashboardId,
|
||||
panelId,
|
||||
panelType: PANEL_KIND_TO_PANEL_TYPE[panelKind],
|
||||
query: currentQuery,
|
||||
});
|
||||
|
||||
const onSave = useCallback(async (): Promise<void> => {
|
||||
if (!isEditable) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Bake the live query into the spec so unstaged edits are saved too.
|
||||
await save(buildSaveSpec(draft.spec));
|
||||
@@ -193,14 +215,18 @@ function PanelEditorContainer({
|
||||
} catch {
|
||||
toast.error('Failed to save panel');
|
||||
}
|
||||
}, [save, buildSaveSpec, draft.spec, onSaved]);
|
||||
}, [isEditable, save, buildSaveSpec, draft.spec, onSaved]);
|
||||
|
||||
return (
|
||||
<div className={styles.page} data-testid="panel-editor-v2">
|
||||
<Header
|
||||
isDirty={isDirty}
|
||||
isSaving={isSaving}
|
||||
showSwitchToView={!isNew}
|
||||
readOnly={!isEditable}
|
||||
readOnlyReason={editDisabledReason}
|
||||
onSave={onSave}
|
||||
onSwitchToView={onSwitchToView}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<ResizablePanelGroup
|
||||
|
||||
@@ -24,7 +24,7 @@ export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: false,
|
||||
download: { csv: false, png: true, svg: true },
|
||||
createAlert: true,
|
||||
search: false,
|
||||
drilldown: true,
|
||||
|
||||
@@ -24,7 +24,7 @@ export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: false,
|
||||
download: { csv: false, png: true, svg: true },
|
||||
createAlert: true,
|
||||
search: false,
|
||||
drilldown: false,
|
||||
|
||||
@@ -21,6 +21,11 @@
|
||||
|
||||
// Logs: drop the row separators for a denser log view (V1 logs table parity).
|
||||
.logRows {
|
||||
// Every row opens the log detail drawer, so flag it as clickable.
|
||||
:global(.ant-table-tbody) > tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:global(.ant-table-tbody) > tr > td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export const definition: PanelDefinition<'signoz/ListPanel'> = {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: false,
|
||||
download: { csv: false, png: true, svg: true },
|
||||
createAlert: false,
|
||||
search: true,
|
||||
drilldown: false,
|
||||
|
||||
@@ -24,7 +24,7 @@ export const definition: PanelDefinition<'signoz/NumberPanel'> = {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: false,
|
||||
download: { csv: false, png: true, svg: true },
|
||||
createAlert: true,
|
||||
search: false,
|
||||
drilldown: true,
|
||||
|
||||
@@ -20,7 +20,7 @@ export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: false,
|
||||
download: { csv: false, png: true, svg: true },
|
||||
createAlert: false,
|
||||
search: false,
|
||||
drilldown: true,
|
||||
|
||||
@@ -18,3 +18,11 @@
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
}
|
||||
|
||||
.clickableCell {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--primary-background);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,10 @@ function panelWith(
|
||||
): PanelOfKind<'signoz/TablePanel'> {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: { plugin: { kind: 'signoz/TablePanel', spec } },
|
||||
spec: {
|
||||
display: { name: 'Table panel' },
|
||||
plugin: { kind: 'signoz/TablePanel', spec },
|
||||
},
|
||||
} as unknown as PanelOfKind<'signoz/TablePanel'>;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import type {
|
||||
PanelQueryData,
|
||||
PanelTable,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
|
||||
import type { PanelOfKind } from '../../../types/rendererProps';
|
||||
import { prepareScalarTables } from '../../../../queryV5/prepareScalarTables';
|
||||
import { buildTableCsvRows, getTableCsvRows } from '../tableCsv';
|
||||
|
||||
// Stub number/unit formatting so assertions cover only the row-building.
|
||||
jest.mock('../../../utils/formatPanelValue', () => ({
|
||||
formatPanelValue: (value: number, unit?: string): string =>
|
||||
`${value}${unit ?? ''}`,
|
||||
}));
|
||||
|
||||
jest.mock('../../../../queryV5/prepareScalarTables', () => ({
|
||||
prepareScalarTables: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../../queryV5/v5ResponseData', () => ({
|
||||
getScalarResults: jest.fn(() => []),
|
||||
}));
|
||||
|
||||
const mockPrepareScalarTables = prepareScalarTables as jest.MockedFunction<
|
||||
typeof prepareScalarTables
|
||||
>;
|
||||
|
||||
const table: PanelTable = {
|
||||
queryName: 'A',
|
||||
legend: '',
|
||||
columns: [
|
||||
{ name: 'service', queryName: 'A', isValueColumn: false, id: 'service' },
|
||||
{ name: 'p99', queryName: 'A', isValueColumn: true, id: 'A' },
|
||||
],
|
||||
rows: [
|
||||
{ data: { service: 'frontend', A: 1234 } },
|
||||
{ data: { service: 'cart', A: 56 } },
|
||||
],
|
||||
};
|
||||
|
||||
describe('buildTableCsvRows', () => {
|
||||
it('keys rows by column name in display order and formats value columns', () => {
|
||||
const rows = buildTableCsvRows({
|
||||
table,
|
||||
columnUnits: { A: 'ms' },
|
||||
decimalPrecision: undefined,
|
||||
});
|
||||
|
||||
expect(rows).toStrictEqual([
|
||||
{ service: 'frontend', p99: '1234ms' },
|
||||
{ service: 'cart', p99: '56ms' },
|
||||
]);
|
||||
expect(Object.keys(rows[0])).toStrictEqual(['service', 'p99']);
|
||||
});
|
||||
|
||||
it('renders group columns and non-numeric value cells as raw text', () => {
|
||||
const rows = buildTableCsvRows({
|
||||
table: {
|
||||
...table,
|
||||
rows: [{ data: { service: 'api', A: 'n/a' } }],
|
||||
},
|
||||
columnUnits: {},
|
||||
decimalPrecision: undefined,
|
||||
});
|
||||
|
||||
expect(rows).toStrictEqual([{ service: 'api', p99: 'n/a' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTableCsvRows', () => {
|
||||
const panel = {
|
||||
spec: { plugin: { spec: { formatting: { columnUnits: { A: 'ms' } } } } },
|
||||
} as unknown as PanelOfKind<'signoz/TablePanel'>;
|
||||
const data = {} as PanelQueryData;
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('prepares the scalar table and flattens the first non-empty one to rows', () => {
|
||||
mockPrepareScalarTables.mockReturnValue([table]);
|
||||
|
||||
expect(getTableCsvRows(panel, data)).toStrictEqual([
|
||||
{ service: 'frontend', p99: '1234ms' },
|
||||
{ service: 'cart', p99: '56ms' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns no rows when the response has no table', () => {
|
||||
mockPrepareScalarTables.mockReturnValue([]);
|
||||
|
||||
expect(getTableCsvRows(panel, data)).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,7 @@ export const definition: PanelDefinition<'signoz/TablePanel'> = {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: true,
|
||||
download: { csv: true, png: true, svg: true },
|
||||
createAlert: false,
|
||||
// V1 parity: only tables (and lists) expose the header search box.
|
||||
search: true,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { TableProps } from 'antd';
|
||||
import type { DashboardtypesTableThresholdDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PrecisionOption } from 'components/Graph/types';
|
||||
import type { PanelTable } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import type {
|
||||
PanelTable,
|
||||
PanelTableColumn,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import { coerceToString } from 'utils/stringUtils';
|
||||
|
||||
import type { PanelThreshold } from '../../types/threshold';
|
||||
@@ -10,6 +13,8 @@ import { formatPanelValue } from '../../utils/formatPanelValue';
|
||||
import { getColumnUnit } from '../../utils/getColumnUnit';
|
||||
import { toPanelThreshold } from '../../utils/mapComparisonThreshold';
|
||||
|
||||
import styles from './TablePanel.module.scss';
|
||||
|
||||
/** A prepared scalar-table row flattened for the antd Table, with the antd key. */
|
||||
export type TableRowData = Record<string, unknown> & { key: number };
|
||||
|
||||
@@ -28,6 +33,26 @@ export function mapTableThresholds(
|
||||
return byColumn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain-text value of a table cell (value columns formatted through unit +
|
||||
* precision, group columns raw). Shared by the renderer and the CSV export.
|
||||
*/
|
||||
export function formatTableCellText(
|
||||
col: PanelTableColumn,
|
||||
raw: unknown,
|
||||
unit: string | undefined,
|
||||
decimalPrecision?: PrecisionOption,
|
||||
): string {
|
||||
if (!col.isValueColumn) {
|
||||
return coerceToString(raw);
|
||||
}
|
||||
const num = Number(raw);
|
||||
if (!Number.isFinite(num)) {
|
||||
return coerceToString(raw);
|
||||
}
|
||||
return formatPanelValue(num, unit, decimalPrecision);
|
||||
}
|
||||
|
||||
// Sort comparator: numeric when both cells parse as numbers (value columns and
|
||||
// numeric group keys), otherwise a locale string compare. Nullish sorts last.
|
||||
function compareCells(a: unknown, b: unknown): number {
|
||||
@@ -87,15 +112,13 @@ export function buildTableColumns({
|
||||
sorter: (a: TableRowData, b: TableRowData): number =>
|
||||
compareCells(a[key], b[key]),
|
||||
render: (raw: unknown): React.ReactNode => {
|
||||
if (!col.isValueColumn) {
|
||||
return coerceToString(raw);
|
||||
}
|
||||
const text = formatTableCellText(col, raw, unit, decimalPrecision);
|
||||
const num = Number(raw);
|
||||
if (!Number.isFinite(num)) {
|
||||
return coerceToString(raw);
|
||||
}
|
||||
const text = formatPanelValue(num, unit, decimalPrecision);
|
||||
if (colThresholds.length === 0) {
|
||||
if (
|
||||
!col.isValueColumn ||
|
||||
colThresholds.length === 0 ||
|
||||
!Number.isFinite(num)
|
||||
) {
|
||||
return text;
|
||||
}
|
||||
const { threshold } = resolveActiveThreshold(colThresholds, num, unit);
|
||||
@@ -120,7 +143,7 @@ export function buildTableColumns({
|
||||
if (onCellClick) {
|
||||
cellProps.onClick = (event): void =>
|
||||
onCellClick({ columnId: key, record, event });
|
||||
cellProps.style = { ...cellProps.style, cursor: 'pointer' };
|
||||
cellProps.className = styles.clickableCell;
|
||||
}
|
||||
|
||||
return cellProps;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { PrecisionOption } from 'components/Graph/types';
|
||||
import { prepareScalarTables } from 'pages/DashboardPageV2/DashboardContainer/queryV5/prepareScalarTables';
|
||||
import type {
|
||||
PanelQueryData,
|
||||
PanelTable,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import { getScalarResults } from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
|
||||
|
||||
import { resolveDecimalPrecision } from '../../utils/chartAppearance/resolvers';
|
||||
import { getColumnUnit } from '../../utils/getColumnUnit';
|
||||
import type { PanelOfKind } from '../../types/rendererProps';
|
||||
|
||||
import { formatTableCellText } from './tableColumns';
|
||||
|
||||
interface BuildTableCsvRowsArgs {
|
||||
table: PanelTable;
|
||||
/** Per-column display unit (`formatting.columnUnits`), keyed by column key. */
|
||||
columnUnits: Record<string, string>;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens a prepared table into CSV rows keyed by column name, reusing the
|
||||
* on-screen cell formatting in display column order. Exports the full result
|
||||
* set, not the paginated view (V1 parity).
|
||||
*/
|
||||
export function buildTableCsvRows({
|
||||
table,
|
||||
columnUnits,
|
||||
decimalPrecision,
|
||||
}: BuildTableCsvRowsArgs): Record<string, string>[] {
|
||||
return table.rows.map((row) => {
|
||||
const csvRow: Record<string, string> = {};
|
||||
table.columns.forEach((col) => {
|
||||
const key = col.id || col.name;
|
||||
const unit = getColumnUnit(key, columnUnits);
|
||||
csvRow[col.name] = formatTableCellText(
|
||||
col,
|
||||
row.data[key],
|
||||
unit,
|
||||
decimalPrecision,
|
||||
);
|
||||
});
|
||||
return csvRow;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the scalar table from the query response and flattens it to CSV rows,
|
||||
* reusing the on-screen formatting. Returns [] when the response has no table.
|
||||
*/
|
||||
export function getTableCsvRows(
|
||||
panel: PanelOfKind<'signoz/TablePanel'>,
|
||||
data: PanelQueryData,
|
||||
): Record<string, string>[] {
|
||||
const spec = panel.spec.plugin.spec;
|
||||
const table = prepareScalarTables({
|
||||
results: getScalarResults(data.response),
|
||||
legendMap: data.legendMap ?? {},
|
||||
requestPayload: data.requestPayload,
|
||||
}).find((candidate) => candidate.columns.length > 0);
|
||||
if (!table) {
|
||||
return [];
|
||||
}
|
||||
return buildTableCsvRows({
|
||||
table,
|
||||
columnUnits: spec.formatting?.columnUnits ?? {},
|
||||
decimalPrecision: resolveDecimalPrecision(spec.formatting?.decimalPrecision),
|
||||
});
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: false,
|
||||
download: { csv: false, png: true, svg: true },
|
||||
createAlert: true,
|
||||
search: false,
|
||||
drilldown: true,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import type { FilterData } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
// Drilldown is the click-to-context-menu feature ported from V1. Every renderer turns its native
|
||||
// click into one `DrilldownClickPayload`; the kind-agnostic orchestration layer consumes only that.
|
||||
@@ -34,3 +36,13 @@ export interface DrilldownClickPayload {
|
||||
coordinates: { x: number; y: number };
|
||||
context: DrilldownContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the View modal on a refined drilldown query (filter-by-value / breakout). In the grid this
|
||||
* navigates to the modal seeded with the query; inside the modal it refines the view in place.
|
||||
*/
|
||||
export type OpenDrilldownView = (
|
||||
panelId: string,
|
||||
query: Query,
|
||||
panelType: PANEL_TYPES,
|
||||
) => void;
|
||||
|
||||
@@ -8,27 +8,29 @@ import type { PanelKind } from './panelKind';
|
||||
import type { QueryBuilderFieldRule } from './panelCapabilities';
|
||||
import type { BaseRendererProps, PanelRendererProps } from './rendererProps';
|
||||
|
||||
/** Export formats offered under the single "Download" action. */
|
||||
export enum DownloadFormat {
|
||||
CSV = 'csv',
|
||||
PNG = 'png',
|
||||
SVG = 'svg',
|
||||
}
|
||||
|
||||
/**
|
||||
* Which panel actions a kind supports. Required field, so registering a new
|
||||
* kind forces an explicit decision for every action. Chrome actions (move to
|
||||
* section, clone, delete) are dashboard-layout concerns available to every
|
||||
* panel and are intentionally not declarable here.
|
||||
* Which actions a kind supports, declared per-kind in `kinds/<Kind>/definition.ts`.
|
||||
* Chrome actions (move, clone, delete) are layout concerns and aren't declared here.
|
||||
*/
|
||||
export interface PanelActionCapabilities {
|
||||
/** Kind has a full-screen view — gates the "View" action. */
|
||||
/** Gates the "View" action. */
|
||||
view: boolean;
|
||||
/** Kind is editable in the V2 panel editor — gates the "Edit panel" action. */
|
||||
/** Gates the "Edit panel" action. */
|
||||
edit: boolean;
|
||||
/** Kind can be cloned — gates the "Clone" action. */
|
||||
/** Gates the "Clone" action. */
|
||||
clone: boolean;
|
||||
/** Gates "Download as CSV". V1 parity: only table panels carry exportable data. */
|
||||
download: boolean;
|
||||
/** Kind's query can seed a new alert — gates "Create Alerts". */
|
||||
/** Which formats this kind can be downloaded as (CSV is table-only). */
|
||||
download: Record<DownloadFormat, boolean>;
|
||||
/** Gates "Create Alerts". */
|
||||
createAlert: boolean;
|
||||
/**
|
||||
* Header search box that filters rendered rows client-side (V1 parity: only
|
||||
* tabular kinds). Not a menu action — the renderer must consume `searchTerm`.
|
||||
*/
|
||||
/** Client-side header search box, consumed by the renderer via `searchTerm`. */
|
||||
search: boolean;
|
||||
/**
|
||||
* Kind supports click-to-drilldown (context menu + View/Breakout). V1 parity: charts + scalar
|
||||
@@ -51,13 +53,10 @@ export interface PanelDefinition<K extends PanelKind = PanelKind> {
|
||||
actions: PanelActionCapabilities;
|
||||
}
|
||||
|
||||
// Total over PanelKind: every kind must be registered (missing → compile error),
|
||||
// so getPanelDefinition never returns undefined.
|
||||
// Every kind must be registered, so getPanelDefinition never returns undefined.
|
||||
export type PanelRegistry = { [K in PanelKind]: PanelDefinition<K> };
|
||||
|
||||
// PanelDefinition with its Renderer widened to the kind-agnostic prop surface.
|
||||
// getPanelDefinition resolves to this, concentrating the unavoidable cast in one
|
||||
// place rather than leaking it to every call site (the kind isn't known statically).
|
||||
export interface RenderablePanelDefinition extends Omit<
|
||||
PanelDefinition,
|
||||
'Renderer'
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { unparse } from 'papaparse';
|
||||
|
||||
import { toSafeFileName } from './toSafeFileName';
|
||||
|
||||
/** Serializes rows (keyed by column header) to CSV and downloads the file. */
|
||||
export function downloadCsv(
|
||||
rows: Record<string, string>[],
|
||||
fileBaseName: string,
|
||||
): void {
|
||||
const csv = unparse(rows);
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${toSafeFileName(fileBaseName)}.csv`;
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** Makes a download filename safe across OSes; falls back when empty. */
|
||||
export function toSafeFileName(name: string): string {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
return 'panel';
|
||||
}
|
||||
return trimmed.replace(/[\\/:*?"<>|]+/g, '-');
|
||||
}
|
||||
@@ -76,10 +76,14 @@ function Panel({
|
||||
<div
|
||||
className={styles.panel}
|
||||
data-panel-visible={isVisible ? 'true' : 'false'}
|
||||
// Stable locator so the "Download as PNG" action can find this node to
|
||||
// capture, without threading a ref through the header/actions chain.
|
||||
data-panel-root={panelId}
|
||||
>
|
||||
<PanelHeader
|
||||
panelId={panelId}
|
||||
panel={panel}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
warning={data.response?.data?.warning}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { EllipsisVertical } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
|
||||
import ConfirmDeleteDialog from '../../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
|
||||
import type { PanelActionsConfig } from '../Panel';
|
||||
@@ -10,8 +11,10 @@ import styles from './PanelActionsMenu.module.scss';
|
||||
|
||||
interface PanelActionsMenuProps {
|
||||
panelId: string;
|
||||
/** The panel itself — its query seeds "Create Alerts". */
|
||||
/** The panel itself — seeds "Create Alerts" and the download filename. */
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** The panel's query response — the source for "Download as CSV". */
|
||||
data: PanelQueryData;
|
||||
/** Layout context for move/delete — absent outside editable sectioned mode. */
|
||||
panelActions?: PanelActionsConfig;
|
||||
}
|
||||
@@ -24,11 +27,13 @@ interface PanelActionsMenuProps {
|
||||
function PanelActionsMenu({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
panelActions,
|
||||
}: PanelActionsMenuProps): JSX.Element | null {
|
||||
const { items, deleteConfirm } = usePanelActionItems({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
panelActions,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import type { ROLES } from 'types/roles';
|
||||
|
||||
import type { DashboardSection } from '../../../../utils';
|
||||
import { DASHBOARD_LOCKED_REASON } from '../../../../store/slices/editContextSlice';
|
||||
import { useDashboardStore } from '../../../../store/useDashboardStore';
|
||||
import { usePanelActionItems } from '../usePanelActionItems';
|
||||
|
||||
/** Keys of the disabled items, in order. */
|
||||
function disabledKeys(
|
||||
result: ReturnType<typeof usePanelActionItems>,
|
||||
): unknown[] {
|
||||
return result.items
|
||||
.filter((item) => 'disabled' in item && item.disabled)
|
||||
.map((item) => ('key' in item ? item.key : undefined));
|
||||
}
|
||||
|
||||
const mockOpenEditor = jest.fn();
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/hooks/useOpenPanelEditor',
|
||||
@@ -47,6 +58,13 @@ jest.mock('../../hooks/useCreateAlertFromPanel', () => ({
|
||||
useCreateAlertFromPanel: (): jest.Mock => mockCreateAlert,
|
||||
}));
|
||||
|
||||
const mockDownloadImage = jest.fn();
|
||||
jest.mock('../../hooks/useDownloadPanelImage', () => ({
|
||||
useDownloadPanelImage: (): { downloadPanelImage: jest.Mock } => ({
|
||||
downloadPanelImage: mockDownloadImage,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Role is the only thing read off the app context; useComponentPermission runs
|
||||
// for real so the tests exercise the actual role → permission mapping.
|
||||
let mockRole: ROLES = 'ADMIN';
|
||||
@@ -84,9 +102,16 @@ const mockPanel = {
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
|
||||
const mockData = {
|
||||
response: undefined,
|
||||
requestPayload: undefined,
|
||||
legendMap: {},
|
||||
} as PanelQueryData;
|
||||
|
||||
const baseArgs = {
|
||||
panelId: 'panel-1',
|
||||
panel: mockPanel,
|
||||
data: mockData,
|
||||
panelActions: { currentLayoutIndex: 0, sections: TWO_TITLED_SECTIONS },
|
||||
};
|
||||
|
||||
@@ -100,7 +125,7 @@ describe('usePanelActionItems', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockRole = 'ADMIN';
|
||||
useDashboardStore.setState({ isEditable: true });
|
||||
useDashboardStore.setState({ isEditable: true, editDisabledReason: '' });
|
||||
});
|
||||
|
||||
it('ADMIN on an editable dashboard with a known kind gets the full V1-parity set, divider-separated', () => {
|
||||
@@ -110,51 +135,76 @@ describe('usePanelActionItems', () => {
|
||||
'edit-panel',
|
||||
'clone-panel',
|
||||
'divider',
|
||||
'download',
|
||||
'create-alert',
|
||||
'divider',
|
||||
'move',
|
||||
'divider',
|
||||
'delete-panel',
|
||||
]);
|
||||
// download stays hidden: no current kind declares the capability
|
||||
// (V1 parity — CSV export was table-only).
|
||||
// The single "Download" entry is a submenu (PNG/SVG, plus CSV on tables);
|
||||
// it's present for every renderable kind.
|
||||
});
|
||||
|
||||
it('AUTHOR loses edit and clone (edit_widget excludes AUTHOR) but keeps the rest', () => {
|
||||
it('AUTHOR sees edit and clone disabled (edit_widget excludes AUTHOR) but can move and delete', () => {
|
||||
mockRole = 'AUTHOR';
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
// The full set is now always present — the role gate disables rather than hides.
|
||||
expect(itemKeys(result.current)).toStrictEqual([
|
||||
'view-panel',
|
||||
'edit-panel',
|
||||
'clone-panel',
|
||||
'divider',
|
||||
'download',
|
||||
'create-alert',
|
||||
'divider',
|
||||
'move',
|
||||
'divider',
|
||||
'delete-panel',
|
||||
]);
|
||||
});
|
||||
|
||||
it('VIEWER keeps only the role-ungated actions (view, create-alert)', () => {
|
||||
mockRole = 'VIEWER';
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
expect(itemKeys(result.current)).toStrictEqual([
|
||||
'view-panel',
|
||||
'divider',
|
||||
'create-alert',
|
||||
expect(disabledKeys(result.current)).toStrictEqual([
|
||||
'edit-panel',
|
||||
'clone-panel',
|
||||
]);
|
||||
});
|
||||
|
||||
it('read-only dashboard keeps View and Create Alerts (V1 parity: both survive a lock)', () => {
|
||||
useDashboardStore.setState({ isEditable: false });
|
||||
it('VIEWER sees every edit action disabled (no edit permissions)', () => {
|
||||
mockRole = 'VIEWER';
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
expect(disabledKeys(result.current)).toStrictEqual([
|
||||
'edit-panel',
|
||||
'clone-panel',
|
||||
'move',
|
||||
'delete-panel',
|
||||
]);
|
||||
});
|
||||
|
||||
it('a non-editable (locked / no-permission) dashboard disables every edit action', () => {
|
||||
useDashboardStore.setState({
|
||||
isEditable: false,
|
||||
editDisabledReason: DASHBOARD_LOCKED_REASON,
|
||||
});
|
||||
// A read-only dashboard mounts panels without layout context (no panelActions).
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({ ...baseArgs, panelActions: undefined }),
|
||||
);
|
||||
// Create Alerts opens a new tab and never mutates the dashboard, so it
|
||||
// isn't gated on edit access — matching V1's locked-dashboard menu.
|
||||
expect(itemKeys(result.current)).toStrictEqual([
|
||||
'view-panel',
|
||||
'edit-panel',
|
||||
'clone-panel',
|
||||
'divider',
|
||||
'download',
|
||||
'create-alert',
|
||||
'divider',
|
||||
'move',
|
||||
'divider',
|
||||
'delete-panel',
|
||||
]);
|
||||
expect(disabledKeys(result.current)).toStrictEqual([
|
||||
'edit-panel',
|
||||
'clone-panel',
|
||||
'move',
|
||||
'delete-panel',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -277,6 +327,25 @@ describe('usePanelActionItems', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('the Download submenu captures the panel by id, name and chosen format', () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
const download = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'download',
|
||||
) as { children: { key: string; onClick: () => void }[] };
|
||||
|
||||
// TimeSeries declares no CSV capability, so the submenu is just PNG + SVG.
|
||||
expect(download.children.map((c) => c.key)).toStrictEqual([
|
||||
'download-png',
|
||||
'download-svg',
|
||||
]);
|
||||
|
||||
download.children.find((c) => c.key === 'download-png')?.onClick();
|
||||
expect(mockDownloadImage).toHaveBeenCalledWith('panel-1', 'CPU', 'png');
|
||||
|
||||
download.children.find((c) => c.key === 'download-svg')?.onClick();
|
||||
expect(mockDownloadImage).toHaveBeenCalledWith('panel-1', 'CPU', 'svg');
|
||||
});
|
||||
|
||||
it('view opens the View modal for the panel', () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
const view = result.current.items.find(
|
||||
@@ -294,13 +363,4 @@ describe('usePanelActionItems', () => {
|
||||
(createAlert as { onClick: () => void }).onClick();
|
||||
expect(mockCreateAlert).toHaveBeenCalledWith(mockPanel, 'panel-1');
|
||||
});
|
||||
|
||||
it('create-alert seeds an alert from this panel', () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
const createAlert = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'create-alert',
|
||||
);
|
||||
(createAlert as { onClick: () => void }).onClick();
|
||||
expect(mockCreateAlert).toHaveBeenCalledWith(mockPanel, 'panel-1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,6 +37,8 @@ export const PANEL_ACTION_META: Record<PanelActionId, PanelActionMeta> = {
|
||||
view: { capability: 'view' },
|
||||
edit: { permission: 'edit_widget', capability: 'edit' },
|
||||
clone: { permission: 'edit_widget' },
|
||||
// Single entry for every export format (CSV/PNG/SVG); like view it isn't
|
||||
// role-gated (V1 parity). The per-format options live in usePanelActionItems.
|
||||
download: { capability: 'download' },
|
||||
createAlert: { capability: 'createAlert' },
|
||||
// Moving a panel between sections mutates the dashboard layout.
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { type ReactNode, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Bell,
|
||||
CloudDownload,
|
||||
Copy,
|
||||
FolderInput,
|
||||
FolderOutput,
|
||||
Fullscreen,
|
||||
PenLine,
|
||||
Trash2,
|
||||
@@ -18,6 +16,7 @@ import {
|
||||
} from 'hooks/useConfirmableAction';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import { useOpenPanelEditor } from 'pages/DashboardPageV2/DashboardContainer/hooks/useOpenPanelEditor';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
@@ -26,87 +25,24 @@ import type { PanelActionsConfig } from '../Panel';
|
||||
import { useClonePanel } from '../hooks/useClonePanel';
|
||||
import { useCreateAlertFromPanel } from '../hooks/useCreateAlertFromPanel';
|
||||
import { useDeletePanel } from '../hooks/useDeletePanel';
|
||||
import {
|
||||
type MovePanelArgs,
|
||||
useMovePanelToSection,
|
||||
} from '../hooks/useMovePanelToSection';
|
||||
import { useDownloadPanelMenuItem } from '../hooks/useDownloadPanelMenuItem';
|
||||
import { useMovePanelToSection } from '../hooks/useMovePanelToSection';
|
||||
import { useViewPanel } from '../hooks/useViewPanel';
|
||||
import { buildMoveItems } from '../utils/buildMoveItems';
|
||||
import { PANEL_ACTION_META } from './panelActionMeta';
|
||||
import DisabledMenuItemLabel from '../../../components/DisabledMenuItemLabel/DisabledMenuItemLabel';
|
||||
import { DASHBOARD_NO_EDIT_PERMISSION_REASON } from '../../../hooks/useDashboardEditGuard';
|
||||
|
||||
// Stable fallback so renders without layout context don't churn the mutation
|
||||
// hooks' deps (a fresh [] each render would re-create their callbacks).
|
||||
const EMPTY_SECTIONS: DashboardSection[] = [];
|
||||
|
||||
/** Placeholder for V1-parity actions whose V2 implementations land later. */
|
||||
function notImplementedYet(feature: string): void {
|
||||
// eslint-disable-next-line no-alert -- temporary placeholder, see above
|
||||
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;
|
||||
/** The panel itself — its query seeds the "Create Alerts" action. */
|
||||
/** The panel itself — seeds "Create Alerts" and the download filename. */
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** The panel's query response — the source for "Download as CSV". */
|
||||
data: PanelQueryData;
|
||||
/** Layout context for move/delete — absent outside editable mode. */
|
||||
panelActions?: PanelActionsConfig;
|
||||
}
|
||||
@@ -118,19 +54,15 @@ export interface PanelActionItems {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the panel actions menu items (V1 WidgetHeader set plus V2's "Move to
|
||||
* section"). Every action passes three gates before it appears:
|
||||
*
|
||||
* kind — what the panel kind declares it supports (PanelDefinition.actions);
|
||||
* unknown kinds support no kind-gated actions.
|
||||
* role — componentPermission lookup for the current user (PANEL_ACTION_META;
|
||||
* actions without a permission key are open to every role, V1 parity).
|
||||
* context — runtime state: dashboard editable (store), layout config present.
|
||||
* View and Download remain available on read-only dashboards, as in V1.
|
||||
* Resolves the panel actions menu items. Each action passes three gates before
|
||||
* it appears: kind (PanelDefinition.actions), role (useComponentPermission) and
|
||||
* context (dashboard editable + layout config present). View and Download stay
|
||||
* available on read-only dashboards, as in V1.
|
||||
*/
|
||||
export function usePanelActionItems({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
panelActions,
|
||||
}: UsePanelActionItemsArgs): PanelActionItems {
|
||||
const panelKind = panel.spec.plugin.kind;
|
||||
@@ -145,18 +77,24 @@ export function usePanelActionItems({
|
||||
user.role,
|
||||
);
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const editDisabledReason = useDashboardStore((s) => s.editDisabledReason);
|
||||
const openPanelEditor = useOpenPanelEditor();
|
||||
const createAlert = useCreateAlertFromPanel();
|
||||
const { openView } = useViewPanel();
|
||||
|
||||
// Mutations are store-backed (dashboardId/refetch) — the layout tree only
|
||||
// supplies data (`sections`), so no callbacks are threaded through it.
|
||||
// Mutations are store-backed; the layout tree only supplies `sections`.
|
||||
const sections = panelActions?.sections ?? EMPTY_SECTIONS;
|
||||
const movePanel = useMovePanelToSection({ sections });
|
||||
const deletePanel = useDeletePanel({ sections });
|
||||
const clonePanel = useClonePanel({ sections });
|
||||
|
||||
const panelCapabilities = getPanelDefinition(panelKind).actions;
|
||||
const downloadItem = useDownloadPanelMenuItem({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
actions: panelCapabilities,
|
||||
});
|
||||
|
||||
// Delete runs on confirm, not on click — the menu item opens a prompt.
|
||||
const deleteConfirm = useConfirmableAction(
|
||||
@@ -174,6 +112,23 @@ export function usePanelActionItems({
|
||||
const { request: requestDelete } = deleteConfirm;
|
||||
|
||||
const items = useMemo<MenuItem[]>(() => {
|
||||
// The reason an edit action is unavailable: dashboard not editable (locked /
|
||||
// no permission) takes precedence, else the missing widget-level role
|
||||
// permission. Empty string ⇒ the action is enabled.
|
||||
const reasonFor = (hasRolePerm: boolean): string => {
|
||||
if (!isEditable) {
|
||||
return editDisabledReason;
|
||||
}
|
||||
return hasRolePerm ? '' : DASHBOARD_NO_EDIT_PERMISSION_REASON;
|
||||
};
|
||||
// Disabled rows keep a hover tooltip via DisabledMenuItemLabel (see component).
|
||||
const label = (reason: string, text: string): ReactNode =>
|
||||
reason ? (
|
||||
<DisabledMenuItemLabel reason={reason}>{text}</DisabledMenuItemLabel>
|
||||
) : (
|
||||
text
|
||||
);
|
||||
|
||||
const panelGroup: MenuItem[] = [];
|
||||
if (panelCapabilities.view) {
|
||||
panelGroup.push({
|
||||
@@ -183,41 +138,43 @@ export function usePanelActionItems({
|
||||
onClick: (): void => openView(panelId),
|
||||
});
|
||||
}
|
||||
if (isEditable && canEditWidget && panelCapabilities.edit) {
|
||||
if (panelCapabilities.edit) {
|
||||
const reason = reasonFor(canEditWidget);
|
||||
panelGroup.push({
|
||||
key: 'edit-panel',
|
||||
label: 'Edit panel',
|
||||
label: label(reason, 'Edit panel'),
|
||||
icon: <PenLine size={14} />,
|
||||
disabled: !!reason,
|
||||
onClick: (): void => openPanelEditor(panelId),
|
||||
});
|
||||
}
|
||||
// Clone needs the section context (source spec + dimensions) to place the
|
||||
// copy, so — unlike Edit — it requires panelActions.
|
||||
if (isEditable && canEditWidget && panelActions && panelCapabilities.clone) {
|
||||
if (panelCapabilities.clone) {
|
||||
// Clone needs the section context to place the copy; without it (read-only
|
||||
// mount) it stays disabled.
|
||||
const reason = reasonFor(canEditWidget);
|
||||
panelGroup.push({
|
||||
key: 'clone-panel',
|
||||
label: 'Clone',
|
||||
label: label(reason, 'Clone'),
|
||||
icon: <Copy size={14} />,
|
||||
onClick: (): void =>
|
||||
void clonePanel({
|
||||
panelId,
|
||||
layoutIndex: panelActions.currentLayoutIndex,
|
||||
}),
|
||||
disabled: !!reason || !panelActions,
|
||||
onClick: (): void => {
|
||||
if (panelActions) {
|
||||
void clonePanel({
|
||||
panelId,
|
||||
layoutIndex: panelActions.currentLayoutIndex,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const dataGroup: MenuItem[] = [];
|
||||
if (panelCapabilities.download) {
|
||||
dataGroup.push({
|
||||
key: 'download-panel',
|
||||
label: 'Download as CSV',
|
||||
icon: <CloudDownload size={14} />,
|
||||
onClick: (): void => notImplementedYet('Download'),
|
||||
});
|
||||
if (downloadItem) {
|
||||
dataGroup.push(downloadItem);
|
||||
}
|
||||
// Seeding an alert opens a new tab and never mutates the dashboard, so —
|
||||
// unlike edit/clone — it isn't gated on `isEditable` (V1 parity: available
|
||||
// on locked dashboards too).
|
||||
|
||||
// Create Alerts opens a new tab and never mutates the dashboard, so —
|
||||
// unlike edit/clone — it isn't gated on editability (V1 parity).
|
||||
if (panelCapabilities.createAlert) {
|
||||
dataGroup.push({
|
||||
key: 'create-alert',
|
||||
@@ -227,28 +184,35 @@ export function usePanelActionItems({
|
||||
});
|
||||
}
|
||||
|
||||
const moveReason = reasonFor(canMove);
|
||||
const moveGroup: MenuItem[] =
|
||||
canMove && panelActions
|
||||
!moveReason && panelActions
|
||||
? buildMoveItems({
|
||||
sections,
|
||||
currentLayoutIndex: panelActions.currentLayoutIndex,
|
||||
panelId,
|
||||
movePanel,
|
||||
})
|
||||
: [];
|
||||
|
||||
const deleteGroup: MenuItem[] =
|
||||
canDelete && panelActions
|
||||
? [
|
||||
: [
|
||||
{
|
||||
key: 'delete-panel',
|
||||
danger: true,
|
||||
icon: <Trash2 size={14} />,
|
||||
label: 'Delete panel',
|
||||
onClick: (): void => requestDelete(),
|
||||
key: 'move',
|
||||
label: label(moveReason, 'Move to section'),
|
||||
icon: <FolderInput size={14} />,
|
||||
disabled: true,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
];
|
||||
|
||||
const deleteReason = reasonFor(canDelete);
|
||||
const deleteGroup: MenuItem[] = [
|
||||
{
|
||||
key: 'delete-panel',
|
||||
danger: true,
|
||||
icon: <Trash2 size={14} />,
|
||||
label: label(deleteReason, 'Delete panel'),
|
||||
disabled: !!deleteReason || !panelActions,
|
||||
onClick: (): void => requestDelete(),
|
||||
},
|
||||
];
|
||||
|
||||
return [panelGroup, dataGroup, moveGroup, deleteGroup]
|
||||
.filter((group) => group.length > 0)
|
||||
@@ -257,6 +221,7 @@ export function usePanelActionItems({
|
||||
);
|
||||
}, [
|
||||
isEditable,
|
||||
editDisabledReason,
|
||||
canEditWidget,
|
||||
canMove,
|
||||
canDelete,
|
||||
@@ -265,6 +230,7 @@ export function usePanelActionItems({
|
||||
panelActions,
|
||||
sections,
|
||||
panelId,
|
||||
downloadItem,
|
||||
openView,
|
||||
openPanelEditor,
|
||||
createAlert,
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import type { PanelTimePreferenceLabel } from 'pages/DashboardPageV2/DashboardContainer/hooks/resolvePanelTimeWindow';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
|
||||
import type { PanelActionsConfig } from '../Panel';
|
||||
import PanelActionsMenu from '../PanelActionsMenu/PanelActionsMenu';
|
||||
@@ -23,6 +24,8 @@ interface PanelHeaderProps {
|
||||
panelId: string;
|
||||
/** The panel itself — its query seeds the menu's "Create Alerts" action. */
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** The panel's query response — the menu's source for "Download as CSV". */
|
||||
data: PanelQueryData;
|
||||
/** Background refresh in flight — shows a spinner without blinking the chart. */
|
||||
isFetching: boolean;
|
||||
/** Latest query error — surfaced as a header error indicator. */
|
||||
@@ -51,6 +54,7 @@ interface PanelHeaderProps {
|
||||
function PanelHeader({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
isFetching,
|
||||
error,
|
||||
warning,
|
||||
@@ -117,6 +121,7 @@ function PanelHeader({
|
||||
<PanelActionsMenu
|
||||
panelId={panelId}
|
||||
panel={panel}
|
||||
data={data}
|
||||
panelActions={panelActions}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LayoutDashboard, Rows2 } from '@signozhq/icons';
|
||||
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
import { findRootSection, type DashboardSection } from '../../../utils';
|
||||
import type { SectionOption } from './types';
|
||||
|
||||
const ROOT_LABEL = 'Dashboard (root)';
|
||||
@@ -11,8 +11,9 @@ const SECTION_DESCRIPTION = 'Section';
|
||||
export function buildSectionOptions(
|
||||
sections: DashboardSection[],
|
||||
): SectionOption[] {
|
||||
const rootSection = findRootSection(sections);
|
||||
return sections.map((section) => {
|
||||
const isRoot = !section.title && section.layoutIndex === 0;
|
||||
const isRoot = rootSection === section;
|
||||
return {
|
||||
value: String(section.layoutIndex),
|
||||
layoutIndex: section.layoutIndex,
|
||||
|
||||
@@ -2,11 +2,13 @@ import { useMemo } from 'react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import ContextMenu from 'periscope/components/ContextMenu';
|
||||
import PanelEditorQueryBuilder from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/PanelEditorQueryBuilder';
|
||||
import PreviewPane from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/PreviewPane/PreviewPane';
|
||||
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
|
||||
import { useOpenPanelEditor } from 'pages/DashboardPageV2/DashboardContainer/hooks/useOpenPanelEditor';
|
||||
|
||||
import { useDrilldown } from '../hooks/useDrilldown';
|
||||
import { usePanelInteractions } from '../hooks/usePanelInteractions';
|
||||
import ViewPanelModalHeader from './ViewPanelModalHeader';
|
||||
import { useViewPanelMode } from './useViewPanelMode';
|
||||
@@ -48,9 +50,16 @@ function ViewPanelModalContent({
|
||||
onChangePanelKind,
|
||||
resetQuery,
|
||||
buildSaveSpec,
|
||||
applyDrilldownQuery,
|
||||
} = useViewPanelMode({ panel, panelId, time: timeOverride });
|
||||
const { data, isFetching, error, refetch, cancelQuery, pagination } = query;
|
||||
|
||||
// Grid drill-down, but filter-by-value / breakout refine this view in place. Drills the draft
|
||||
// so it reflects in-modal edits (and the click's time range follows the per-view window).
|
||||
const drilldown = useDrilldown(draft, panelId, {
|
||||
openDrilldownView: applyDrilldownQuery,
|
||||
});
|
||||
|
||||
// Drag-to-zoom stays inside the modal; opt the chart out of the dashboard's
|
||||
// cursor-sync group so a drag here can't replay onto the grid panels.
|
||||
const { dashboardPreference } = usePanelInteractions();
|
||||
@@ -115,9 +124,12 @@ function ViewPanelModalContent({
|
||||
panelMode={PanelMode.STANDALONE_VIEW}
|
||||
dashboardPreference={isolatedPreference}
|
||||
onCloseStandaloneView={onClose}
|
||||
onClick={drilldown.onPanelClick}
|
||||
enableDrillDown={drilldown.enableDrillDown}
|
||||
hideHeader
|
||||
/>
|
||||
</div>
|
||||
<ContextMenu {...drilldown.contextMenuProps} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQue
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { usePanelEditSession } from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/hooks/usePanelEditSession';
|
||||
import type { OpenDrilldownView } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/drilldown';
|
||||
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
@@ -56,6 +57,11 @@ export interface UseViewPanelModeReturn {
|
||||
buildSaveSpec: (
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
) => DashboardtypesPanelSpecDTO;
|
||||
/**
|
||||
* Drill-down handoff for filter-by-value / breakout: refine the view in place (persist to the
|
||||
* URL so it survives refresh, and re-run the preview), rather than opening a new View modal.
|
||||
*/
|
||||
applyDrilldownQuery: OpenDrilldownView;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,6 +107,7 @@ export function useViewPanelMode({
|
||||
onChangePanelKind,
|
||||
buildSaveSpec,
|
||||
reset,
|
||||
setSpec,
|
||||
} = usePanelEditSession({ panel: initialPanel, panelId, time });
|
||||
|
||||
// The query the view opened with, captured once — the Reset target.
|
||||
@@ -119,6 +126,31 @@ export function useViewPanelMode({
|
||||
redirectWithQueryBuilderData(savedQuery);
|
||||
}, [reset, redirectWithQueryBuilderData, savedQuery]);
|
||||
|
||||
// redirectWithQueryBuilderData (not the grid's openViewWithQuery): the cloned query keeps its id,
|
||||
// so the QB provider's `stagedQuery.id === url id` guard would skip a plain URL write. setSpec
|
||||
// commits into the draft too — filter/breakout aren't a structural change, so it won't auto-commit.
|
||||
const applyDrilldownQuery = useCallback<OpenDrilldownView>(
|
||||
(viewPanelId, drilldownQuery, drilldownPanelType): void => {
|
||||
redirectWithQueryBuilderData(
|
||||
drilldownQuery,
|
||||
{
|
||||
[QueryParams.expandedWidgetId]: viewPanelId,
|
||||
[QueryParams.graphType]: drilldownPanelType,
|
||||
},
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
setSpec(
|
||||
buildViewPanelSpec({
|
||||
spec: draft.spec,
|
||||
query: drilldownQuery,
|
||||
panelType: drilldownPanelType,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[redirectWithQueryBuilderData, setSpec, draft.spec],
|
||||
);
|
||||
|
||||
// Current builder datasource — resolved the same way as the full editor's
|
||||
// ConfigPane so the two selectors stay in sync, then defaulted to the kind's first
|
||||
// signal (PromQL/ClickHouse carry none) so the query builder always has one.
|
||||
@@ -135,5 +167,6 @@ export function useViewPanelMode({
|
||||
onChangePanelKind,
|
||||
resetQuery,
|
||||
buildSaveSpec,
|
||||
applyDrilldownQuery,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import type { ReactElement } from 'react';
|
||||
import type { Warning } from 'types/api';
|
||||
|
||||
@@ -43,6 +44,11 @@ function makePanel(overrides?: {
|
||||
const baseProps = {
|
||||
panel: makePanel(),
|
||||
panelId: 'panel-1',
|
||||
data: {
|
||||
response: undefined,
|
||||
requestPayload: undefined,
|
||||
legendMap: {},
|
||||
} as PanelQueryData,
|
||||
isFetching: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -47,10 +47,28 @@ jest.mock('../ViewPanelModal/useViewPanelMode', () => ({
|
||||
resetQuery: jest.fn(),
|
||||
signal: 'logs',
|
||||
buildSaveSpec: (spec: unknown): unknown => spec,
|
||||
applyDrilldownQuery: jest.fn(),
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
// Drill-down orchestration (popover, submenus, View-in-X) has its own suite
|
||||
// (useDrilldown.test.tsx) and pulls in router/redux/react-query; stub it so this
|
||||
// suite only asserts that the modal arms the preview and renders the menu host.
|
||||
const mockOnPanelClick = jest.fn();
|
||||
jest.mock('../hooks/useDrilldown', () => ({
|
||||
useDrilldown: (): unknown => ({
|
||||
enableDrillDown: true,
|
||||
onPanelClick: mockOnPanelClick,
|
||||
contextMenuProps: {
|
||||
coordinates: null,
|
||||
popoverPosition: null,
|
||||
items: null,
|
||||
onClose: jest.fn(),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// The View modal reuses the edit page's query builder, which reads the global
|
||||
// QueryBuilder context and pulls in the ClickHouse/PromQL editors; stub it here.
|
||||
jest.mock(
|
||||
@@ -174,4 +192,23 @@ describe('ViewPanelModal', () => {
|
||||
};
|
||||
expect(props.dashboardPreference?.syncMode).toBe(DashboardCursorSync.None);
|
||||
});
|
||||
|
||||
// Parity with the grid: the View modal arms the same drill-down click on the preview.
|
||||
it('arms drill-down on the preview', () => {
|
||||
mockPreviewPaneRender.mockClear();
|
||||
renderWithProvider(
|
||||
<ViewPanelModal
|
||||
panel={makePanel('signoz/TimeSeriesPanel')}
|
||||
panelId="p1"
|
||||
open
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
const props = mockPreviewPaneRender.mock.calls.at(-1)?.[0] as {
|
||||
onClick?: unknown;
|
||||
enableDrillDown?: boolean;
|
||||
};
|
||||
expect(props.enableDrillDown).toBe(true);
|
||||
expect(props.onClick).toBe(mockOnPanelClick);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { getTableCsvRows } from 'pages/DashboardPageV2/DashboardContainer/Panels/kinds/TablePanel/tableCsv';
|
||||
import { downloadCsv } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/downloadCsv';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
|
||||
import { useDownloadPanelCsv } from '../useDownloadPanelCsv';
|
||||
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/Panels/kinds/TablePanel/tableCsv',
|
||||
() => ({ getTableCsvRows: jest.fn() }),
|
||||
);
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/Panels/utils/downloadCsv',
|
||||
() => ({ downloadCsv: jest.fn() }),
|
||||
);
|
||||
|
||||
const mockGetTableCsvRows = getTableCsvRows as jest.Mock;
|
||||
const mockDownloadCsv = downloadCsv as jest.Mock;
|
||||
|
||||
const data = {} as PanelQueryData;
|
||||
const panelOf = (kind: string): DashboardtypesPanelDTO =>
|
||||
({
|
||||
spec: { display: { name: 'CPU' }, plugin: { kind } },
|
||||
}) as DashboardtypesPanelDTO;
|
||||
|
||||
describe('useDownloadPanelCsv', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('exports the table rows as CSV named after the panel', () => {
|
||||
mockGetTableCsvRows.mockReturnValue([{ service: 'frontend', p99: '1ms' }]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDownloadPanelCsv({
|
||||
panel: panelOf('signoz/TablePanel'),
|
||||
data,
|
||||
canDownloadCsv: true,
|
||||
}),
|
||||
);
|
||||
result.current();
|
||||
|
||||
expect(mockGetTableCsvRows).toHaveBeenCalledTimes(1);
|
||||
expect(mockDownloadCsv).toHaveBeenCalledWith(
|
||||
[{ service: 'frontend', p99: '1ms' }],
|
||||
'CPU',
|
||||
);
|
||||
});
|
||||
|
||||
it('no-ops when the response has no rows', () => {
|
||||
mockGetTableCsvRows.mockReturnValue([]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDownloadPanelCsv({
|
||||
panel: panelOf('signoz/TablePanel'),
|
||||
data,
|
||||
canDownloadCsv: true,
|
||||
}),
|
||||
);
|
||||
result.current();
|
||||
|
||||
expect(mockDownloadCsv).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('no-ops when the kind cannot download CSV, without building rows', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDownloadPanelCsv({
|
||||
panel: panelOf('signoz/TimeSeriesPanel'),
|
||||
data,
|
||||
canDownloadCsv: false,
|
||||
}),
|
||||
);
|
||||
result.current();
|
||||
|
||||
expect(mockGetTableCsvRows).not.toHaveBeenCalled();
|
||||
expect(mockDownloadCsv).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { DownloadFormat } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
|
||||
import { downloadElementAsImage } from '../../utils/downloadPanelImage';
|
||||
import { useDownloadPanelImage } from '../useDownloadPanelImage';
|
||||
|
||||
jest.mock('../../utils/downloadPanelImage', () => ({
|
||||
downloadElementAsImage: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: { error: jest.fn(), dismiss: jest.fn() },
|
||||
}));
|
||||
|
||||
const mockCapture = downloadElementAsImage as jest.MockedFunction<
|
||||
typeof downloadElementAsImage
|
||||
>;
|
||||
const mockToastError = toast.error as jest.Mock;
|
||||
|
||||
function mountPanel(panelId: string): HTMLElement {
|
||||
const node = document.createElement('div');
|
||||
node.setAttribute('data-panel-root', panelId);
|
||||
document.body.appendChild(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
describe('useDownloadPanelImage', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('captures the panel node located by its data-panel-root marker, forwarding the format', async () => {
|
||||
const node = mountPanel('panel-1');
|
||||
mockCapture.mockResolvedValue();
|
||||
|
||||
const { result } = renderHook(() => useDownloadPanelImage());
|
||||
await result.current.downloadPanelImage(
|
||||
'panel-1',
|
||||
'My panel',
|
||||
DownloadFormat.SVG,
|
||||
);
|
||||
|
||||
expect(mockCapture).toHaveBeenCalledWith(
|
||||
node,
|
||||
'My panel',
|
||||
DownloadFormat.SVG,
|
||||
);
|
||||
expect(mockToastError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when no panel matches the id (e.g. unmounted)', async () => {
|
||||
const { result } = renderHook(() => useDownloadPanelImage());
|
||||
await result.current.downloadPanelImage('missing', 'x', DownloadFormat.PNG);
|
||||
|
||||
expect(mockCapture).not.toHaveBeenCalled();
|
||||
expect(mockToastError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces an error notification when the capture fails', async () => {
|
||||
mountPanel('panel-2');
|
||||
mockCapture.mockRejectedValue(new Error('capture boom'));
|
||||
|
||||
const { result } = renderHook(() => useDownloadPanelImage());
|
||||
await result.current.downloadPanelImage('panel-2', 'x', DownloadFormat.PNG);
|
||||
|
||||
expect(mockToastError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PanelActionCapabilities } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
|
||||
import { useDownloadPanelMenuItem } from '../useDownloadPanelMenuItem';
|
||||
|
||||
const mockDownloadCsv = jest.fn();
|
||||
jest.mock('../useDownloadPanelCsv', () => ({
|
||||
useDownloadPanelCsv: (): jest.Mock => mockDownloadCsv,
|
||||
}));
|
||||
|
||||
const mockDownloadImage = jest.fn();
|
||||
jest.mock('../useDownloadPanelImage', () => ({
|
||||
useDownloadPanelImage: (): { downloadPanelImage: jest.Mock } => ({
|
||||
downloadPanelImage: mockDownloadImage,
|
||||
}),
|
||||
}));
|
||||
|
||||
const panel = {
|
||||
spec: { display: { name: 'CPU' }, plugin: { kind: 'signoz/TablePanel' } },
|
||||
} as DashboardtypesPanelDTO;
|
||||
const data = {} as PanelQueryData;
|
||||
|
||||
const download = (
|
||||
formats: PanelActionCapabilities['download'],
|
||||
): PanelActionCapabilities => ({
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: formats,
|
||||
createAlert: true,
|
||||
search: true,
|
||||
drilldown: true,
|
||||
});
|
||||
|
||||
type Submenu = { children: { key: string; onClick: () => void }[] };
|
||||
|
||||
function render(actions: PanelActionCapabilities): { current: unknown } {
|
||||
return renderHook(() =>
|
||||
useDownloadPanelMenuItem({ panelId: 'panel-1', panel, data, actions }),
|
||||
).result;
|
||||
}
|
||||
|
||||
describe('useDownloadPanelMenuItem', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('returns null when the kind supports no download format', () => {
|
||||
const result = render(download({ csv: false, png: false, svg: false }));
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it('offers only the supported formats, dispatching CSV and image to their hooks', () => {
|
||||
const result = render(download({ csv: true, png: true, svg: true }));
|
||||
const item = result.current as Submenu;
|
||||
|
||||
expect(item.children.map((c) => c.key)).toStrictEqual([
|
||||
'download-csv',
|
||||
'download-png',
|
||||
'download-svg',
|
||||
]);
|
||||
|
||||
item.children.find((c) => c.key === 'download-csv')?.onClick();
|
||||
expect(mockDownloadCsv).toHaveBeenCalledTimes(1);
|
||||
expect(mockDownloadImage).not.toHaveBeenCalled();
|
||||
|
||||
item.children.find((c) => c.key === 'download-png')?.onClick();
|
||||
expect(mockDownloadImage).toHaveBeenCalledWith('panel-1', 'CPU', 'png');
|
||||
|
||||
item.children.find((c) => c.key === 'download-svg')?.onClick();
|
||||
expect(mockDownloadImage).toHaveBeenCalledWith('panel-1', 'CPU', 'svg');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { getTableCsvRows } from 'pages/DashboardPageV2/DashboardContainer/Panels/kinds/TablePanel/tableCsv';
|
||||
import type { PanelOfKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
|
||||
import { downloadCsv } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/downloadCsv';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
|
||||
interface UseDownloadPanelCsvArgs {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
data: PanelQueryData;
|
||||
/**
|
||||
* Whether the kind's definition declares CSV as a downloadable format
|
||||
* (`actions.download.csv`). Only tables carry tabular data, so this is the
|
||||
* same gate the menu uses — kept here so the callback stays a no-op when
|
||||
* invoked for a kind that can't produce CSV.
|
||||
*/
|
||||
canDownloadCsv: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a callback that exports the panel's data as CSV, gated on the kind's
|
||||
* declared download capability. Non-CSV kinds get a no-op.
|
||||
*/
|
||||
export function useDownloadPanelCsv({
|
||||
panel,
|
||||
data,
|
||||
canDownloadCsv,
|
||||
}: UseDownloadPanelCsvArgs): () => void {
|
||||
const fileName = panel.spec.display.name;
|
||||
|
||||
return useCallback((): void => {
|
||||
if (!canDownloadCsv) {
|
||||
return;
|
||||
}
|
||||
const rows = getTableCsvRows(panel as PanelOfKind<'signoz/TablePanel'>, data);
|
||||
if (rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
downloadCsv(rows, fileName);
|
||||
}, [canDownloadCsv, fileName, panel, data]);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useCallback } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
|
||||
import {
|
||||
downloadElementAsImage,
|
||||
type PanelImageFormat,
|
||||
} from '../utils/downloadPanelImage';
|
||||
|
||||
interface UseDownloadPanelImage {
|
||||
downloadPanelImage: (
|
||||
panelId: string,
|
||||
panelName: string,
|
||||
format: PanelImageFormat,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a V2 panel as an image (PNG or SVG). Locates the panel's root node
|
||||
* by its `data-panel-root` marker (set in Panel.tsx) so the capture works
|
||||
* without threading a ref through the header → actions-menu chain, then
|
||||
* delegates to the pure capture util. Failures surface as an error toast.
|
||||
*/
|
||||
export function useDownloadPanelImage(): UseDownloadPanelImage {
|
||||
const downloadPanelImage = useCallback(
|
||||
async (
|
||||
panelId: string,
|
||||
panelName: string,
|
||||
format: PanelImageFormat,
|
||||
): Promise<void> => {
|
||||
const node = document.querySelector<HTMLElement>(
|
||||
`[data-panel-root="${CSS.escape(panelId)}"]`,
|
||||
);
|
||||
// The menu lives inside the panel, so the node is normally present;
|
||||
// bail quietly if the panel unmounted between open and click.
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await downloadElementAsImage(node, panelName, format);
|
||||
} catch {
|
||||
toast.error('Could not download panel.', {
|
||||
action: {
|
||||
label: 'Dismiss',
|
||||
onClick: (): void => {
|
||||
toast.dismiss();
|
||||
},
|
||||
},
|
||||
description: 'Something went wrong while capturing the panel image.',
|
||||
});
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { downloadPanelImage };
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
DownloadFormat,
|
||||
type PanelActionCapabilities,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
|
||||
import { buildDownloadMenuItem } from '../utils/buildDownloadMenuItem';
|
||||
import { useDownloadPanelCsv } from './useDownloadPanelCsv';
|
||||
import { useDownloadPanelImage } from './useDownloadPanelImage';
|
||||
|
||||
interface UseDownloadPanelMenuItemArgs {
|
||||
panelId: string;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
data: PanelQueryData;
|
||||
actions: PanelActionCapabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the panel's "Download" submenu item: CSV from the query response,
|
||||
* PNG/SVG from the rendered node. Null when the kind supports no format.
|
||||
*/
|
||||
export function useDownloadPanelMenuItem({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
actions,
|
||||
}: UseDownloadPanelMenuItemArgs): MenuItem | null {
|
||||
const panelName = panel.spec.display.name;
|
||||
const downloadPanelCsv = useDownloadPanelCsv({
|
||||
panel,
|
||||
data,
|
||||
canDownloadCsv: actions.download[DownloadFormat.CSV],
|
||||
});
|
||||
const { downloadPanelImage } = useDownloadPanelImage();
|
||||
|
||||
const onDownload = useCallback(
|
||||
(format: DownloadFormat): void => {
|
||||
if (format === DownloadFormat.CSV) {
|
||||
downloadPanelCsv();
|
||||
return;
|
||||
}
|
||||
void downloadPanelImage(panelId, panelName, format);
|
||||
},
|
||||
[downloadPanelCsv, downloadPanelImage, panelId, panelName],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => buildDownloadMenuItem({ supported: actions.download, onDownload }),
|
||||
[actions.download, onDownload],
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
import type {
|
||||
DrilldownClickPayload,
|
||||
DrilldownContext,
|
||||
OpenDrilldownView,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/drilldown';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
@@ -54,6 +55,14 @@ export interface UseDrilldownResult {
|
||||
contextMenuProps: DrilldownContextMenuProps;
|
||||
}
|
||||
|
||||
export interface UseDrilldownOptions {
|
||||
/**
|
||||
* How filter-by-value / breakout hand off the refined query. Defaults to navigating to the View
|
||||
* modal (grid); the View modal passes its own handler so those actions refine the view in place.
|
||||
*/
|
||||
openDrilldownView?: OpenDrilldownView;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrates panel drill-down: owns the popover + which submenu is open, and routes the clicked
|
||||
* point to the base aggregate menu (View in Logs/Traces), the group filter menu, or the breakout picker.
|
||||
@@ -61,6 +70,7 @@ export interface UseDrilldownResult {
|
||||
export function useDrilldown(
|
||||
panel: DashboardtypesPanelDTO,
|
||||
panelId: string,
|
||||
options?: UseDrilldownOptions,
|
||||
): UseDrilldownResult {
|
||||
const kind = panel.spec.plugin.kind;
|
||||
const panelType = PANEL_KIND_TO_PANEL_TYPE[kind];
|
||||
@@ -122,7 +132,9 @@ export function useDrilldown(
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const { openViewWithQuery } = useViewPanel();
|
||||
// Default handoff navigates to the View modal (grid); the modal overrides this to refine in place.
|
||||
const { openViewWithQuery: navigateToView } = useViewPanel();
|
||||
const openViewWithQuery = options?.openDrilldownView ?? navigateToView;
|
||||
|
||||
const breakout = useDrilldownBreakout({
|
||||
panelId,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { toPng, toSvg } from 'html-to-image';
|
||||
import { DownloadFormat } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
|
||||
import { downloadElementAsImage } from '../downloadPanelImage';
|
||||
|
||||
jest.mock('html-to-image', () => ({ toPng: jest.fn(), toSvg: jest.fn() }));
|
||||
|
||||
const mockToPng = toPng as jest.MockedFunction<typeof toPng>;
|
||||
const mockToSvg = toSvg as jest.MockedFunction<typeof toSvg>;
|
||||
|
||||
describe('downloadElementAsImage', () => {
|
||||
let node: HTMLElement;
|
||||
let fakeLink: {
|
||||
href: string;
|
||||
download: string;
|
||||
click: jest.Mock;
|
||||
remove: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockToPng.mockReset();
|
||||
mockToPng.mockResolvedValue('data:image/png;base64,AAAA');
|
||||
mockToSvg.mockReset();
|
||||
mockToSvg.mockResolvedValue('data:image/svg+xml;base64,BBBB');
|
||||
|
||||
fakeLink = { href: '', download: '', click: jest.fn(), remove: jest.fn() };
|
||||
// Only stub the anchor used for the download; let every other tag (the
|
||||
// elements the filter test builds) fall through to the real DOM.
|
||||
const realCreateElement = document.createElement.bind(document);
|
||||
jest
|
||||
.spyOn(document, 'createElement')
|
||||
.mockImplementation((tag: string) =>
|
||||
tag === 'a'
|
||||
? (fakeLink as unknown as HTMLAnchorElement)
|
||||
: realCreateElement(tag),
|
||||
);
|
||||
|
||||
node = document.createElement('div');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('captures a PNG via the png encoder, named after the panel', async () => {
|
||||
await downloadElementAsImage(node, 'My panel', DownloadFormat.PNG);
|
||||
|
||||
expect(mockToPng).toHaveBeenCalledTimes(1);
|
||||
expect(mockToPng.mock.calls[0][0]).toBe(node);
|
||||
expect(mockToSvg).not.toHaveBeenCalled();
|
||||
expect(fakeLink.href).toBe('data:image/png;base64,AAAA');
|
||||
expect(fakeLink.download).toBe('My panel.png');
|
||||
expect(fakeLink.click).toHaveBeenCalledTimes(1);
|
||||
expect(fakeLink.remove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('captures an SVG via the svg encoder and extension', async () => {
|
||||
await downloadElementAsImage(node, 'My panel', DownloadFormat.SVG);
|
||||
|
||||
expect(mockToSvg).toHaveBeenCalledTimes(1);
|
||||
expect(mockToPng).not.toHaveBeenCalled();
|
||||
expect(fakeLink.href).toBe('data:image/svg+xml;base64,BBBB');
|
||||
expect(fakeLink.download).toBe('My panel.svg');
|
||||
});
|
||||
|
||||
it('filters the actions cluster (.panel-no-drag) out of the capture but keeps content', async () => {
|
||||
await downloadElementAsImage(node, 'x', DownloadFormat.PNG);
|
||||
|
||||
const { filter } = mockToPng.mock.calls[0][1] as {
|
||||
filter: (n: HTMLElement) => boolean;
|
||||
};
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.classList.add('panel-no-drag');
|
||||
const chart = document.createElement('canvas');
|
||||
|
||||
expect(filter(actions)).toBe(false);
|
||||
expect(filter(chart)).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to "panel" when untitled and sanitizes filesystem-unsafe characters', async () => {
|
||||
await downloadElementAsImage(node, ' ', DownloadFormat.PNG);
|
||||
expect(fakeLink.download).toBe('panel.png');
|
||||
|
||||
await downloadElementAsImage(node, 'errors/sec: p99', DownloadFormat.SVG);
|
||||
expect(fakeLink.download).toBe('errors-sec- p99.svg');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
CloudDownload,
|
||||
FileCode,
|
||||
FileImage,
|
||||
FileSpreadsheet,
|
||||
} from '@signozhq/icons';
|
||||
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import {
|
||||
DownloadFormat,
|
||||
type PanelActionCapabilities,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
|
||||
const DOWNLOAD_FORMAT_OPTIONS: {
|
||||
format: DownloadFormat;
|
||||
label: string;
|
||||
icon: JSX.Element;
|
||||
}[] = [
|
||||
{
|
||||
format: DownloadFormat.CSV,
|
||||
label: 'Download as CSV',
|
||||
icon: <FileSpreadsheet size={14} />,
|
||||
},
|
||||
{
|
||||
format: DownloadFormat.PNG,
|
||||
label: 'Download as PNG',
|
||||
icon: <FileImage size={14} />,
|
||||
},
|
||||
{
|
||||
format: DownloadFormat.SVG,
|
||||
label: 'Download as SVG',
|
||||
icon: <FileCode size={14} />,
|
||||
},
|
||||
];
|
||||
|
||||
interface DownloadMenuItemArgs {
|
||||
supported?: PanelActionCapabilities['download'];
|
||||
onDownload: (format: DownloadFormat) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Download" submenu: one option per format the kind supports, each handing
|
||||
* the format to `onDownload`. Null when the kind supports no format.
|
||||
*/
|
||||
export function buildDownloadMenuItem({
|
||||
supported,
|
||||
onDownload,
|
||||
}: DownloadMenuItemArgs): MenuItem | null {
|
||||
if (!supported) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const children: MenuItem[] = DOWNLOAD_FORMAT_OPTIONS.filter(
|
||||
({ format }) => supported[format],
|
||||
).map(({ format, label, icon }) => ({
|
||||
key: `download-${format}`,
|
||||
label,
|
||||
icon,
|
||||
onClick: (): void => onDownload(format),
|
||||
}));
|
||||
|
||||
if (children.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
key: 'download',
|
||||
label: 'Download',
|
||||
icon: <CloudDownload size={14} />,
|
||||
children,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { FolderInput, FolderOutput } from '@signozhq/icons';
|
||||
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
|
||||
import { findRootSection, type DashboardSection } from '../../../utils';
|
||||
import type { MovePanelArgs } from '../hooks/useMovePanelToSection';
|
||||
|
||||
interface MoveItemsArgs {
|
||||
sections: DashboardSection[];
|
||||
currentLayoutIndex: number;
|
||||
panelId: string;
|
||||
movePanel: (args: MovePanelArgs) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Move to section" submenu 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.
|
||||
*/
|
||||
export 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 = findRootSection(sections);
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { toPng, toSvg } from 'html-to-image';
|
||||
import { DownloadFormat } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
import { toSafeFileName } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/toSafeFileName';
|
||||
|
||||
/** Image formats a panel can be exported to (both via html-to-image). */
|
||||
export type PanelImageFormat = DownloadFormat.PNG | DownloadFormat.SVG;
|
||||
|
||||
// The actions cluster (search box, status popovers, three-dot menu) is chrome,
|
||||
// not content — it carries this class to opt out of the grid drag handle (see
|
||||
// PanelHeader), and we reuse it to drop the whole cluster from the capture.
|
||||
const ACTIONS_CONTAINER_CLASS = 'panel-no-drag';
|
||||
|
||||
// Render raster output at 2x so the PNG stays crisp on retina displays. SVG is
|
||||
// vector, so the ratio is irrelevant there (html-to-image ignores it).
|
||||
const CAPTURE_PIXEL_RATIO = 2;
|
||||
|
||||
// Per-format encoder: html-to-image's toPng/toSvg share a signature and both
|
||||
// return a ready-to-download data URL, so they differ only by file extension.
|
||||
const FORMAT_ENCODERS: Record<
|
||||
PanelImageFormat,
|
||||
{ encode: typeof toPng; extension: PanelImageFormat }
|
||||
> = {
|
||||
[DownloadFormat.PNG]: { encode: toPng, extension: DownloadFormat.PNG },
|
||||
[DownloadFormat.SVG]: { encode: toSvg, extension: DownloadFormat.SVG },
|
||||
};
|
||||
|
||||
/**
|
||||
* Captures a panel's rendered DOM node as an image (PNG or SVG) and triggers a
|
||||
* browser download. The hover-only actions cluster is filtered out so the image
|
||||
* is just the panel title plus its chart/table. Resolves once the download is
|
||||
* initiated; rejects if the capture fails (the caller surfaces the error).
|
||||
*/
|
||||
export async function downloadElementAsImage(
|
||||
node: HTMLElement,
|
||||
fileBaseName: string,
|
||||
format: PanelImageFormat,
|
||||
): Promise<void> {
|
||||
const { encode, extension } = FORMAT_ENCODERS[format];
|
||||
|
||||
const dataUrl = await encode(node, {
|
||||
// Skip the actions cluster (and its subtree) — search/status/menu chrome.
|
||||
filter: (domNode) => !domNode.classList?.contains(ACTIONS_CONTAINER_CLASS),
|
||||
// `.panel` paints --l2-background; pass it explicitly so the rounded
|
||||
// corners fill with the panel colour instead of staying transparent.
|
||||
backgroundColor: window.getComputedStyle(node).backgroundColor,
|
||||
pixelRatio: CAPTURE_PIXEL_RATIO,
|
||||
cacheBust: true,
|
||||
});
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = dataUrl;
|
||||
link.download = `${toSafeFileName(fileBaseName)}.${extension}`;
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
|
||||
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
|
||||
|
||||
@@ -29,6 +30,7 @@ interface SectionProps {
|
||||
|
||||
function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const editDisabledReason = useDashboardStore((s) => s.editDisabledReason);
|
||||
const {
|
||||
isPickerOpen,
|
||||
openPicker,
|
||||
@@ -104,22 +106,19 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
onToggle={toggle}
|
||||
repeatVariable={section.repeatVariable}
|
||||
dragHandle={dragHandle}
|
||||
actions={
|
||||
isEditable
|
||||
? {
|
||||
onRename: (): void => setIsRenaming(true),
|
||||
onAddPanel: (): void => openPicker(section.layoutIndex),
|
||||
onDeleteSection: (): void => setIsDeleteOpen(true),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
disabledReason={isEditable ? '' : editDisabledReason}
|
||||
actions={{
|
||||
onRename: (): void => setIsRenaming(true),
|
||||
onAddPanel: (): void => openPicker(section.layoutIndex),
|
||||
onDeleteSection: (): void => setIsDeleteOpen(true),
|
||||
}}
|
||||
/>
|
||||
{open &&
|
||||
(section.items.length > 0 ? (
|
||||
grid
|
||||
) : (
|
||||
<div className={styles.emptySection}>
|
||||
{isEditable && (
|
||||
{isEditable ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="dashed"
|
||||
@@ -130,6 +129,19 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
) : (
|
||||
<TooltipSimple title={editDisabledReason} disableHoverableContent>
|
||||
<Button
|
||||
type="button"
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
prefix={<Plus size="md" />}
|
||||
disabled
|
||||
testId={`section-add-panel-${section.id}`}
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { useMemo } from 'react';
|
||||
import { type ReactNode, useMemo } from 'react';
|
||||
import { EllipsisVertical, PenLine, Plus, Trash2 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
|
||||
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
|
||||
import DisabledMenuItemLabel from '../../../components/DisabledMenuItemLabel/DisabledMenuItemLabel';
|
||||
import styles from './SectionActionsMenu.module.scss';
|
||||
|
||||
interface SectionActionsMenuProps {
|
||||
sectionId: string;
|
||||
/** Non-empty when edits are unavailable — items render disabled with this reason. */
|
||||
disabledReason?: string;
|
||||
onAddPanel?: () => void;
|
||||
onRename?: () => void;
|
||||
onDeleteSection?: () => void;
|
||||
@@ -15,17 +18,28 @@ interface SectionActionsMenuProps {
|
||||
|
||||
function SectionActionsMenu({
|
||||
sectionId,
|
||||
disabledReason = '',
|
||||
onAddPanel,
|
||||
onRename,
|
||||
onDeleteSection,
|
||||
}: SectionActionsMenuProps): JSX.Element {
|
||||
const items = useMemo<MenuItem[]>(() => {
|
||||
const disabled = !!disabledReason;
|
||||
const label = (text: string): ReactNode =>
|
||||
disabled ? (
|
||||
<DisabledMenuItemLabel reason={disabledReason}>
|
||||
{text}
|
||||
</DisabledMenuItemLabel>
|
||||
) : (
|
||||
text
|
||||
);
|
||||
const result: MenuItem[] = [];
|
||||
if (onAddPanel) {
|
||||
result.push({
|
||||
key: 'add-panel',
|
||||
icon: <Plus size={14} />,
|
||||
label: 'Add panel',
|
||||
label: label('Add panel'),
|
||||
disabled,
|
||||
onClick: onAddPanel,
|
||||
});
|
||||
}
|
||||
@@ -33,7 +47,8 @@ function SectionActionsMenu({
|
||||
result.push({
|
||||
key: 'rename',
|
||||
icon: <PenLine size={14} />,
|
||||
label: 'Rename section',
|
||||
label: label('Rename section'),
|
||||
disabled,
|
||||
onClick: onRename,
|
||||
});
|
||||
}
|
||||
@@ -44,13 +59,14 @@ function SectionActionsMenu({
|
||||
key: 'delete-section',
|
||||
danger: true,
|
||||
icon: <Trash2 size={14} />,
|
||||
label: 'Delete section',
|
||||
label: label('Delete section'),
|
||||
disabled,
|
||||
onClick: onDeleteSection,
|
||||
},
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}, [onAddPanel, onRename, onDeleteSection]);
|
||||
}, [disabledReason, onAddPanel, onRename, onDeleteSection]);
|
||||
|
||||
return (
|
||||
<DropdownMenuSimple menu={{ items }}>
|
||||
|
||||
@@ -29,8 +29,10 @@ interface SectionHeaderProps {
|
||||
repeatVariable?: string;
|
||||
/** Provided by SortableSection in sectioned mode; absent for untitled/free-flow. */
|
||||
dragHandle?: SectionDragHandle;
|
||||
/** Present only in editable mode; absent (read-only) when locked/no-permission. */
|
||||
/** The section action handlers (always provided; disabled state gates them). */
|
||||
actions?: SectionHeaderActions;
|
||||
/** Non-empty when edits are unavailable — actions render disabled with this reason. */
|
||||
disabledReason?: string;
|
||||
}
|
||||
|
||||
function SectionHeader({
|
||||
@@ -41,6 +43,7 @@ function SectionHeader({
|
||||
repeatVariable,
|
||||
dragHandle,
|
||||
actions,
|
||||
disabledReason = '',
|
||||
}: SectionHeaderProps): JSX.Element {
|
||||
return (
|
||||
<div className={cx(styles.header, { [styles.headerOpen]: open })}>
|
||||
@@ -79,6 +82,7 @@ function SectionHeader({
|
||||
{actions ? (
|
||||
<SectionActionsMenu
|
||||
sectionId={sectionId}
|
||||
disabledReason={disabledReason}
|
||||
onAddPanel={actions.onAddPanel}
|
||||
onRename={actions.onRename}
|
||||
onDeleteSection={actions.onDeleteSection}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
.label {
|
||||
// Re-enable pointer events so the tooltip fires while the disabled row
|
||||
// (pointer-events: none) suppresses selection.
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
|
||||
import styles from './DisabledMenuItemLabel.module.scss';
|
||||
|
||||
interface DisabledMenuItemLabelProps {
|
||||
reason: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Menu-item label that shows a hover tooltip even though the row is disabled.
|
||||
* A disabled dropdown item has `pointer-events: none`, so the label re-enables
|
||||
* them (`styles.label`) to become a valid hover target for the tooltip.
|
||||
*/
|
||||
function DisabledMenuItemLabel({
|
||||
reason,
|
||||
children,
|
||||
}: DisabledMenuItemLabelProps): JSX.Element {
|
||||
return (
|
||||
<TooltipSimple title={reason} disableHoverableContent>
|
||||
<span className={styles.label}>{children}</span>
|
||||
</TooltipSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default DisabledMenuItemLabel;
|
||||
@@ -21,8 +21,8 @@ jest.mock('api/generated/services/dashboard', () => ({
|
||||
|
||||
jest.mock('../../store/useDashboardStore', () => ({
|
||||
useDashboardStore: jest.fn(
|
||||
(selector: (s: { dashboardId: string }) => unknown) =>
|
||||
selector({ dashboardId: 'dash-1' }),
|
||||
(selector: (s: { dashboardId: string; isEditable: boolean }) => unknown) =>
|
||||
selector({ dashboardId: 'dash-1', isEditable: true }),
|
||||
),
|
||||
}));
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import {
|
||||
DASHBOARD_LOCKED_REASON,
|
||||
DASHBOARD_NO_EDIT_PERMISSION_REASON,
|
||||
} from '../store/slices/editContextSlice';
|
||||
|
||||
// Re-exported from the (dependency-light) store slice so importing just the reason
|
||||
// strings doesn't pull this hook's provider chain into leaf modules / unit tests.
|
||||
export {
|
||||
DASHBOARD_LOCKED_REASON,
|
||||
DASHBOARD_NO_EDIT_PERMISSION_REASON,
|
||||
} from '../store/slices/editContextSlice';
|
||||
|
||||
export interface DashboardEditGuard {
|
||||
/** `canEditDashboard && !isLocked` — the effective gate for performing edits. */
|
||||
isEditable: boolean;
|
||||
isLocked: boolean;
|
||||
/** The user's role grants edit permission, regardless of the lock. */
|
||||
canEditDashboard: boolean;
|
||||
/** Why edits are disabled (locked vs no-permission), for tooltips; '' when editable. */
|
||||
editDisabledReason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for whether a V2 dashboard can be edited, plus the reason
|
||||
* it can't (locked vs no permission) so controls can render disabled with a tooltip.
|
||||
* Used where the store isn't seeded (the panel-editor route reached by direct URL).
|
||||
*/
|
||||
export function useDashboardEditGuard(
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO | undefined,
|
||||
): DashboardEditGuard {
|
||||
const { user } = useAppContext();
|
||||
const [editDashboardPermission] = useComponentPermission(
|
||||
['edit_dashboard'],
|
||||
user.role,
|
||||
);
|
||||
const canEditDashboard = !!editDashboardPermission;
|
||||
const isLocked = !!dashboard?.locked;
|
||||
let editDisabledReason = '';
|
||||
if (isLocked) {
|
||||
editDisabledReason = DASHBOARD_LOCKED_REASON;
|
||||
} else if (!canEditDashboard) {
|
||||
editDisabledReason = DASHBOARD_NO_EDIT_PERMISSION_REASON;
|
||||
}
|
||||
return {
|
||||
isEditable: canEditDashboard && !isLocked,
|
||||
isLocked,
|
||||
canEditDashboard,
|
||||
editDisabledReason,
|
||||
};
|
||||
}
|
||||
@@ -31,9 +31,15 @@ export interface UseDashboardFetchResult {
|
||||
export function useDashboardFetch(
|
||||
dashboardId: string,
|
||||
): UseDashboardFetchResult {
|
||||
const { data, isLoading, isError, error, refetch } = useGetDashboardV2({
|
||||
id: dashboardId,
|
||||
});
|
||||
const { data, isLoading, isError, error, refetch } = useGetDashboardV2(
|
||||
{ id: dashboardId },
|
||||
{
|
||||
// The spec is kept fresh in-cache by optimistic patches (useOptimisticPatch),
|
||||
// so never auto-refetch: it would flash the grid back to server state as
|
||||
// observers mount across the panel tree. Explicit refetch() still works.
|
||||
query: { staleTime: Infinity, refetchOnMount: false },
|
||||
},
|
||||
);
|
||||
const dashboard = data?.data;
|
||||
|
||||
return { dashboard, isLoading, isError, error, refetch };
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useMutation, useQueryClient } from 'react-query';
|
||||
import {
|
||||
getGetDashboardV2QueryKey,
|
||||
@@ -11,6 +12,7 @@ import type {
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { applyJsonPatch } from '../optimistic/applyJsonPatch';
|
||||
import { DASHBOARD_LOCKED_REASON } from '../store/slices/editContextSlice';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
|
||||
/** Cached dashboard snapshot, kept for rollback on error. */
|
||||
@@ -35,6 +37,7 @@ export function useOptimisticPatch(
|
||||
dashboardIdOverride?: string,
|
||||
): UseOptimisticPatch {
|
||||
const storeDashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const storeIsEditable = useDashboardStore((s) => s.isEditable);
|
||||
const dashboardId = dashboardIdOverride ?? storeDashboardId;
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = getGetDashboardV2QueryKey({ id: dashboardId });
|
||||
@@ -69,8 +72,24 @@ export function useOptimisticPatch(
|
||||
},
|
||||
});
|
||||
|
||||
// Defense-in-depth: block edits when the store is warm for this dashboard and
|
||||
// it isn't editable (locked/no-permission). Callers already hide/disable their
|
||||
// controls; this guards any un-gated or future caller. When the store isn't
|
||||
// seeded for this id (e.g. the panel editor reached by direct URL), fall through
|
||||
// — that surface derives editability itself and gates its own save.
|
||||
const { mutateAsync } = mutation;
|
||||
const patchAsync = useCallback(
|
||||
(ops: DashboardtypesJSONPatchOperationDTO[]): Promise<unknown> => {
|
||||
if (storeDashboardId === dashboardId && !storeIsEditable) {
|
||||
return Promise.reject(new Error(DASHBOARD_LOCKED_REASON));
|
||||
}
|
||||
return mutateAsync(ops);
|
||||
},
|
||||
[storeDashboardId, dashboardId, storeIsEditable, mutateAsync],
|
||||
);
|
||||
|
||||
return {
|
||||
patchAsync: mutation.mutateAsync,
|
||||
patchAsync,
|
||||
isPatching: mutation.isLoading,
|
||||
error: mutation.error ?? null,
|
||||
};
|
||||
|
||||
@@ -2,11 +2,10 @@ import { useEffect } from 'react';
|
||||
import { FullScreen, useFullScreenHandle } from 'react-full-screen';
|
||||
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import DashboardPageToolbar from './DashboardPageToolbar';
|
||||
import PanelsAndSectionsLayout from './PanelsAndSectionsLayout';
|
||||
import { useDashboardEditGuard } from './hooks/useDashboardEditGuard';
|
||||
import { useResolvedVariables } from './hooks/useResolvedVariables';
|
||||
import { useDashboardStore } from './store/useDashboardStore';
|
||||
import styles from './DashboardContainer.module.scss';
|
||||
@@ -32,18 +31,15 @@ function DashboardContainer({
|
||||
|
||||
const fullScreenHandle = useFullScreenHandle();
|
||||
|
||||
const { user } = useAppContext();
|
||||
const [editDashboardPermission] = useComponentPermission(
|
||||
['edit_dashboard'],
|
||||
user.role,
|
||||
);
|
||||
const { isLocked, canEditDashboard } = useDashboardEditGuard(dashboard);
|
||||
|
||||
// Seed during render (not an effect) so the first Panel render already sees the id —
|
||||
// useDashboardFetchRequired throws on a missing id. setEditContext self-guards.
|
||||
const setEditContext = useDashboardStore((s) => s.setEditContext);
|
||||
setEditContext({
|
||||
dashboardId: dashboard.id,
|
||||
isEditable: !dashboard.locked && editDashboardPermission,
|
||||
isLocked,
|
||||
canEditDashboard,
|
||||
refetch,
|
||||
});
|
||||
|
||||
@@ -55,11 +51,7 @@ function DashboardContainer({
|
||||
<FullScreen handle={fullScreenHandle}>
|
||||
<div className={styles.container}>
|
||||
<DashboardPageHeader title={name} image={image} />
|
||||
<DashboardPageToolbar
|
||||
dashboard={dashboard}
|
||||
handle={fullScreenHandle}
|
||||
refetch={refetch}
|
||||
/>
|
||||
<DashboardPageToolbar dashboard={dashboard} handle={fullScreenHandle} />
|
||||
<PanelsAndSectionsLayout layouts={spec.layouts} panels={spec.panels} />
|
||||
</div>
|
||||
</FullScreen>
|
||||
|
||||
@@ -2,6 +2,13 @@ import type { StateCreator } from 'zustand';
|
||||
|
||||
import type { DashboardStore } from '../useDashboardStore';
|
||||
|
||||
/** Tooltip reason shown on edit controls disabled because the dashboard is locked. */
|
||||
export const DASHBOARD_LOCKED_REASON = 'This dashboard is locked';
|
||||
|
||||
/** Tooltip reason shown on edit controls disabled for want of edit permission. */
|
||||
export const DASHBOARD_NO_EDIT_PERMISSION_REASON =
|
||||
'You don’t have permission to edit this dashboard';
|
||||
|
||||
/**
|
||||
* Edit context shared across the V2 dashboard tree — the dashboard id, whether
|
||||
* the user can edit, and the react-query refetch. Set once by DashboardContainer
|
||||
@@ -10,11 +17,20 @@ import type { DashboardStore } from '../useDashboardStore';
|
||||
*/
|
||||
export interface EditContextSlice {
|
||||
dashboardId: string;
|
||||
/** `canEditDashboard && !isLocked` — the effective edit gate. */
|
||||
isEditable: boolean;
|
||||
/** The dashboard is locked. Distinct from `isEditable`: edit-permitted users
|
||||
* still see a locked dashboard's controls (disabled), viewers never do. */
|
||||
isLocked: boolean;
|
||||
/** The user's role grants edit permission, regardless of the lock state. */
|
||||
canEditDashboard: boolean;
|
||||
/** Why edits are disabled (locked vs no-permission), for control tooltips; '' when editable. */
|
||||
editDisabledReason: string;
|
||||
refetch: () => void;
|
||||
setEditContext: (ctx: {
|
||||
dashboardId: string;
|
||||
isEditable: boolean;
|
||||
isLocked: boolean;
|
||||
canEditDashboard: boolean;
|
||||
refetch: () => void;
|
||||
}) => void;
|
||||
}
|
||||
@@ -27,20 +43,35 @@ export const createEditContextSlice: StateCreator<
|
||||
> = (set, get) => ({
|
||||
dashboardId: '',
|
||||
isEditable: false,
|
||||
isLocked: false,
|
||||
canEditDashboard: false,
|
||||
editDisabledReason: '',
|
||||
refetch: (): void => undefined,
|
||||
// Idempotent (no-op when unchanged) so it's safe to call during render.
|
||||
setEditContext: (ctx): void => {
|
||||
const { dashboardId, isEditable, refetch } = get();
|
||||
const isEditable = ctx.canEditDashboard && !ctx.isLocked;
|
||||
let editDisabledReason = '';
|
||||
if (ctx.isLocked) {
|
||||
editDisabledReason = DASHBOARD_LOCKED_REASON;
|
||||
} else if (!ctx.canEditDashboard) {
|
||||
editDisabledReason = DASHBOARD_NO_EDIT_PERMISSION_REASON;
|
||||
}
|
||||
const prev = get();
|
||||
if (
|
||||
dashboardId === ctx.dashboardId &&
|
||||
isEditable === ctx.isEditable &&
|
||||
refetch === ctx.refetch
|
||||
prev.dashboardId === ctx.dashboardId &&
|
||||
prev.isEditable === isEditable &&
|
||||
prev.isLocked === ctx.isLocked &&
|
||||
prev.canEditDashboard === ctx.canEditDashboard &&
|
||||
prev.refetch === ctx.refetch
|
||||
) {
|
||||
return;
|
||||
}
|
||||
set({
|
||||
dashboardId: ctx.dashboardId,
|
||||
isEditable: ctx.isEditable,
|
||||
isEditable,
|
||||
isLocked: ctx.isLocked,
|
||||
canEditDashboard: ctx.canEditDashboard,
|
||||
editDisabledReason,
|
||||
refetch: ctx.refetch,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -137,3 +137,14 @@ export function layoutsToSections(
|
||||
})
|
||||
.filter((s): s is DashboardSection => s !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* The untitled, free-flow root section that ungrouped panels live in — the
|
||||
* first layout (`layoutIndex === 0`) when it has no title. Titled sections and
|
||||
* any later untitled layout are never the root.
|
||||
*/
|
||||
export function findRootSection(
|
||||
sections: DashboardSection[],
|
||||
): DashboardSection | undefined {
|
||||
return sections.find((section) => !section.title && section.layoutIndex === 0);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
|
||||
import { useDashboardFetch } from '../DashboardContainer/hooks/useDashboardFetch';
|
||||
import { useDashboardEditGuard } from '../DashboardContainer/hooks/useDashboardEditGuard';
|
||||
import { getPanelDefinition } from '../DashboardContainer/Panels/registry';
|
||||
import { buildPluginSpec } from '../DashboardContainer/Panels/utils/buildPluginSpec';
|
||||
import { buildDefaultQueries } from '../DashboardContainer/Panels/utils/buildDefaultQueries';
|
||||
@@ -42,6 +43,9 @@ function PanelEditorPage(): JSX.Element {
|
||||
|
||||
const { dashboard, isLoading, isError, error } =
|
||||
useDashboardFetch(dashboardId);
|
||||
// Derived here (not from the store) because the editor route doesn't mount
|
||||
// DashboardContainer, so the store's edit context may be cold on a direct URL.
|
||||
const { isEditable, editDisabledReason } = useDashboardEditGuard(dashboard);
|
||||
|
||||
// A `panel/new?panelKind=…` route means "create": seed a default panel of that
|
||||
// kind rather than looking one up. Persisted (with a real id) only on save.
|
||||
@@ -110,6 +114,8 @@ function PanelEditorPage(): JSX.Element {
|
||||
panel={panel}
|
||||
isNew={!!newKind}
|
||||
layoutIndex={layoutIndex}
|
||||
isEditable={isEditable}
|
||||
editDisabledReason={editDisabledReason}
|
||||
onClose={backToDashboard}
|
||||
onSaved={backToDashboard}
|
||||
/>
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import LLMObservabilityModelPricing from 'container/LLMObservability/Settings/ModelPricing/LLMObservabilityModelPricing';
|
||||
|
||||
function LLMObservabilityModelPricingPage(): JSX.Element {
|
||||
return <LLMObservabilityModelPricing />;
|
||||
}
|
||||
|
||||
export default LLMObservabilityModelPricingPage;
|
||||
@@ -60,6 +60,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
// The Badge is already pill-rounded, so an 18x18 box renders a circle for a
|
||||
// single digit and a capsule for more.
|
||||
.eventsBadge {
|
||||
// l3 background (!important beats the Badge's [data-color] rule).
|
||||
--badge-background: var(--l3-background) !important;
|
||||
--badge-padding: 0 5px;
|
||||
// Static count, not interactive — cancel the Badge's hover background change.
|
||||
--badge-hover-background: var(--badge-background) !important;
|
||||
|
||||
margin-left: var(--spacing-3);
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.tabsScroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import {
|
||||
TabsContent,
|
||||
TabsList,
|
||||
@@ -281,6 +282,8 @@ function SpanDetailsContent({
|
||||
// .map((key) => ({ key, value: allAttrs[key] }));
|
||||
// }, [selectedSpan]);
|
||||
|
||||
const eventsCount = selectedSpan.events?.length || 0;
|
||||
|
||||
return (
|
||||
<div className={styles.body}>
|
||||
<div className={styles.detailsSection}>
|
||||
@@ -397,7 +400,12 @@ function SpanDetailsContent({
|
||||
<Bookmark size={14} /> Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="events" variant="secondary">
|
||||
<ScrollText size={14} /> Events ({selectedSpan.events?.length || 0})
|
||||
<ScrollText size={14} /> Events
|
||||
{eventsCount > 0 && (
|
||||
<Badge color="secondary" className={styles.eventsBadge}>
|
||||
{eventsCount}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="logs" variant="secondary">
|
||||
<List size={14} /> Logs
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
.backBtn {
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.traceIdSection {
|
||||
@@ -26,6 +27,11 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
|
||||
// Tabular figures so the trace ID's digits line up at a fixed width.
|
||||
:global(.key-value-label__value) {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.filterSection {
|
||||
|
||||
@@ -133,7 +133,7 @@ function TraceDetailsHeader({
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="md"
|
||||
size="icon"
|
||||
className={styles.backBtn}
|
||||
onClick={handlePreviousBtnClick}
|
||||
aria-label="Back"
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { ChevronsRight, Copy, Search, X } from '@signozhq/icons';
|
||||
import { ArrowRightFromLine, Search, X } from '@signozhq/icons';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import {
|
||||
TooltipRoot,
|
||||
@@ -21,6 +19,7 @@ import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { uniqBy } from 'lodash-es';
|
||||
import NozButton from 'pages/TraceDetailsV3/TraceDetailsHeader/NozButton';
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
@@ -89,7 +88,6 @@ function Filters({
|
||||
onExpand: () => void;
|
||||
onCollapse: () => void;
|
||||
}): JSX.Element {
|
||||
const [, setCopy] = useCopyToClipboard();
|
||||
const [filters, setFilters] = useState<TagFilter>(
|
||||
BASE_FILTER_QUERY.filters || { items: [], op: 'AND' },
|
||||
);
|
||||
@@ -301,20 +299,7 @@ function Filters({
|
||||
<div className={styles.pillPopover}>
|
||||
<div className={styles.pillPopoverHeader}>
|
||||
<Typography.Text>Search query</Typography.Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={(): void => {
|
||||
setCopy(expression);
|
||||
toast.success('Copied to clipboard', {
|
||||
richColors: false,
|
||||
position: 'top-right',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Copy size={12} />
|
||||
</Button>
|
||||
<CopyButton value={expression} size={12} />
|
||||
</div>
|
||||
<div className={styles.pillPopoverExpression}>{expression}</div>
|
||||
</div>
|
||||
@@ -421,7 +406,7 @@ function Filters({
|
||||
color="secondary"
|
||||
onClick={onCollapse}
|
||||
>
|
||||
<ChevronsRight size={14} />
|
||||
<ArrowRightFromLine size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Collapse filters</TooltipContent>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// Square copy button whose icon cross-fades between copy and check.
|
||||
.copyButton {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// Both icons occupy the same box; only one is visible at a time.
|
||||
.iconStack {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transition:
|
||||
opacity 300ms ease,
|
||||
filter 300ms ease,
|
||||
transform 300ms ease;
|
||||
}
|
||||
|
||||
// Idle state: copy visible, check blurred/rotated/faded out.
|
||||
.copyIcon {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
.checkIcon {
|
||||
opacity: 0;
|
||||
filter: blur(4px);
|
||||
transform: rotate(-90deg);
|
||||
// Green checkmark to signal a successful copy; eases in a touch slower.
|
||||
color: var(--bg-forest-500);
|
||||
transition-duration: 500ms;
|
||||
}
|
||||
|
||||
// Copied state: copy fades/blurs/rotates out, check animates in.
|
||||
.iconStack[data-copied='true'] {
|
||||
.copyIcon {
|
||||
opacity: 0;
|
||||
filter: blur(4px);
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.checkIcon {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
66
frontend/src/periscope/components/CopyButton/CopyButton.tsx
Normal file
66
frontend/src/periscope/components/CopyButton/CopyButton.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { CSSProperties, useCallback } from 'react';
|
||||
import { Check, Copy } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import cx from 'classnames';
|
||||
import { useCopyToClipboard } from 'hooks/useCopyToClipboard';
|
||||
|
||||
import styles from './CopyButton.module.scss';
|
||||
|
||||
export interface CopyButtonProps {
|
||||
/** Text written to the clipboard on click. */
|
||||
value: string;
|
||||
/** Icon size in px. Default 14. */
|
||||
size?: number;
|
||||
/** Accessible label for the idle (not-yet-copied) state. Default "Copy". */
|
||||
ariaLabel?: string;
|
||||
/** Extra class merged onto the button. */
|
||||
className?: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Square, icon-only copy button. Shows a copy icon that cross-fades to a
|
||||
* checkmark (blur + rotate + fade) on copy, reverting after 2s. The checkmark
|
||||
* uses the hover-state icon colour.
|
||||
*/
|
||||
function CopyButton({
|
||||
value,
|
||||
size = 14,
|
||||
ariaLabel = 'Copy',
|
||||
className,
|
||||
testId,
|
||||
}: CopyButtonProps): JSX.Element {
|
||||
const { copyToClipboard, isCopied } = useCopyToClipboard();
|
||||
|
||||
const handleClick = useCallback((): void => {
|
||||
copyToClipboard(value);
|
||||
}, [copyToClipboard, value]);
|
||||
|
||||
const stackStyle: CSSProperties = { width: size, height: size };
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={cx(styles.copyButton, className)}
|
||||
onClick={handleClick}
|
||||
aria-label={isCopied ? 'Copied' : ariaLabel}
|
||||
testId={testId}
|
||||
>
|
||||
<span className={styles.iconStack} style={stackStyle} data-copied={isCopied}>
|
||||
<Copy size={size} className={cx(styles.icon, styles.copyIcon)} />
|
||||
<Check size={size} className={cx(styles.icon, styles.checkIcon)} />
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
CopyButton.defaultProps = {
|
||||
size: 14,
|
||||
ariaLabel: 'Copy',
|
||||
className: undefined,
|
||||
testId: undefined,
|
||||
};
|
||||
|
||||
export default CopyButton;
|
||||
@@ -22,23 +22,6 @@
|
||||
--dropdown-menu-content-z-index: 1000;
|
||||
}
|
||||
|
||||
&__copy-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--l2-foreground);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--l1-foreground);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
}
|
||||
|
||||
// Shared content container — no scroll, each view handles its own
|
||||
&__content {
|
||||
flex: 1;
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { ChevronDown, Copy } from '@signozhq/icons';
|
||||
import { ChevronDown } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple as Dropdown } from '@signozhq/ui/dropdown-menu';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
import { JsonView } from 'periscope/components/JsonView';
|
||||
import { PrettyView } from 'periscope/components/PrettyView';
|
||||
import { PrettyViewProps } from 'periscope/components/PrettyView';
|
||||
import { PrettyView, PrettyViewProps } from 'periscope/components/PrettyView';
|
||||
|
||||
import './DataViewer.styles.scss';
|
||||
|
||||
@@ -33,7 +31,6 @@ function DataViewer({
|
||||
prettyViewProps,
|
||||
}: DataViewerProps): JSX.Element {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('pretty');
|
||||
const [, setCopy] = useCopyToClipboard();
|
||||
|
||||
const jsonString = useMemo(() => JSON.stringify(data, null, 2), [data]);
|
||||
|
||||
@@ -51,14 +48,6 @@ function DataViewer({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = (): void => {
|
||||
const text = JSON.stringify(data, null, 2);
|
||||
setCopy(text);
|
||||
toast.success('Copied to clipboard', {
|
||||
position: 'top-right',
|
||||
});
|
||||
};
|
||||
|
||||
const currentLabel =
|
||||
VIEW_MODE_OPTIONS.find((opt) => opt.value === viewMode)?.label ?? 'Pretty';
|
||||
|
||||
@@ -94,14 +83,7 @@ function DataViewer({
|
||||
{currentLabel}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<button
|
||||
type="button"
|
||||
className="data-viewer__copy-btn"
|
||||
onClick={handleCopy}
|
||||
aria-label="Copy JSON"
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
<CopyButton value={jsonString} ariaLabel="Copy JSON" />
|
||||
</div>
|
||||
|
||||
<div className="data-viewer__content">
|
||||
|
||||
@@ -137,5 +137,6 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
MCP_SERVER: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
AI_ASSISTANT_BASE: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
LLM_OBSERVABILITY_BASE: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
LLM_OBSERVABILITY_MODEL_PRICING: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
LLM_OBSERVABILITY_OVERVIEW: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
LLM_OBSERVABILITY_CONFIGURATION: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user