Compare commits

...

11 Commits

Author SHA1 Message Date
Ashwin Bhatkal
7136a51fdc feat(dashboard-v2): disable panel editor save when not editable 2026-07-08 01:48:30 +05:30
Ashwin Bhatkal
3b1c1c401f feat(dashboard-v2): open the JSON editor read-only when not editable 2026-07-08 01:48:30 +05:30
Ashwin Bhatkal
58aec92161 feat(dashboard-v2): disable panel and section edit actions with a hover reason 2026-07-08 01:48:30 +05:30
Ashwin Bhatkal
4e1c77cab1 feat(dashboard-v2): disable toolbar edit actions with a hover reason when not editable 2026-07-08 01:48:30 +05:30
Ashwin Bhatkal
14694f55e1 feat(dashboard-v2): lock-aware edit context with disable reasons, guard mutations, skip chart refetch on lock toggle 2026-07-08 01:48:30 +05:30
Abhi kumar
db4219e0f1 feat(dashboards-v2): download panel as PNG/SVG/CSV (#11803)
* chore(dashboards-v2): add html-to-image for panel image export

Dependency used to capture a panel's rendered DOM node as PNG/SVG.

* feat(dashboards-v2): declare per-kind download capability

Adds the DownloadFormat enum and a per-kind `actions.download` map. Tables allow CSV/PNG/SVG, charts PNG/SVG; CSV is table-only (V1 parity).

* feat(dashboards-v2): capture a panel as a PNG/SVG image

downloadElementAsImage captures the panel's rendered node via html-to-image (dropping the hover chrome); useDownloadPanelImage locates it by its data-panel-root marker and surfaces failures as a toast. toSafeFileName sanitizes the filename.

* feat(dashboards-v2): export table panel data as CSV

getTableCsvRows flattens the prepared scalar table (reusing the on-screen cell formatting) into rows, and downloadCsv serializes them via papaparse.

* feat(dashboards-v2): add the Download action to the panel menu

useDownloadPanelMenuItem composes the submenu, dispatching CSV to the query response (useDownloadPanelCsv) and PNG/SVG to the DOM capture; the response is threaded to the menu via props. buildDownloadMenuItem renders the format options; usePanelActionItems consumes the ready item.

* chore: pr review fixes
2026-07-07 16:23:22 +00:00
Gaurav Tewari
165c945511 feat(frontend): LLM pricing UI update and tests [5/6] (#11910)
* feat(llm-pricing): add model pricing foundation (route, permission, page shell)

* feat(llm-pricing): add listing page and table

* chore(llm-pricing): drop search + source filters from list request

The list API does not honour the q (search) and source params yet, so
the controls did nothing. Remove the search input and source dropdown
along with the params we sent, and trim useModelPricingFilters to the
URL-backed page state that pagination still needs. Currency dropdown,
tabs, table and pagination are unchanged. Filters will return once the
backend supports them.

* refactor(llm-pricing): extract getRelativeTime helper in utils

Pull the relative-time formatting out of getRelativeLastSeen into a
small local getRelativeTime helper. Kept feature-local (not in the
shared utils/timeUtils) so the LLM pricing module owns its own dayjs
config; the local relativeTime extend stays for test self-sufficiency.

* refactor(llm-pricing): drop dead NaN guard in formatPricePerMillion

Pricing fields are typed as required numbers and JSON can't carry NaN,
so Number.isNaN was unreachable. Keep the null/undefined guard as API
defensiveness (toFixed on a missing value would crash the row). Also
trims the now-redundant dayjs.extend comment.

* refactor(llm-pricing): centralize constants and shared types

Extract PAGE_SIZE, PAGE_KEY, COLUMN_COUNT and CURRENCY_OPTIONS into a
new constants.ts, and move the ModelPricingFilters contract into
types.ts. Component prop interfaces stay colocated with their
components, matching the convention in the drawer PR.

* refactor(llm-pricing): use nuqs for list pagination URL state

Replace the hand-rolled useHistory + URLSearchParams plumbing in
useModelPricingFilters with nuqs useQueryState, matching the convention
used by the dashboards, alerts and k8s list pages. Behaviour is
unchanged: parseAsInteger.withDefault(1) keeps ?page=1 out of the URL
and history:'replace' avoids polluting the back-stack.

* refactor(llm-pricing): inline pagination, drop useModelPricingFilters

The hook had shrunk to a one-line nuqs wrapper after search/source were
removed, so inline the useQueryState call into the container and remove
the hook file plus the now-unused ModelPricingFilters type. When the
filters return (once the API honours them) they can move back into a
dedicated hook.

* feat(llm-pricing): disable currency selector (USD-only for now)

Only USD is priced today, so render the currency SelectSimple in a
disabled state pinned to USD. A disabled select can't fire onChange, so
the currency useState is dead — drop it (and the now-unused useState
import).

* refactor(llm-pricing): render model costs inside its tab + tab URL param

The listing was rendered outside the Tabs, so the tab was decorative.
Move all model-cost content (currency control, list query, table,
pagination, footer) into a ModelCostsTab component rendered as the
'Model costs' tab's children, and drive the active tab from a 'tab' URL
query param (nuqs). The container is now just the page shell. Unpriced
models stays a disabled placeholder for a later PR.

* style(llm-pricing): target @signozhq table slots, drop dead antd/leftover rules

The component uses @signozhq/ui Table/Tabs (Radix-based), not antd, so the
.ant-table-* and .ant-tabs-nav selectors never matched — the intended
uppercase/muted header styling wasn't applied. Retarget header/cell rules to
[data-slot='table-head'|'table-cell'] (no !important needed). Also remove dead
rules left over from the removed search/source/add UI (.filters-bar__search,
__source, __add, .page-header__actions) and the unused .source-badge--auto/
--override modifiers.

* fix(llm-pricing): constrain currency dropdown width, drop tab URL param

- Currency SelectSimple stretched to fill the filters bar; give it a fixed
  160px width (min-width couldn't cap the trigger).
- Model costs is the only enabled tab for now, so use Tabs defaultValue
  instead of a URL-backed param. Removes the nuqs tab state plus the now-unused
  TAB_KEYS/TAB_QUERY_KEY constants and TabKey type.

* chore: self review changes

* fix: add skeleton loading

* refactor: self review changes

* refactor: initial prop

* fix: update styling

* fix: add comments in utils

* feat(llm-pricing): add model cost drawer and wire into listing page

* fix(llm-pricing): restrict pricing management to admins

Align the frontend write gate with the backend, which protects the
LLM pricing create/update/delete endpoints with AdminAccess (admin
only). Previously manage_llm_pricing allowed EDITOR/AUTHOR, so those
roles saw the Add/Save affordances but their writes were rejected with
a 403. Also removes the AUTHOR entry, which could never reach the page
(the route gate excludes it).

* fix(llm-pricing): read-only drawer shows View title, hides source picker

Non-managers open the drawer in view mode (write APIs are Admin-only), so:
- the heading reads "View model cost" instead of "Edit model cost"
- the Source (auto vs. override) picker is hidden, since switching source is
  a manager-only action with nothing actionable for a viewer.

* refactor: form in edit / add modal

* chore: update color tokens

* fix: add error handling

* chore: update more self review changes

* chore: self review changes

* chore: self review changes

* fix: minor grammer thing

* fix: route thing

* refactor: migrate to css moduel

* refactor: migrate to css module

* refactor: migrate to css module

* refactor: migrate to tanstack table

* docs: clarify price precision comment

* chore: remove comment

* chore: remove comment

* fix: disable isDirty in case of llm pricing

* refactor: number

* feat: add search , dropdown and flag

* feat: feature flag on entire route and add mode costs tabs

* fix: add isFetchingFeatureFlags

* chore: update flag

* refactor: shell

* fix: add key to route

* feat: add flags

* chore: additional refactor

* chore: add commet in utis

* chore: self review changes

* refactor: types and other things

* refactor: types and other things

* chore: add disable on source id

* empty commit

* chore: empty commit

* fix: add demo side nav on sidenav

* chore: remove demo side nav

* refactor: update routes

* chore: remove usd selector for now

* fix: layout shift

* refactor: styles

* refactor: typography component

* refactor: more changes

* refactor: typograhy

* refactor(llm-pricing): break model-cost drawer into per-component files + tokens

Apply the CSS-module/component conventions to the drawer that came from
drawer-3:
- Move the drawer under ModelCostTabPanel/components/ModelCostDrawer/ to mirror
  the ModelCostsTable structure
- Split the single 395-LOC ModelCostDrawer.module.scss into per-component
  co-located modules; cross-component selectors live in shared.module.scss and
  are pulled in via CSS-modules `composes`
- shared.module.scss is a composes target (parsed as plain CSS), so it is kept
  flat with block comments — no SCSS nesting or // comments
- Use --text-vanilla-* (not --bg-vanilla-*) for text colors, matching the
  listing code

* refactor: more changes

* refactor: styling and components

* refactor: styling and components

* chore: add a tooltip on hover

* feat: add delete confirm modal

* fix: update title

* refactor: css variables

* refactor: use signoz button and minor css update

* chore: sync table

* chore: remove extra comment

* chore: use typograpgy test in table config

* fix: minior issues

* fix: llm pricing listing

* refactor: remove extra classes

* refactor: side nav changes

* fix: update missing styles

* chore: update edit and delete options

* chore: remove extra comment

* chore: revert env changes

* chore: add enable check

* chore: remove divider

* refactor: use delete confirm dialog

* chore: remove scss file

* feat: move ui to easily accessable tabs

* feat: update test cases

* chore: update text

* chore: self review changes

* chore: self review refactor

* chore: self review changes

* chore: remove worktree

* chore: revert env.ts

* chore: update tests

* chore: self review changes

* chore: update test cases

* chore: remove extra comments

* chore: use constants

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-07-07 15:05:23 +00:00
Aditya Singh
ff19aedaa8 feat(trace-details): UI polish + reusable CopyButton (#11958)
* refactor(trace-details): use ArrowRightFromLine for filter collapse icon

* refactor(trace-details): square header back button with l1 border, tabular-nums trace ID

* refactor(trace-details): show events count as an l3 badge

* feat(periscope): add reusable CopyButton with copy-to-check animation

* refactor(trace-details): use CopyButton in DataViewer and filter query popover

* fix(traces): address PR review on CopyButton and events badge

- Remove unused onCopy prop from CopyButton; it was wired to fire
  unconditionally (even on failed clipboard writes), contradicting its
  documented "on successful copy" contract, and no caller used it. Will
  be re-added gated on success when actually needed.
- Only render the Events tab count badge when eventsCount > 0 so spans
  with no events don't show a "0" badge.

* style(traces): tokenize events badge margin per review

Use var(--spacing-3) for the events badge margin-left instead of a raw
6px. Leave the 18px badge box and 5px padding off-grid (no matching
token; nearest values would change the single-digit circle).
2026-07-07 15:01:58 +00:00
Abhi kumar
ef8319585b feat(dashboard-v2): View-modal drill-down + editor "Switch to View Mode" (#12004)
* fix(dashboard-v2): only mark a new panel savable once it has a query

A new panel was always treated as dirty, so Save was enabled even with no
query to run. Track dirtiness off spec/query edits and require a seeded query
(List auto-seeds one; other kinds open query-less) before a new panel is
savable.

* feat(dashboard-v2): drill down from the panel View modal

Wire the shared drill-down orchestration into the View modal so filter-by-value
and breakout refine the expanded view in place (URL-persisted, re-runs the
preview) instead of opening a nested View modal. Adds an OpenDrilldownView
handoff type, an optional openDrilldownView override on useDrilldown, and the
PreviewPane onClick/enableDrillDown pass-through the modal uses. Cells and log
rows get a pointer/hover affordance for the click.

* fix(dashboard-v2): keep the dashboard spec cache patch-driven

The spec cache is kept fresh by optimistic patches, so auto-refetching flashed
the grid back to server state as observers mounted across the panel tree. Set
staleTime: Infinity + refetchOnMount: false; explicit refetch() still works.

* feat(dashboard-v2): add "Switch to View Mode" to the panel editor

Add the V1 "Switch to View Mode" button to the panel editor header. It leaves
the full-page editor for the dashboard with this panel expanded in the View
modal, seeded with the live (un-saved) query via the same expandedWidgetId +
graphType + compositeQuery URL contract the modal hydrates from. Shown for
existing panels only — a new panel isn't saved to the dashboard spec yet.
2026-07-07 14:20:00 +00:00
Abhi kumar
14ba332935 fix: ui fixes for panel selection modal (#11944)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix: ui fixes for panel selection modal

* feat(dashboards-v2): choose target section when adding a panel

Add a section picker to the New Panel modal so a panel can be placed in
any section (or the untitled root), not just the one the "Add panel"
action was triggered from. Sections are read from the cached dashboard
query via useDashboardSections (no refetch, no prop-drilling).

Picking a panel type now selects it (highlighted card) and reveals a
footer holding the section dropdown and an "Add Panel" button split
50/50; confirming shows a brief loader before navigating to the editor.
The chosen section's layoutIndex is threaded through
useCreatePanel.createPanel.

* fix(dashboards-v2): refine New Panel modal footer + section labels

- Keep the footer visible at all times; the "Add Panel" confirm is disabled
  until a panel type is selected (instead of hiding the whole footer).
- Footer layout: the section selector fills the available width and the
  confirm button takes its natural width (no more 50/50 split).
- Add a "Select panel type" label above the panel-type grid, matching the
  existing "Add panel to" label.

* chore: pr review fixes
2026-07-07 12:28:15 +00:00
Vinicius Lourenço
fda7b66d49 feat(authz): add devtools for authz (#11933)
* feat(authz): add devtools for authz

* fix(authz): add missing allowed/deniedPermissions to test mocks

* fix(pnpm-lock): keep updated

* fix(pr): address comments

* fix(pr): use our button component

* fix(pr): split modal in smaller components
2026-07-07 12:11:05 +00:00
135 changed files with 5302 additions and 587 deletions

View File

@@ -0,0 +1,3 @@
export const IS_DEV = false;
export const IS_PROD = true;
export const MODE = 'test';

View File

@@ -29,6 +29,7 @@ const config: Config.InitialOptions = {
'^constants/env$': '<rootDir>/__mocks__/env.ts',
'^src/constants/env$': '<rootDir>/__mocks__/env.ts',
'^@signozhq/icons$': '<rootDir>/__mocks__/signozhqIconsMock.tsx',
'^lib/env$': '<rootDir>/__mocks__/lib/env.ts',
'^test-mocks/(.*)$': '<rootDir>/__mocks__/$1',
'^react-syntax-highlighter/dist/esm/(.*)$':
'<rootDir>/node_modules/react-syntax-highlighter/dist/cjs/$1',

View File

@@ -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",

View File

@@ -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:

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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'
),
);

View File

@@ -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,
},
];

View File

@@ -22,6 +22,7 @@ import {
} from 'container/AIAssistant/store/useAIAssistantStore';
import { useThemeMode } from 'hooks/useDarkMode';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { IS_DEV } from 'lib/env';
import history from 'lib/history';
import { ROLES as UserRole } from 'types/roles';
@@ -30,6 +31,33 @@ import { useCmdK } from '../../providers/cmdKProvider';
import './cmdKPalette.scss';
const AuthZDevModal = IS_DEV
? React.lazy(() =>
import('lib/authz/devtools/AuthZDevModal/AuthZDevModal').then((m) => ({
default: m.AuthZDevModal,
})),
)
: null;
const AuthZDevFloatingIndicator = IS_DEV
? React.lazy(() =>
import('lib/authz/devtools/AuthZDevFloatingIndicator/AuthZDevFloatingIndicator').then(
(m) => ({
default: m.AuthZDevFloatingIndicator,
}),
),
)
: null;
const openAuthZDevModal = IS_DEV
? (): void => {
void import('lib/authz/devtools/useAuthZDevStore').then((m) => {
m.openAuthZDevModal();
return m;
});
}
: undefined;
type CmdAction = {
id: string;
name: string;
@@ -110,6 +138,7 @@ export function CmdKPalette({
aiAssistant: isAIAssistantEnabled
? { open: handleOpenAIAssistant }
: undefined,
authzDevTools: openAuthZDevModal ? { open: openAuthZDevModal } : undefined,
});
// RBAC filter: show action if no roles set OR current user role is included
@@ -146,37 +175,57 @@ export function CmdKPalette({
};
return (
<CommandDialog open={open} onOpenChange={setOpen} position="top" offset={110}>
<CommandInput placeholder="Search…" className="cmdk-input-wrapper" />
<CommandList className="cmdk-list-scroll">
<CommandEmpty>No results</CommandEmpty>
{grouped.map(([section, items]) => (
<CommandGroup
key={section}
heading={section}
className="cmdk-section-heading"
>
{items.map((it) => (
<CommandItem
key={it.id}
onSelect={(): void => handleInvoke(it)}
value={it.name}
className={theme === 'light' ? 'cmdk-item-light' : 'cmdk-item'}
>
<span
className={cx('cmd-item-icon', it.id === 'ai-assistant' && 'noz-icon')}
<>
<CommandDialog
open={open}
onOpenChange={setOpen}
position="top"
offset={110}
>
<CommandInput placeholder="Search…" className="cmdk-input-wrapper" />
<CommandList className="cmdk-list-scroll">
<CommandEmpty>No results</CommandEmpty>
{grouped.map(([section, items]) => (
<CommandGroup
key={section}
heading={section}
className="cmdk-section-heading"
>
{items.map((it) => (
<CommandItem
key={it.id}
onSelect={(): void => handleInvoke(it)}
value={it.name}
className={theme === 'light' ? 'cmdk-item-light' : 'cmdk-item'}
>
{it.icon}
</span>
{it.name}
{it.shortcut && it.shortcut.length > 0 && (
<CommandShortcut>{it.shortcut.join(' • ')}</CommandShortcut>
)}
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</CommandDialog>
<span
className={cx(
'cmd-item-icon',
it.id === 'ai-assistant' && 'noz-icon',
)}
>
{it.icon}
</span>
{it.name}
{it.shortcut && it.shortcut.length > 0 && (
<CommandShortcut>{it.shortcut.join(' • ')}</CommandShortcut>
)}
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</CommandDialog>
{IS_DEV && AuthZDevModal && (
<React.Suspense fallback={null}>
<AuthZDevModal />
</React.Suspense>
)}
{IS_DEV && AuthZDevFloatingIndicator && (
<React.Suspense fallback={null}>
<AuthZDevFloatingIndicator />
</React.Suspense>
)}
</>
);
}

View File

@@ -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;

View File

@@ -43,10 +43,17 @@ type ActionDeps = {
aiAssistant?: {
open: () => void;
};
/**
* Provided only in development mode. Opens the AuthZ DevTools modal
* for testing permission overrides.
*/
authzDevTools?: {
open: () => void;
};
};
export function createShortcutActions(deps: ActionDeps): CmdAction[] {
const { navigate, handleThemeChange, aiAssistant } = deps;
const { navigate, handleThemeChange, aiAssistant, authzDevTools } = deps;
const actions: CmdAction[] = [
{
@@ -302,5 +309,17 @@ export function createShortcutActions(deps: ActionDeps): CmdAction[] {
});
}
if (authzDevTools) {
actions.push({
id: 'authz-devtools',
name: 'AuthZ DevTools',
keywords: 'authz permissions rbac debug devtools override testing',
section: 'Dev',
icon: <Settings size={14} />,
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
perform: authzDevTools.open,
});
}
return actions;
}

View File

@@ -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);
}

View File

@@ -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>
);
}

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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={[
{

View File

@@ -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}

View File

@@ -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),
);
});
});

View File

@@ -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(),
);
});
});

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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,
},
};
}

View File

@@ -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';

View File

@@ -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(),
);
});
});

View File

@@ -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 };
}

View File

@@ -48,6 +48,8 @@ describe('CreateRolePage - AuthZ', () => {
isFetching: true,
error: null,
permissions: null,
allowed: false,
deniedPermissions: [],
refetchPermissions: jest.fn(),
});

View File

@@ -77,6 +77,8 @@ describe('EditRolePage - AuthZ', () => {
isFetching: true,
error: null,
permissions: null,
allowed: false,
deniedPermissions: [],
refetchPermissions: jest.fn(),
});

View File

@@ -409,6 +409,8 @@ describe('ViewRolePage - AuthZ', () => {
isFetching: true,
error: null,
permissions: null,
allowed: false,
deniedPermissions: [],
refetchPermissions: jest.fn(),
});

View File

@@ -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];

View File

@@ -0,0 +1,28 @@
.container {
position: fixed;
top: 12px;
left: 50%;
transform: translateX(-50%);
z-index: 9998;
pointer-events: auto;
display: flex;
align-items: center;
gap: 4px;
}
.button {
box-shadow:
0 4px 12px rgb(0 0 0 / 15%),
0 0 0 1px rgb(0 0 0 / 5%);
}
.badge {
margin-left: 4px;
}
.closeButton {
border-radius: 4px;
box-shadow:
0 4px 12px rgb(0 0 0 / 15%),
0 0 0 1px rgb(0 0 0 / 5%);
}

View File

@@ -0,0 +1,61 @@
import { X } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { useState } from 'react';
import { createPortal } from 'react-dom';
import { useAuthZDevStore } from '../useAuthZDevStore';
import styles from './AuthZDevFloatingIndicator.module.css';
export function AuthZDevFloatingIndicator(): JSX.Element | null {
const overrides = useAuthZDevStore((s) => s.overrides);
const isModalOpen = useAuthZDevStore((s) => s.isModalOpen);
const openModal = useAuthZDevStore((s) => s.openModal);
const [isDismissed, setIsDismissed] = useState(false);
const overrideCount = Object.keys(overrides).length;
if (overrideCount === 0 || isModalOpen || isDismissed) {
return null;
}
const handleOpen = (): void => {
setIsDismissed(false);
openModal();
};
const handleDismiss = (e: React.MouseEvent): void => {
e.stopPropagation();
setIsDismissed(true);
};
return createPortal(
<div className={styles.container}>
<Button
variant="solid"
color="warning"
size="sm"
onClick={handleOpen}
className={styles.button}
data-testid="authz-dev-floating-indicator"
>
AuthZ Overrides
<Badge color="warning" className={styles.badge}>
{overrideCount}
</Badge>
</Button>
<Button
variant="ghost"
color="secondary"
size="sm"
onClick={handleDismiss}
className={styles.closeButton}
aria-label="Dismiss indicator"
data-testid="authz-dev-floating-dismiss"
prefix={<X />}
/>
</div>,
document.body,
);
}

View File

@@ -0,0 +1,140 @@
.modal {
--dialog-width: 640px;
--dialog-max-width: 92vw;
--dialog-max-height: 78vh;
--dialog-description-padding: var(--spacing-4) var(--spacing-4) 0px
var(--spacing-4);
[data-slot='dialog-description'],
[data-slot='dialog-header'] {
background-color: var(--l2-background);
}
}
.content {
display: flex;
flex-direction: column;
max-height: calc(78vh - 80px);
}
.header {
display: flex;
flex-direction: column;
flex-shrink: 0;
gap: var(--spacing-4);
padding-bottom: var(--spacing-4);
}
.searchRow {
display: flex;
align-items: center;
gap: var(--spacing-4);
--input-background: var(--l3-background);
--input-hover-background: var(--l3-background);
--input-focus-background: var(--l3-background);
--input-border-color: var(--l3-border);
--input-hover-border-color: var(--l3-border);
--input-focus-border-color: var(--l3-border);
--select-trigger-background-color: var(--l3-background);
--select-trigger-hover-background: var(--l3-background);
--select-trigger-focus-background: var(--l3-background);
--select-trigger-border-color: var(--l3-border);
--select-content-background: var(--l3-background);
--select-item-highlight-background: var(--l3-background-hover);
}
.search {
flex: 1 1 auto;
min-width: 0;
--input-width: 100%;
}
.filter {
flex: 0 0 176px;
/* Normalize the library trigger height (2.25rem) to match the input. */
--select-trigger-height: 2rem;
}
.search > *,
.filter > * {
box-sizing: border-box;
}
.searchIcon {
display: inline-flex;
color: var(--l3-foreground);
}
.actionsRow {
display: flex;
gap: var(--spacing-3);
}
.actionButton {
flex: 0 0 auto;
height: 2rem;
}
.list {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: var(--spacing-6);
padding: var(--spacing-4) 0;
overflow-y: auto;
}
.section {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.sectionHeader {
display: flex;
align-items: baseline;
gap: var(--spacing-3);
padding: var(--spacing-3) var(--spacing-2);
margin: 0 0 var(--spacing-1);
border-bottom: 1px solid var(--l2-border);
}
.empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 160px;
padding: var(--spacing-16);
}
.footer {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: var(--spacing-4);
padding: var(--spacing-4) 0;
border-top: 1px solid var(--l2-border);
}
.hint {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-4);
}
.hintGroup {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
}
.count {
flex: 0 0 auto;
white-space: nowrap;
}

View File

@@ -0,0 +1,108 @@
import { DialogWrapper } from '@signozhq/ui/dialog';
import { useCallback, useRef } from 'react';
import { useAuthZDevStore } from '../useAuthZDevStore';
import { useAuthZQueryInvalidation } from '../useAuthZQueryInvalidation';
import { AuthZDevModalContent } from './AuthZDevModalContent';
import { AuthZDevModalFooter } from './AuthZDevModalFooter';
import { AuthZDevModalHeader } from './AuthZDevModalHeader';
import { useAuthZDevModalData } from './useAuthZDevModalData';
import { useModalKeyboard } from './useModalKeyboard';
import styles from './AuthZDevModal.module.css';
export function AuthZDevModal(): JSX.Element | null {
const isModalOpen = useAuthZDevStore((s) => s.isModalOpen);
const closeModal = useAuthZDevStore((s) => s.closeModal);
const observed = useAuthZDevStore((s) => s.observed);
const overrides = useAuthZDevStore((s) => s.overrides);
const cycleOverride = useAuthZDevStore((s) => s.cycleOverride);
const setOverride = useAuthZDevStore((s) => s.setOverride);
const clearAllOverrides = useAuthZDevStore((s) => s.clearAllOverrides);
const grantAll = useAuthZDevStore((s) => s.grantAll);
const denyAll = useAuthZDevStore((s) => s.denyAll);
useAuthZQueryInvalidation(overrides);
const searchInputRef = useRef<HTMLInputElement>(null);
const {
search,
setSearch,
resourceFilter,
setResourceFilter,
observedList,
resourceFilterItems,
filteredPermissions,
groups,
orderedPermissions,
indexByPermission,
hasActiveFilter,
filteredOverrideCount,
overrideCount,
} = useAuthZDevModalData(observed, overrides);
const { selectedIndex, setSelectedIndex } = useModalKeyboard({
permissions: orderedPermissions,
overrides,
onCycle: cycleOverride,
onSetOverride: setOverride,
onClose: closeModal,
searchInputRef,
});
const handleOpenChange = useCallback(
(open: boolean): void => {
if (!open) {
closeModal();
setSelectedIndex(-1);
}
},
[closeModal, setSelectedIndex],
);
return (
<DialogWrapper
open={isModalOpen}
onOpenChange={handleOpenChange}
title="AuthZ DevTools"
subTitle="Force permission results locally without touching the backend."
className={styles.modal}
width="wide"
>
<div className={styles.content}>
<AuthZDevModalHeader
searchInputRef={searchInputRef}
search={search}
setSearch={setSearch}
resourceFilter={resourceFilter}
setResourceFilter={setResourceFilter}
resourceFilterItems={resourceFilterItems}
hasActiveFilter={hasActiveFilter}
filteredPermissions={filteredPermissions}
filteredOverrideCount={filteredOverrideCount}
overrideCount={overrideCount}
grantAll={grantAll}
denyAll={denyAll}
clearAllOverrides={clearAllOverrides}
/>
<AuthZDevModalContent
observedListLength={observedList.length}
orderedPermissions={orderedPermissions}
groups={groups}
indexByPermission={indexByPermission}
selectedIndex={selectedIndex}
setSelectedIndex={setSelectedIndex}
observed={observed}
overrides={overrides}
onSetOverride={setOverride}
/>
<AuthZDevModalFooter
orderedPermissionsCount={orderedPermissions.length}
observedListLength={observedList.length}
/>
</div>
</DialogWrapper>
);
}

View File

@@ -0,0 +1,85 @@
import { Typography } from '@signozhq/ui/typography';
import { useCallback } from 'react';
import { BrandedPermission } from '../../hooks/useAuthZ/types';
import { ObservedPermission, OverrideState } from '../types';
import { PermissionRow } from './PermissionRow';
import styles from './AuthZDevModal.module.css';
export interface PermissionGroup {
resource: string;
items: string[];
}
export interface AuthZDevModalContentProps {
observedListLength: number;
orderedPermissions: string[];
groups: PermissionGroup[];
indexByPermission: Map<string, number>;
selectedIndex: number;
setSelectedIndex: (index: number) => void;
observed: Record<string, ObservedPermission>;
overrides: Record<string, OverrideState>;
onSetOverride: (permission: BrandedPermission, state: OverrideState) => void;
}
export function AuthZDevModalContent({
observedListLength,
orderedPermissions,
groups,
indexByPermission,
selectedIndex,
setSelectedIndex,
observed,
overrides,
onSetOverride,
}: AuthZDevModalContentProps): JSX.Element {
const handleSelectIndex = useCallback(
(index: number) => (): void => {
setSelectedIndex(index);
},
[setSelectedIndex],
);
return (
<div className={styles.list} data-testid="authz-dev-permission-list">
{orderedPermissions.length === 0 ? (
<div className={styles.empty}>
<Typography.Text align="center" color="muted">
{observedListLength === 0
? 'No permissions observed yet. Navigate the app to trigger permission checks.'
: 'No permissions match your search.'}
</Typography.Text>
</div>
) : (
groups.map((group) => (
<div key={group.resource} className={styles.section}>
<div className={styles.sectionHeader}>
<Typography.Text as="span" size="medium" weight="semibold">
{group.resource}
</Typography.Text>
<Typography.Text as="span" size="small" color="muted">
{group.items.length}
</Typography.Text>
</div>
{group.items.map((permission) => {
const index = indexByPermission.get(permission) ?? 0;
return (
<PermissionRow
key={permission}
observed={observed[permission]}
override={overrides[permission]}
isSelected={index === selectedIndex}
onSetOverride={onSetOverride}
onSelect={handleSelectIndex(index)}
/>
);
})}
</div>
))
)}
</div>
);
}

View File

@@ -0,0 +1,56 @@
import { Kbd } from '@signozhq/ui/kbd';
import { Typography } from '@signozhq/ui/typography';
import styles from './AuthZDevModal.module.css';
export interface AuthZDevModalFooterProps {
orderedPermissionsCount: number;
observedListLength: number;
}
export function AuthZDevModalFooter({
orderedPermissionsCount,
observedListLength,
}: AuthZDevModalFooterProps): JSX.Element {
return (
<div className={styles.footer}>
<div className={styles.hint}>
<span className={styles.hintGroup}>
<Kbd></Kbd>
<Kbd></Kbd>
<Typography.Text as="span" size="small" color="muted">
navigate
</Typography.Text>
</span>
<span className={styles.hintGroup}>
<Kbd></Kbd>
<Kbd></Kbd>
<Typography.Text as="span" size="small" color="muted">
mode
</Typography.Text>
</span>
<span className={styles.hintGroup}>
<Kbd>1-5</Kbd>
<Typography.Text as="span" size="small" color="muted">
set
</Typography.Text>
</span>
<span className={styles.hintGroup}>
<Kbd>/</Kbd>
<Typography.Text as="span" size="small" color="muted">
search
</Typography.Text>
</span>
<span className={styles.hintGroup}>
<Kbd>Esc</Kbd>
<Typography.Text as="span" size="small" color="muted">
close
</Typography.Text>
</span>
</div>
<Typography.Text size="small" color="muted" className={styles.count}>
{orderedPermissionsCount} of {observedListLength} permissions
</Typography.Text>
</div>
);
}

View File

@@ -0,0 +1,119 @@
import { Search } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { RefObject, useCallback } from 'react';
import { BrandedPermission } from '../../hooks/useAuthZ/types';
import styles from './AuthZDevModal.module.css';
export interface AuthZDevModalHeaderProps {
searchInputRef: RefObject<HTMLInputElement>;
search: string;
setSearch: (value: string) => void;
resourceFilter: string;
setResourceFilter: (value: string) => void;
resourceFilterItems: Array<{ value: string; label: string }>;
hasActiveFilter: boolean;
filteredPermissions: BrandedPermission[];
filteredOverrideCount: number;
overrideCount: number;
grantAll: (permissions?: BrandedPermission[]) => void;
denyAll: (permissions?: BrandedPermission[]) => void;
clearAllOverrides: (permissions?: BrandedPermission[]) => void;
}
export function AuthZDevModalHeader({
searchInputRef,
search,
setSearch,
resourceFilter,
setResourceFilter,
resourceFilterItems,
hasActiveFilter,
filteredPermissions,
filteredOverrideCount,
overrideCount,
grantAll,
denyAll,
clearAllOverrides,
}: AuthZDevModalHeaderProps): JSX.Element {
const handleGrantAll = useCallback((): void => {
grantAll(hasActiveFilter ? filteredPermissions : undefined);
}, [grantAll, hasActiveFilter, filteredPermissions]);
const handleDenyAll = useCallback((): void => {
denyAll(hasActiveFilter ? filteredPermissions : undefined);
}, [denyAll, hasActiveFilter, filteredPermissions]);
const handleClearAll = useCallback((): void => {
clearAllOverrides(hasActiveFilter ? filteredPermissions : undefined);
}, [clearAllOverrides, hasActiveFilter, filteredPermissions]);
return (
<div className={styles.header}>
<div className={styles.searchRow}>
<div className={styles.search}>
<Input
ref={searchInputRef}
placeholder="Search permissions..."
value={search}
onChange={(e): void => setSearch(e.target.value)}
prefix={<Search size={14} className={styles.searchIcon} />}
aria-label="Search permissions"
data-testid="authz-dev-search"
/>
</div>
<div className={styles.filter}>
<SelectSimple
items={resourceFilterItems}
value={resourceFilter}
onChange={(value): void => setResourceFilter(value as string)}
testId="authz-dev-resource-filter"
withPortal={false}
/>
</div>
</div>
<div className={styles.actionsRow}>
<Button
className={styles.actionButton}
variant="outlined"
color="success"
size="sm"
onClick={handleGrantAll}
disabled={filteredPermissions.length === 0}
data-testid="authz-dev-grant-all"
>
{hasActiveFilter ? 'Grant filtered' : 'Grant all'}
</Button>
<Button
className={styles.actionButton}
variant="outlined"
color="error"
size="sm"
onClick={handleDenyAll}
disabled={filteredPermissions.length === 0}
data-testid="authz-dev-deny-all"
>
{hasActiveFilter ? 'Deny filtered' : 'Deny all'}
</Button>
<Button
className={styles.actionButton}
variant="outlined"
color="secondary"
size="sm"
onClick={handleClearAll}
disabled={
hasActiveFilter ? filteredOverrideCount === 0 : overrideCount === 0
}
data-testid="authz-dev-clear-all"
>
{hasActiveFilter
? `Clear filtered (${filteredOverrideCount})`
: `Clear all (${overrideCount})`}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,72 @@
.segmented {
display: inline-flex;
align-items: center;
gap: var(--spacing-1);
padding: var(--spacing-1);
background: var(--l2-background);
border: 1px solid var(--l3-border);
border-radius: var(--radius-2);
}
.segment {
--button-height: 22px;
--button-padding: 0 var(--spacing-3);
--button-secondary-ghost-hover-foreground: var(--l2-foreground);
--button-variant-ghost-background-color: transparent;
border: none;
--button-border-radius: calc(var(--radius-2) - 1px);
transition:
background 120ms ease,
color 120ms ease;
}
.segment:not(.segmentActive):hover {
--button-secondary-ghost-hover-foreground: var(--l2-foreground-hover);
--button-variant-ghost-background-color: var(--l2-background);
}
.segmentIcon {
display: inline-flex;
align-items: center;
}
.segment.optAuto {
--button-secondary-ghost-hover-foreground: var(--l1-foreground);
--button-variant-ghost-background-color: var(--l3-background);
}
.segment.optGranted {
--button-secondary-ghost-hover-foreground: var(--success-foreground);
--button-variant-ghost-background-color: color-mix(
in srgb,
var(--accent-forest) 22%,
transparent
);
}
.segment.optDenied {
--button-secondary-ghost-hover-foreground: var(--danger-foreground);
--button-variant-ghost-background-color: color-mix(
in srgb,
var(--accent-cherry) 22%,
transparent
);
}
.segment.optDelay {
--button-secondary-ghost-hover-foreground: var(--warning-foreground);
--button-variant-ghost-background-color: color-mix(
in srgb,
var(--accent-amber) 22%,
transparent
);
}
.segment.optError {
--button-secondary-ghost-hover-foreground: var(--danger-foreground);
--button-variant-ghost-background-color: color-mix(
in srgb,
var(--accent-cherry) 22%,
transparent
);
}

View File

@@ -0,0 +1,93 @@
import { Check, Clock, RotateCcw, X, Zap } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import type { BrandedPermission } from '../../hooks/useAuthZ/types';
import { OverrideState } from '../types';
import styles from './OverrideControl.module.css';
import { Button } from '@signozhq/ui/button';
type OverrideControlProps = {
permission: BrandedPermission;
value: OverrideState;
onSelect: (permission: BrandedPermission, state: OverrideState) => void;
};
type OverrideOption = {
state: OverrideState;
label: string;
icon: React.ReactNode;
activeClassName: string;
};
const OVERRIDE_OPTIONS: OverrideOption[] = [
{
state: OverrideState.Reset,
label: 'Auto',
icon: <RotateCcw size={13} />,
activeClassName: styles.optAuto,
},
{
state: OverrideState.Granted,
label: 'Grant',
icon: <Check size={13} />,
activeClassName: styles.optGranted,
},
{
state: OverrideState.Denied,
label: 'Deny',
icon: <X size={13} />,
activeClassName: styles.optDenied,
},
{
state: OverrideState.Delay,
label: 'Delay',
icon: <Clock size={13} />,
activeClassName: styles.optDelay,
},
{
state: OverrideState.Error,
label: 'Error',
icon: <Zap size={13} />,
activeClassName: styles.optError,
},
];
export function OverrideControl({
permission,
value,
onSelect,
}: OverrideControlProps): JSX.Element {
return (
<div className={styles.segmented}>
{OVERRIDE_OPTIONS.map((option) => {
const isActive = value === option.state;
return (
<Button
key={option.state}
type="button"
aria-pressed={isActive}
aria-label={option.label}
title={option.label}
className={cx(styles.segment, {
[styles.segmentActive]: isActive,
[option.activeClassName]: isActive,
})}
variant="ghost"
color="secondary"
onClick={(): void => onSelect(permission, option.state)}
data-testid={`override-${option.state}-${permission}`}
>
<div className={styles.segmentIcon}>{option.icon}</div>
{isActive && (
<Typography.Text as="span" size="small" weight="medium">
{option.label}
</Typography.Text>
)}
</Button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,68 @@
.permissionRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-6);
padding: var(--spacing-2);
border: 1px solid transparent;
border-radius: var(--radius-2);
cursor: pointer;
transition:
background 120ms ease,
border-color 120ms ease;
}
.permissionRow:hover {
background: var(--l2-background-hover);
}
/* Overridden rows carry a faint full border in the override color. */
.permissionRow.rowGranted {
border-color: color-mix(in srgb, var(--accent-forest) 45%, transparent);
}
.permissionRow.rowDenied {
border-color: color-mix(in srgb, var(--accent-cherry) 45%, transparent);
}
.permissionRow.rowDelay {
border-color: color-mix(in srgb, var(--accent-amber) 45%, transparent);
}
.permissionRow.rowError {
border-color: color-mix(in srgb, var(--accent-cherry) 45%, transparent);
}
/* Keyboard selection wins over the override border. */
.permissionRow.isSelected {
border-color: var(--primary);
}
.permissionInfo {
display: flex;
flex: 1 1 auto;
align-items: baseline;
gap: var(--spacing-2);
min-width: 0;
}
.relation {
flex: 0 0 auto;
--typography-color: var(--accent-primary);
}
.separator {
flex: 0 0 auto;
}
.object {
flex: 0 1 auto;
min-width: 0;
}
.permissionMeta {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: var(--spacing-5);
}

View File

@@ -0,0 +1,114 @@
import { Badge, BadgeColor } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { memo, useCallback, useMemo } from 'react';
import type { BrandedPermission } from '../../hooks/useAuthZ/types';
import { parsePermission } from '../../hooks/useAuthZ/utils';
import { OverrideState, type ObservedPermission } from '../types';
import { OverrideControl } from './OverrideControl';
import styles from './PermissionRow.module.css';
type PermissionRowProps = {
observed: ObservedPermission;
override: OverrideState | undefined;
isSelected: boolean;
onSetOverride: (permission: BrandedPermission, state: OverrideState) => void;
onSelect: () => void;
};
const ROW_OVERRIDE_CLASSES: Record<OverrideState, string | null> = {
[OverrideState.Reset]: null,
[OverrideState.Granted]: styles.rowGranted,
[OverrideState.Denied]: styles.rowDenied,
[OverrideState.Delay]: styles.rowDelay,
[OverrideState.Error]: styles.rowError,
};
export const PermissionRow = memo(function PermissionRow({
observed,
override,
isSelected,
onSetOverride,
onSelect,
}: PermissionRowProps): JSX.Element {
const currentState = override ?? OverrideState.Reset;
const { relation, objectId } = useMemo(() => {
const parsed = parsePermission(observed.permission);
const separatorIndex = parsed.object.indexOf(':');
return {
relation: parsed.relation,
objectId:
separatorIndex === -1
? parsed.object
: parsed.object.slice(separatorIndex + 1),
};
}, [observed.permission]);
const handleSetOverride = useCallback(
(permission: BrandedPermission, state: OverrideState): void => {
onSelect();
onSetOverride(permission, state);
},
[onSelect, onSetOverride],
);
let apiColor: BadgeColor = 'secondary';
let apiLabel = 'API ?';
if (observed.apiValue === true) {
apiColor = 'success';
apiLabel = 'API ✓';
} else if (observed.apiValue === false) {
apiColor = 'error';
apiLabel = 'API ✗';
}
return (
<div
className={cx(styles.permissionRow, ROW_OVERRIDE_CLASSES[currentState], {
[styles.isSelected]: isSelected,
})}
data-testid={`permission-row-${observed.permission}`}
>
<div className={styles.permissionInfo}>
<Typography.Text
as="span"
size="small"
weight="medium"
className={styles.relation}
>
{relation}
</Typography.Text>
<Typography.Text
as="span"
size="small"
color="muted"
className={styles.separator}
>
:
</Typography.Text>
<Typography.Text
as="span"
size="small"
truncate={1}
className={styles.object}
>
{objectId}
</Typography.Text>
</div>
<div className={styles.permissionMeta}>
<Badge variant="outline" color={apiColor}>
{apiLabel}
</Badge>
<OverrideControl
permission={observed.permission}
value={currentState}
onSelect={handleSetOverride}
/>
</div>
</div>
);
});

View File

@@ -0,0 +1,172 @@
import { useMemo, useState } from 'react';
import type { BrandedPermission } from '../../hooks/useAuthZ/types';
import { parsePermission } from '../../hooks/useAuthZ/utils';
import type { ObservedPermission, OverrideState } from '../types';
type SelectItem = {
value: string;
label: string;
};
type PermissionGroup = {
resource: string;
items: BrandedPermission[];
};
type UseAuthZDevModalDataResult = {
search: string;
setSearch: (search: string) => void;
resourceFilter: string;
setResourceFilter: (filter: string) => void;
observedList: ObservedPermission[];
resourceFilterItems: SelectItem[];
filteredPermissions: BrandedPermission[];
groups: PermissionGroup[];
orderedPermissions: BrandedPermission[];
indexByPermission: Map<string, number>;
hasActiveFilter: boolean;
filteredOverrideCount: number;
overrideCount: number;
};
export function useAuthZDevModalData(
observed: Record<string, ObservedPermission>,
overrides: Record<string, OverrideState>,
): UseAuthZDevModalDataResult {
const [search, setSearch] = useState('');
const [resourceFilter, setResourceFilter] = useState<string>('all');
const observedList = useMemo(
() =>
Object.values(observed).sort((a, b) =>
a.permission.localeCompare(b.permission),
),
[observed],
);
const resources = useMemo(() => {
const resourceSet = new Set<string>();
for (const obs of observedList) {
const { object } = parsePermission(obs.permission);
const resource = object.split(':')[0];
resourceSet.add(resource);
}
return Array.from(resourceSet).sort();
}, [observedList]);
const resourceFilterItems = useMemo<SelectItem[]>(
() => [
{ value: 'all', label: 'All resources' },
...resources.map((resource) => ({
value: resource,
label: resource,
})),
],
[resources],
);
const filteredPermissions = useMemo(() => {
let filtered = observedList;
if (search) {
const searchLower = search.toLowerCase();
filtered = filtered.filter((obs) =>
obs.permission.toLowerCase().includes(searchLower),
);
}
if (resourceFilter !== 'all') {
filtered = filtered.filter((obs) => {
const { object } = parsePermission(obs.permission);
const resource = object.split(':')[0];
return resource === resourceFilter;
});
}
return filtered.map((obs) => obs.permission);
}, [observedList, search, resourceFilter]);
const { groups, orderedPermissions } = useMemo(() => {
const groupMap = new Map<string, BrandedPermission[]>();
for (const permission of filteredPermissions) {
const { object } = parsePermission(permission);
const resource = object.split(':')[0] || 'other';
const bucket = groupMap.get(resource);
if (bucket) {
bucket.push(permission);
} else {
groupMap.set(resource, [permission]);
}
}
const sortItems = (items: BrandedPermission[]): BrandedPermission[] =>
[...items].sort((a, b) => {
const objA = parsePermission(a).object;
const objB = parsePermission(b).object;
const idA = objA.split(':')[1] ?? '';
const idB = objB.split(':')[1] ?? '';
const isWildcardA = idA === '*';
const isWildcardB = idB === '*';
// Wildcards first
if (isWildcardA && !isWildcardB) {
return -1;
}
if (!isWildcardA && isWildcardB) {
return 1;
}
// Then by object ID, then by full permission
const idCompare = idA.localeCompare(idB);
if (idCompare !== 0) {
return idCompare;
}
return a.localeCompare(b);
});
const sortedGroups = Array.from(groupMap, ([resource, items]) => ({
resource,
items: sortItems(items),
})).sort((a, b) => a.resource.localeCompare(b.resource));
return {
groups: sortedGroups,
orderedPermissions: sortedGroups.flatMap((group) => group.items),
};
}, [filteredPermissions]);
const indexByPermission = useMemo(() => {
const map = new Map<string, number>();
orderedPermissions.forEach((permission, index) => {
map.set(permission, index);
});
return map;
}, [orderedPermissions]);
const hasActiveFilter = search !== '' || resourceFilter !== 'all';
const filteredOverrideCount = useMemo(() => {
if (!hasActiveFilter) {
return Object.keys(overrides).length;
}
return filteredPermissions.filter((p) => p in overrides).length;
}, [hasActiveFilter, overrides, filteredPermissions]);
const overrideCount = Object.keys(overrides).length;
return {
search,
setSearch,
resourceFilter,
setResourceFilter,
observedList,
resourceFilterItems,
filteredPermissions,
groups,
orderedPermissions,
indexByPermission,
hasActiveFilter,
filteredOverrideCount,
overrideCount,
};
}

View File

@@ -0,0 +1,174 @@
import { useCallback, useEffect, useState } from 'react';
import type { BrandedPermission } from '../../hooks/useAuthZ/types';
import { OverrideState, OVERRIDE_CYCLE } from '../types';
type UseModalKeyboardOptions = {
permissions: BrandedPermission[];
overrides: Record<string, OverrideState>;
onCycle: (permission: BrandedPermission) => void;
onSetOverride: (permission: BrandedPermission, state: OverrideState) => void;
onClose: () => void;
searchInputRef: React.RefObject<HTMLInputElement | null>;
};
type UseModalKeyboardResult = {
selectedIndex: number;
setSelectedIndex: (index: number) => void;
};
type KeyContext = {
permissions: BrandedPermission[];
overrides: Record<string, OverrideState>;
selectedIndex: number;
setSelectedIndex: React.Dispatch<React.SetStateAction<number>>;
onCycle: (permission: BrandedPermission) => void;
onSetOverride: (permission: BrandedPermission, state: OverrideState) => void;
};
const ARROW_KEYS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']);
const NUMBER_KEY_INDEX: Record<string, number> = {
'1': 0,
'2': 1,
'3': 2,
'4': 3,
'5': 4,
};
function stepOverrideState(
current: OverrideState,
direction: number,
): OverrideState {
const currentIndex = OVERRIDE_CYCLE.indexOf(current);
const nextIndex =
(currentIndex + direction + OVERRIDE_CYCLE.length) % OVERRIDE_CYCLE.length;
return OVERRIDE_CYCLE[nextIndex];
}
// Arrow keys stay active even while the search input is focused so the list can
// be driven without leaving the search field.
function handleArrowKey(key: string, ctx: KeyContext): void {
if (key === 'ArrowDown') {
ctx.setSelectedIndex((prev) =>
Math.min(prev + 1, ctx.permissions.length - 1),
);
return;
}
if (key === 'ArrowUp') {
ctx.setSelectedIndex((prev) => Math.max(prev - 1, 0));
return;
}
const selected = ctx.permissions[ctx.selectedIndex];
if (!selected) {
return;
}
const direction = key === 'ArrowLeft' ? -1 : 1;
ctx.onSetOverride(
selected,
stepOverrideState(ctx.overrides[selected] ?? OverrideState.Reset, direction),
);
}
// Number and space/enter shortcuts type into the search field, so they only run
// when it is not focused. Returns whether the key was handled.
function handleActionKey(key: string, ctx: KeyContext): boolean {
const selected = ctx.permissions[ctx.selectedIndex];
const numberIndex = NUMBER_KEY_INDEX[key];
if (numberIndex !== undefined) {
if (selected) {
ctx.onSetOverride(selected, OVERRIDE_CYCLE[numberIndex]);
}
return true;
}
if (key === ' ' || key === 'Enter') {
if (selected) {
ctx.onCycle(selected);
}
return true;
}
return false;
}
export function useModalKeyboard({
permissions,
overrides,
onCycle,
onSetOverride,
onClose,
searchInputRef,
}: UseModalKeyboardOptions): UseModalKeyboardResult {
// Start with no selection (-1) to avoid accidental override changes from
// Enter keypress that opened the modal also triggering cycleOverride.
const [selectedIndex, setSelectedIndex] = useState(-1);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
const isSearchFocused = document.activeElement === searchInputRef.current;
if (e.key === 'Escape') {
e.preventDefault();
onClose();
return;
}
if (e.key === '/') {
if (!isSearchFocused) {
e.preventDefault();
searchInputRef.current?.focus();
}
return;
}
const ctx: KeyContext = {
permissions,
overrides,
selectedIndex,
setSelectedIndex,
onCycle,
onSetOverride,
};
if (ARROW_KEYS.has(e.key)) {
e.preventDefault();
handleArrowKey(e.key, ctx);
return;
}
if (isSearchFocused) {
return;
}
if (handleActionKey(e.key, ctx)) {
e.preventDefault();
}
},
[
permissions,
overrides,
selectedIndex,
onCycle,
onSetOverride,
onClose,
searchInputRef,
],
);
useEffect((): (() => void) => {
window.addEventListener('keydown', handleKeyDown);
return (): void => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [handleKeyDown]);
useEffect((): void => {
if (selectedIndex >= permissions.length && permissions.length > 0) {
setSelectedIndex(permissions.length - 1);
}
}, [permissions.length, selectedIndex]);
return {
selectedIndex,
setSelectedIndex,
};
}

View File

@@ -0,0 +1,46 @@
import type { BrandedPermission } from '../hooks/useAuthZ/types';
export enum OverrideState {
Granted = 'granted',
Denied = 'denied',
Delay = 'delay',
Error = 'error',
Reset = 'reset',
}
export type ObservedPermission = {
permission: BrandedPermission;
apiValue: boolean | null;
lastSeen: number;
};
export type PermissionOverride = {
permission: BrandedPermission;
state: OverrideState;
};
export type AuthZDevStore = {
isModalOpen: boolean;
observed: Record<string, ObservedPermission>;
overrides: Record<string, OverrideState>;
openModal: () => void;
closeModal: () => void;
toggleModal: () => void;
registerObserved: (permission: BrandedPermission, apiValue: boolean) => void;
setOverride: (permission: BrandedPermission, state: OverrideState) => void;
clearOverride: (permission: BrandedPermission) => void;
clearAllOverrides: (permissions?: BrandedPermission[]) => void;
grantAll: (permissions?: BrandedPermission[]) => void;
denyAll: (permissions?: BrandedPermission[]) => void;
cycleOverride: (permission: BrandedPermission) => void;
};
export const OVERRIDE_CYCLE: OverrideState[] = [
OverrideState.Reset,
OverrideState.Granted,
OverrideState.Denied,
OverrideState.Delay,
OverrideState.Error,
];

View File

@@ -0,0 +1,137 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { BrandedPermission } from '../hooks/useAuthZ/types';
import { OverrideState, OVERRIDE_CYCLE, type AuthZDevStore } from './types';
import { getScopedKey } from 'utils/storage';
export const useAuthZDevStore = create<AuthZDevStore>()(
persist(
(set, get) => ({
isModalOpen: false,
observed: {},
overrides: {},
openModal: (): void => {
set({ isModalOpen: true });
},
closeModal: (): void => {
set({ isModalOpen: false });
},
toggleModal: (): void => {
set((state) => ({ isModalOpen: !state.isModalOpen }));
},
registerObserved: (
permission: BrandedPermission,
apiValue: boolean,
): void => {
set((state) => ({
observed: {
...state.observed,
[permission]: {
permission,
apiValue,
lastSeen: Date.now(),
},
},
}));
},
setOverride: (permission: BrandedPermission, state: OverrideState): void => {
if (state === OverrideState.Reset) {
get().clearOverride(permission);
return;
}
set((s) => ({
overrides: {
...s.overrides,
[permission]: state,
},
}));
},
clearOverride: (permission: BrandedPermission): void => {
set((state) => {
const { [permission]: _, ...rest } = state.overrides;
return { overrides: rest };
});
},
clearAllOverrides: (permissions?: BrandedPermission[]): void => {
if (permissions) {
set((state) => {
const newOverrides = { ...state.overrides };
for (const permission of permissions) {
delete newOverrides[permission];
}
return { overrides: newOverrides };
});
} else {
set({ overrides: {} });
}
},
grantAll: (permissions?: BrandedPermission[]): void => {
set((state) => {
const keys = permissions ?? Object.keys(state.observed);
const newOverrides: Record<string, OverrideState> = {
...state.overrides,
};
for (const key of keys) {
newOverrides[key] = OverrideState.Granted;
}
return { overrides: newOverrides };
});
},
denyAll: (permissions?: BrandedPermission[]): void => {
set((state) => {
const keys = permissions ?? Object.keys(state.observed);
const newOverrides: Record<string, OverrideState> = {
...state.overrides,
};
for (const key of keys) {
newOverrides[key] = OverrideState.Denied;
}
return { overrides: newOverrides };
});
},
cycleOverride: (permission: BrandedPermission): void => {
const currentOverride = get().overrides[permission] ?? OverrideState.Reset;
const currentIndex = OVERRIDE_CYCLE.indexOf(currentOverride);
const nextIndex = (currentIndex + 1) % OVERRIDE_CYCLE.length;
const nextState = OVERRIDE_CYCLE[nextIndex];
get().setOverride(permission, nextState);
},
}),
{
name: `@signoz/${getScopedKey('authz-dev-overrides')}`,
partialize: (state) => {
// Clear apiValue for permissions without active override (auto mode)
// since the API value can change between sessions
const observed: typeof state.observed = {};
for (const [key, obs] of Object.entries(state.observed)) {
observed[key] = {
...obs,
apiValue: key in state.overrides ? obs.apiValue : null,
};
}
return {
observed,
overrides: state.overrides,
};
},
},
),
);
export const openAuthZDevModal = (): void =>
useAuthZDevStore.getState().openModal();
export const closeAuthZDevModal = (): void =>
useAuthZDevStore.getState().closeModal();
export const toggleAuthZDevModal = (): void =>
useAuthZDevStore.getState().toggleModal();

View File

@@ -0,0 +1,28 @@
import { useEffect, useRef } from 'react';
import { useQueryClient } from 'react-query';
import type { OverrideState } from './types';
type Overrides = Record<string, OverrideState>;
export function useAuthZQueryInvalidation(overrides: Overrides): void {
const queryClient = useQueryClient();
const prevOverridesRef = useRef<Overrides>(overrides);
useEffect(() => {
const prevOverrides = prevOverridesRef.current;
prevOverridesRef.current = overrides;
const allKeys = new Set([
...Object.keys(prevOverrides),
...Object.keys(overrides),
]);
for (const key of allKeys) {
if (prevOverrides[key] !== overrides[key]) {
// Reset query to initial state and trigger refetch for active observers
void queryClient.resetQueries(['authz', key]);
}
}
}, [overrides, queryClient]);
}

View File

@@ -89,5 +89,13 @@ export type UseAuthZResult = {
isFetching: boolean;
error: Error | null;
permissions: AuthZCheckResponse | null;
/**
* True if every check is granted. False while loading or on error.
*/
allowed: boolean;
/**
* Checks that resolved as not granted (empty while loading/error).
*/
deniedPermissions: BrandedPermission[];
refetchPermissions: () => void;
};

View File

@@ -1,5 +1,6 @@
import { ReactElement } from 'react';
import { renderHook, waitFor } from '@testing-library/react';
import { renderHook, waitFor, act } from '@testing-library/react';
import { useQueryClient } from 'react-query';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { AllTheProviders } from 'tests/test-utils';
@@ -46,12 +47,16 @@ describe('useAuthZ', () => {
expect(result.current.isLoading).toBe(true);
expect(result.current.permissions).toBeNull();
expect(result.current.allowed).toBe(false);
expect(result.current.deniedPermissions).toStrictEqual([]);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.permissions).toStrictEqual(expectedResponse);
expect(result.current.allowed).toBe(false);
expect(result.current.deniedPermissions).toStrictEqual([permission2]);
});
it('should return error and null permissions when API errors', async () => {
@@ -73,6 +78,89 @@ describe('useAuthZ', () => {
expect(result.current.error).not.toBeNull();
expect(result.current.permissions).toBeNull();
expect(result.current.allowed).toBe(false);
expect(result.current.deniedPermissions).toStrictEqual([]);
});
it('should set allowed to true when all permissions are granted', async () => {
const permission1 = buildPermission('read', 'role:*');
const permission2 = buildPermission('update', 'role:123');
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
const payload = await req.json();
return res(
ctx.status(200),
ctx.json(authzMockResponse(payload, [true, true])),
);
}),
);
const { result } = renderHook(() => useAuthZ([permission1, permission2]), {
wrapper,
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.allowed).toBe(true);
expect(result.current.deniedPermissions).toStrictEqual([]);
});
it('should collect all denied permissions when multiple are denied', async () => {
const permission1 = buildPermission('read', 'role:*');
const permission2 = buildPermission('update', 'role:123');
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
const payload = await req.json();
return res(
ctx.status(200),
ctx.json(authzMockResponse(payload, [false, false])),
);
}),
);
const { result } = renderHook(() => useAuthZ([permission1, permission2]), {
wrapper,
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.allowed).toBe(false);
expect(result.current.deniedPermissions).toStrictEqual([
permission1,
permission2,
]);
});
it('should not fetch when enabled is false', async () => {
let requestCount = 0;
const permission = buildPermission('read', 'role:*');
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
requestCount += 1;
const payload = await req.json();
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [true])));
}),
);
const { result } = renderHook(
() => useAuthZ([permission], { enabled: false }),
{ wrapper },
);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(requestCount).toBe(0);
expect(result.current.allowed).toBe(false);
expect(result.current.permissions).toStrictEqual({});
});
it('should refetch when permissions array changes', async () => {
@@ -474,3 +562,120 @@ describe('useAuthZ', () => {
expect(result2.current.permissions).not.toHaveProperty(permission1);
});
});
describe('useAuthZ cache invalidation', () => {
it('should re-render with updated data when query is invalidated', async () => {
const permission = buildPermission('read', 'role:*');
let requestCount = 0;
let shouldGrant = true;
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
requestCount++;
const payload = await req.json();
return res(
ctx.status(200),
ctx.json(authzMockResponse(payload, [shouldGrant])),
);
}),
);
const { result } = renderHook(
() => {
const queryClient = useQueryClient();
const authz = useAuthZ([permission]);
return { authz, queryClient };
},
{ wrapper },
);
await waitFor(() => {
expect(result.current.authz.isLoading).toBe(false);
});
expect(requestCount).toBe(1);
expect(result.current.authz.allowed).toBe(true);
expect(result.current.authz.permissions).toStrictEqual({
[permission]: { isGranted: true },
});
// Change server response and reset query (forces refetch)
shouldGrant = false;
await act(async () => {
await result.current.queryClient.resetQueries(['authz', permission]);
});
await waitFor(() => {
expect(result.current.authz.allowed).toBe(false);
});
expect(requestCount).toBe(2);
expect(result.current.authz.permissions).toStrictEqual({
[permission]: { isGranted: false },
});
});
it('should re-render all components using the same permission when invalidated', async () => {
const permission = buildPermission('update', 'role:123');
let requestCount = 0;
let shouldGrant = true;
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
requestCount++;
const payload = await req.json();
return res(
ctx.status(200),
ctx.json(authzMockResponse(payload, [shouldGrant])),
);
}),
);
// Two separate hooks using the same permission
const { result: result1 } = renderHook(
() => {
const queryClient = useQueryClient();
const authz = useAuthZ([permission]);
return { authz, queryClient };
},
{ wrapper },
);
const { result: result2 } = renderHook(() => useAuthZ([permission]), {
wrapper,
});
await waitFor(() => {
expect(result1.current.authz.isLoading).toBe(false);
expect(result2.current.isLoading).toBe(false);
});
// Both should show granted, single batched request
expect(requestCount).toBe(1);
expect(result1.current.authz.allowed).toBe(true);
expect(result2.current.allowed).toBe(true);
// Change server response and reset query (forces refetch)
shouldGrant = false;
await act(async () => {
await result1.current.queryClient.resetQueries(['authz', permission]);
});
// Both hooks should update
await waitFor(() => {
expect(result1.current.authz.allowed).toBe(false);
expect(result2.current.allowed).toBe(false);
});
expect(result1.current.authz.permissions).toStrictEqual({
[permission]: { isGranted: false },
});
expect(result2.current.permissions).toStrictEqual({
[permission]: { isGranted: false },
});
});
});

View File

@@ -1,13 +1,15 @@
import { useCallback, useMemo } from 'react';
import { useQueries } from 'react-query';
import { isAxiosError } from 'axios';
import { authzCheck } from 'api/generated/services/authz';
import type {
CoretypesObjectDTO,
AuthtypesTransactionDTO,
} from 'api/generated/services/sigNoz.schemas';
import { IS_DEV, MODE } from 'lib/env';
import { AUTHZ_CACHE_TIME, SINGLE_FLIGHT_WAIT_TIME_MS } from './constants';
import {
import type {
AuthZCheckResponse,
BrandedPermission,
UseAuthZOptions,
@@ -17,6 +19,59 @@ import {
gettableTransactionToPermission,
permissionToTransactionDto,
} from './utils';
import { OverrideState } from '../../devtools/types';
let devStoreRef:
| typeof import('../../devtools/useAuthZDevStore').useAuthZDevStore
| null = null;
if (IS_DEV) {
void import('../../devtools/useAuthZDevStore').then((mod) => {
devStoreRef = mod.useAuthZDevStore;
return mod;
});
}
const DEV_DELAY_MS = 2000;
function getDevOverride(permission: BrandedPermission): OverrideState | null {
if (!IS_DEV || !devStoreRef) {
return null;
}
return devStoreRef.getState().overrides[permission] ?? null;
}
async function applyDevOverrideToQuery(
permission: BrandedPermission,
fetchFn: () => Promise<AuthZCheckResponse>,
): Promise<AuthZCheckResponse> {
const override = getDevOverride(permission);
if (override === OverrideState.Error) {
throw new Error(`[AuthZ DevTools] Simulated error for: ${permission}`);
}
if (override === OverrideState.Delay) {
await new Promise((resolve) => setTimeout(resolve, DEV_DELAY_MS));
}
const response = await fetchFn();
if (IS_DEV && devStoreRef) {
const apiValue = response[permission]?.isGranted ?? false;
devStoreRef.getState().registerObserved(permission, apiValue);
}
if (override === OverrideState.Granted) {
return { [permission]: { isGranted: true } };
}
if (override === OverrideState.Denied) {
return { [permission]: { isGranted: false } };
}
return response;
}
let ctx: Promise<AuthZCheckResponse> | null;
let pendingPermissions: BrandedPermission[] = [];
@@ -27,10 +82,11 @@ function dispatchPermission(
pendingPermissions.push(permission);
if (!ctx) {
let resolve: (v: AuthZCheckResponse) => void, reject: (reason?: any) => void;
ctx = new Promise<AuthZCheckResponse>((r, re) => {
resolve = r;
reject = re;
let promiseResolve: (v: AuthZCheckResponse) => void,
promiseReject: (reason?: unknown) => void;
ctx = new Promise<AuthZCheckResponse>((resolve, reject) => {
promiseResolve = resolve;
promiseReject = reject;
});
setTimeout(() => {
@@ -38,7 +94,9 @@ function dispatchPermission(
pendingPermissions = [];
ctx = null;
fetchManyPermissions(copiedPermissions).then(resolve).catch(reject);
fetchManyPermissions(copiedPermissions)
.then(promiseResolve)
.catch(promiseReject);
}, SINGLE_FLIGHT_WAIT_TIME_MS);
}
@@ -85,19 +143,50 @@ export function useAuthZ(
return {
queryKey: ['authz', permission],
cacheTime: AUTHZ_CACHE_TIME,
staleTime: AUTHZ_CACHE_TIME,
// Keep errored state in cache instead of refetching when new observers subscribe
retryOnMount: false,
// Only override retry in non-test mode to avoid interfering with test-utils QueryClient defaults
...(MODE !== 'test' && {
retry: (failureCount: number, error: unknown): boolean => {
// Don't retry simulated dev errors - they will always fail
if (
error instanceof Error &&
error.message.includes('[AuthZ DevTools]')
) {
return false;
}
// Don't retry server errors (5xx) - they won't recover
if (
isAxiosError(error) &&
error.response?.status &&
error.response.status >= 500
) {
return false;
}
return failureCount < 3;
},
}),
refetchOnMount: false,
refetchIntervalInBackground: false,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
enabled,
queryFn: async (): Promise<AuthZCheckResponse> => {
const response = await dispatchPermission(permission);
return {
[permission]: {
isGranted: response[permission].isGranted,
},
const fetchFn = async (): Promise<AuthZCheckResponse> => {
const response = await dispatchPermission(permission);
return {
[permission]: {
isGranted: response[permission].isGranted,
},
};
};
if (IS_DEV) {
return applyDevOverrideToQuery(permission, fetchFn);
}
return fetchFn();
},
};
}),
@@ -107,6 +196,7 @@ export function useAuthZ(
() => queryResults.some((q) => q.isLoading),
[queryResults],
);
const isFetching = useMemo(
() => queryResults.some((q) => q.isFetching),
[queryResults],
@@ -139,15 +229,31 @@ export function useAuthZ(
const refetchPermissions = useCallback(() => {
for (const query of queryResults) {
query.refetch();
void query.refetch();
}
}, [queryResults]);
const allowed = useMemo(() => {
if (isLoading || error || !data) {
return false;
}
return permissions.every((check) => data[check]?.isGranted === true);
}, [permissions, data, isLoading, error]);
const deniedPermissions = useMemo(() => {
if (!data) {
return [];
}
return permissions.filter((check) => data[check]?.isGranted !== true);
}, [permissions, data]);
return {
isLoading,
isFetching,
error,
permissions: data ?? null,
allowed,
deniedPermissions,
refetchPermissions,
};
}

View File

@@ -168,6 +168,8 @@ export function mockUseAuthZGrantAll(
permissions: Object.fromEntries(
permissions.map((p) => [p, { isGranted: true }]),
) as UseAuthZResult['permissions'],
allowed: true,
deniedPermissions: [],
refetchPermissions: jest.fn(),
};
}
@@ -183,6 +185,8 @@ export function mockUseAuthZDenyAll(
permissions: Object.fromEntries(
permissions.map((p) => [p, { isGranted: false }]),
) as UseAuthZResult['permissions'],
allowed: false,
deniedPermissions: permissions,
refetchPermissions: jest.fn(),
};
}
@@ -193,16 +197,23 @@ export function mockUseAuthZGrantByPrefix(
permissions: BrandedPermission[],
options?: UseAuthZOptions,
) => UseAuthZResult {
return (permissions, _options) => ({
isLoading: false,
isFetching: false,
error: null,
permissions: Object.fromEntries(
permissions.map((p) => [
p,
{ isGranted: prefixes.some((prefix) => p.startsWith(prefix)) },
]),
) as UseAuthZResult['permissions'],
refetchPermissions: jest.fn(),
});
return (permissions, _options) => {
const denied = permissions.filter(
(p) => !prefixes.some((prefix) => p.startsWith(prefix)),
);
return {
isLoading: false,
isFetching: false,
error: null,
permissions: Object.fromEntries(
permissions.map((p) => [
p,
{ isGranted: prefixes.some((prefix) => p.startsWith(prefix)) },
]),
) as UseAuthZResult['permissions'],
allowed: denied.length === 0,
deniedPermissions: denied,
refetchPermissions: jest.fn(),
};
};
}

3
frontend/src/lib/env.ts Normal file
View File

@@ -0,0 +1,3 @@
export const IS_DEV = import.meta.env.DEV;
export const IS_PROD = import.meta.env.PROD;
export const MODE = import.meta.env.MODE;

View File

@@ -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}

View File

@@ -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,

View File

@@ -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

View File

@@ -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: ({

View File

@@ -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,

View File

@@ -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);
@@ -63,8 +66,13 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
const { user } = useAppContext();
const { showErrorModal } = useErrorModal();
const { patchAsync } = useOptimisticPatch();
const { isPickerOpen, openPicker, closePicker, createPanel } =
useCreatePanel();
const {
isPickerOpen,
openPicker,
closePicker,
createPanel,
targetLayoutIndex,
} = useCreatePanel();
const isAuthor =
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
@@ -96,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> => {
@@ -183,6 +200,7 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
open={isPickerOpen}
onClose={closePicker}
onSelect={createPanel}
defaultLayoutIndex={targetLayoutIndex}
/>
</section>
);

View File

@@ -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

View File

@@ -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>

View File

@@ -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();

View File

@@ -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');
});
});

View File

@@ -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]);
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -18,3 +18,11 @@
@include custom-scrollbar;
}
}
.clickableCell {
cursor: pointer;
&:hover {
color: var(--primary-background);
}
}

View File

@@ -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'>;
}

View File

@@ -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([]);
});
});

View File

@@ -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,

View File

@@ -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;

View File

@@ -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),
});
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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'

View File

@@ -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);
}

View File

@@ -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, '-');
}

View File

@@ -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}

View File

@@ -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,
});

View File

@@ -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');
});
});

View File

@@ -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.

View File

@@ -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,

View File

@@ -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}
/>
)}

View File

@@ -1,22 +1,69 @@
.panelTypeSection {
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
}
.grid {
align-self: stretch;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.typeButton {
.panelTypeCard {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
border: 1px solid var(--l2-border);
background: var(--l2-background);
padding: 12px;
background: var(--bg-ink-400, #0b0c0e);
border: 1px solid var(--l1-border);
gap: 12px;
cursor: pointer;
font: inherit;
border-radius: 4px;
color: var(--l1-foreground);
cursor: pointer;
text-align: left;
transition:
transform 180ms ease,
border-color 180ms ease;
&:hover {
border-color: var(--bg-robin-500);
background-color: var(--l2-background-hover);
border-color: var(--bg-robin-400);
}
&:active {
transform: translateY(2px);
}
}
.panelTypeCardSelected {
border-color: var(--bg-robin-400);
background-color: var(--l2-background-hover);
box-shadow: inset 0 0 0 1px var(--bg-robin-400);
}
.footerActions {
display: flex;
align-items: flex-end;
justify-content: flex-end;
gap: 8px;
width: 100%;
}
.footerPicker {
// Take all the width left over by the (natural-width) confirm button.
display: flex;
flex: 1;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.pickerLabel {
color: var(--l3-foreground);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.06em;
text-transform: uppercase;
}

View File

@@ -1,45 +1,93 @@
import { Modal } from 'antd';
import { Button } from '@signozhq/ui/button';
import { useEffect, useMemo, useState } from 'react';
import { Color } from '@signozhq/design-tokens';
import { DialogWrapper } from '@signozhq/ui/dialog';
import cx from 'classnames';
import { useDashboardSections } from '../../../hooks/useDashboardSections';
import type { PanelKind } from '../../../Panels/types/panelKind';
import { PANEL_TYPES } from './constants';
import PanelTypeSelectionModalFooter from './PanelTypeSelectionModalFooter';
import { buildSectionOptions, resolveDefaultSectionValue } from './utils';
import styles from './PanelTypeSelectionModal.module.scss';
interface PanelTypeSelectionModalProps {
open: boolean;
onClose: () => void;
onSelect: (panelKind: PanelKind) => void;
onSelect: (panelKind: PanelKind, layoutIndex?: number) => void;
/** Section the picker opens on; omit → the first section. */
defaultLayoutIndex?: number;
}
function PanelTypeSelectionModal({
open,
onClose,
onSelect,
defaultLayoutIndex,
}: PanelTypeSelectionModalProps): JSX.Element {
const sections = useDashboardSections();
const options = useMemo(() => buildSectionOptions(sections), [sections]);
const [selectedValue, setSelectedValue] = useState('');
const [selectedPanelKind, setSelectedPanelKind] = useState<PanelKind | null>(
null,
);
// Seed the target section on open.
useEffect(() => {
if (open) {
setSelectedValue(resolveDefaultSectionValue(options, defaultLayoutIndex));
setSelectedPanelKind(null);
}
}, [open, options, defaultLayoutIndex]);
const handleConfirm = (): void => {
if (selectedPanelKind === null) {
return;
}
const layoutIndex = selectedValue === '' ? undefined : Number(selectedValue);
onSelect(selectedPanelKind, layoutIndex);
};
return (
<Modal
<DialogWrapper
open={open}
title="Select a panel type"
onCancel={onClose}
footer={null}
destroyOnClose
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
title="New Panel"
footer={
<PanelTypeSelectionModalFooter
options={options}
selectedValue={selectedValue}
onSectionChange={setSelectedValue}
isConfirmDisabled={selectedPanelKind === null}
onConfirm={handleConfirm}
/>
}
>
<div className={styles.grid}>
{PANEL_TYPES.map(({ panelKind, label, Icon }) => (
<Button
key={panelKind}
type="button"
variant="ghost"
className={styles.typeButton}
data-testid={`panel-type-${panelKind}`}
onClick={(): void => onSelect(panelKind)}
>
<Icon size={14} />
{label}
</Button>
))}
<div className={styles.panelTypeSection}>
<span className={styles.pickerLabel}>Select panel type</span>
<div className={styles.grid}>
{PANEL_TYPES.map(({ panelKind, label, Icon }) => (
<button
key={panelKind}
type="button"
className={cx(styles.panelTypeCard, {
[styles.panelTypeCardSelected]: panelKind === selectedPanelKind,
})}
data-testid={`panel-type-${panelKind}`}
aria-pressed={panelKind === selectedPanelKind}
onClick={(): void => setSelectedPanelKind(panelKind)}
>
<Icon size={24} color={Color.BG_ROBIN_400} />
{label}
</button>
))}
</div>
</div>
</Modal>
</DialogWrapper>
);
}

View File

@@ -0,0 +1,56 @@
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import SectionPicker from './SectionPicker';
import type { SectionOption } from './types';
import styles from './PanelTypeSelectionModal.module.scss';
interface PanelTypeSelectionModalFooterProps {
options: SectionOption[];
selectedValue: string;
onSectionChange: (value: string) => void;
/** Disabled until a panel type is picked. */
isConfirmDisabled: boolean;
onConfirm: () => void;
}
/**
* Footer for the New Panel modal: an "Add panel to" section picker (shown only
* when the dashboard has more than one section) and the confirm button.
*/
function PanelTypeSelectionModalFooter({
options,
selectedValue,
onSectionChange,
isConfirmDisabled,
onConfirm,
}: PanelTypeSelectionModalFooterProps): JSX.Element {
const hasSectionPicker = options.length > 1;
return (
<div className={styles.footerActions}>
{hasSectionPicker && (
<div className={styles.footerPicker}>
<span className={styles.pickerLabel}>Add panel to</span>
<SectionPicker
options={options}
value={selectedValue}
onChange={onSectionChange}
/>
</div>
)}
<Button
color="primary"
size="md"
disabled={isConfirmDisabled}
prefix={<Plus size={16} />}
onClick={onConfirm}
testId="panel-type-confirm"
>
Add Panel
</Button>
</div>
);
}
export default PanelTypeSelectionModalFooter;

View File

@@ -0,0 +1,55 @@
.select {
width: 100%;
}
.dropdown {
min-width: 260px;
}
.rootIcon {
color: var(--bg-robin-400);
flex-shrink: 0;
}
.sectionIcon {
color: var(--l3-foreground);
flex-shrink: 0;
}
.triggerValue {
display: flex;
align-items: center;
gap: 8px;
overflow: hidden;
}
.triggerLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.optionRow {
display: flex;
align-items: center;
gap: 12px;
padding: 4px 0;
}
.optionText {
display: flex;
flex-direction: column;
gap: 2px;
overflow: hidden;
}
.optionLabel {
color: var(--l1-foreground);
line-height: 1.2;
}
.optionDescription {
color: var(--l3-foreground);
font-size: 12px;
line-height: 1.2;
}

View File

@@ -0,0 +1,65 @@
import { useMemo } from 'react';
// eslint-disable-next-line signoz/no-antd-components
import { Select } from 'antd';
import type { SectionOption } from './types';
import styles from './SectionPicker.module.scss';
interface SectionPickerProps {
options: SectionOption[];
value: string;
onChange: (value: string) => void;
}
function SectionPicker({
options,
value,
onChange,
}: SectionPickerProps): JSX.Element {
// `selectedLabel` (one line) shows in the trigger; `label` (two lines) in the list.
const selectOptions = useMemo(
() =>
options.map((option) => {
const iconClass = option.isRoot ? styles.rootIcon : styles.sectionIcon;
return {
value: option.value,
selectedLabel: (
<span className={styles.triggerValue}>
<option.Icon size={16} className={iconClass} />
<span className={styles.triggerLabel}>{option.label}</span>
</span>
),
label: (
<span
className={styles.optionRow}
data-testid={`panel-section-option-${option.layoutIndex}`}
>
<option.Icon size={16} className={iconClass} />
<span className={styles.optionText}>
<span className={styles.optionLabel}>{option.label}</span>
<span className={styles.optionDescription}>{option.description}</span>
</span>
</span>
),
};
}),
[options],
);
return (
<Select<string>
className={styles.select}
popupClassName={styles.dropdown}
value={value}
onChange={onChange}
data-testid="panel-section-select"
optionLabelProp="selectedLabel"
getPopupContainer={(trigger): HTMLElement =>
trigger.parentElement ?? document.body
}
options={selectOptions}
/>
);
}
export default SectionPicker;

View File

@@ -14,3 +14,16 @@ export interface PanelType {
/** Icon component — the consumer renders it and controls size/color/etc. */
Icon: ComponentType<IconProps>;
}
export interface SectionOption {
/** The section's `layoutIndex`, stringified for the Select value. */
value: string;
layoutIndex: number;
/** Section title, or "Dashboard (root)" for the untitled top-level layout. */
label: string;
/** Caption under the label. */
description: string;
/** Untitled top-level layout (has no section header). */
isRoot: boolean;
Icon: ComponentType<IconProps>;
}

View File

@@ -0,0 +1,42 @@
import { LayoutDashboard, Rows2 } from '@signozhq/icons';
import { findRootSection, type DashboardSection } from '../../../utils';
import type { SectionOption } from './types';
const ROOT_LABEL = 'Dashboard (root)';
const ROOT_DESCRIPTION = 'Top level — no section';
const SECTION_DESCRIPTION = 'Section';
/** Maps dashboard sections to section-picker options (untitled → "root"). */
export function buildSectionOptions(
sections: DashboardSection[],
): SectionOption[] {
const rootSection = findRootSection(sections);
return sections.map((section) => {
const isRoot = rootSection === section;
return {
value: String(section.layoutIndex),
layoutIndex: section.layoutIndex,
label: isRoot ? ROOT_LABEL : (section.title as string),
description: isRoot ? ROOT_DESCRIPTION : SECTION_DESCRIPTION,
isRoot,
Icon: isRoot ? LayoutDashboard : Rows2,
};
});
}
/**
* Picks the option the picker should open on: the section the "Add panel" was
* triggered from when present and still valid, otherwise the first option.
*/
export function resolveDefaultSectionValue(
options: SectionOption[],
defaultLayoutIndex: number | undefined,
): string {
const fallback = options[0]?.value ?? '';
if (defaultLayoutIndex === undefined) {
return fallback;
}
const target = String(defaultLayoutIndex);
return options.some((option) => option.value === target) ? target : fallback;
}

View File

@@ -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>
);
}

View File

@@ -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,
};
}

View File

@@ -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,
};

View File

@@ -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);
});
});

View File

@@ -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();
});
});

View File

@@ -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);
});
});

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