mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-07 23:20:37 +01:00
Compare commits
13 Commits
platform-p
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cec72e25fd | ||
|
|
82c833044d | ||
|
|
f98b8bb1fd | ||
|
|
2114fbddfe | ||
|
|
b2214cf5a3 | ||
|
|
db4219e0f1 | ||
|
|
165c945511 | ||
|
|
ff19aedaa8 | ||
|
|
ef8319585b | ||
|
|
14ba332935 | ||
|
|
fda7b66d49 | ||
|
|
de2212e8b9 | ||
|
|
ff4f18985c |
@@ -155,8 +155,8 @@ querier:
|
||||
cache_ttl: 168h
|
||||
# The interval for recent data that should not be cached.
|
||||
flux_interval: 5m
|
||||
# The maximum number of concurrent queries for missing ranges.
|
||||
max_concurrent_queries: 4
|
||||
# The maximum number of queries a single query range request runs at once.
|
||||
max_concurrent_queries: 8
|
||||
# When filtering logs by trace_id, clamp the query window to the trace time
|
||||
# range with padding to include slightly delayed log exports. Logs only; set
|
||||
# to 0 to disable.
|
||||
|
||||
3
frontend/__mocks__/lib/env.ts
Normal file
3
frontend/__mocks__/lib/env.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const IS_DEV = false;
|
||||
export const IS_PROD = true;
|
||||
export const MODE = 'test';
|
||||
@@ -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',
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
"event-source-polyfill": "1.0.31",
|
||||
"eventemitter3": "5.0.1",
|
||||
"history": "4.10.1",
|
||||
"html-to-image": "1.11.13",
|
||||
"http-status-codes": "2.3.0",
|
||||
"i18next": "^21.6.12",
|
||||
"i18next-browser-languagedetector": "^6.1.3",
|
||||
|
||||
8
frontend/pnpm-lock.yaml
generated
8
frontend/pnpm-lock.yaml
generated
@@ -164,6 +164,9 @@ importers:
|
||||
history:
|
||||
specifier: 4.10.1
|
||||
version: 4.10.1
|
||||
html-to-image:
|
||||
specifier: 1.11.13
|
||||
version: 1.11.13
|
||||
http-status-codes:
|
||||
specifier: 2.3.0
|
||||
version: 2.3.0
|
||||
@@ -5451,6 +5454,9 @@ packages:
|
||||
resolution: {integrity: sha512-n6l5uca7/y5joxZ3LUePhzmBFUJ+U2YWzhMa8XUTecSeSlQiZdF5XAd/Q3/WUl0VsXgUwWi8I7CNIwdI5WN1SQ==}
|
||||
engines: {node: '>=20.10'}
|
||||
|
||||
html-to-image@1.11.13:
|
||||
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
|
||||
|
||||
html-void-elements@3.0.0:
|
||||
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
|
||||
|
||||
@@ -14495,6 +14501,8 @@ snapshots:
|
||||
|
||||
html-tags@5.1.0: {}
|
||||
|
||||
html-to-image@1.11.13: {}
|
||||
|
||||
html-void-elements@3.0.0: {}
|
||||
|
||||
http-proxy-agent@5.0.0:
|
||||
|
||||
@@ -62,6 +62,6 @@
|
||||
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
|
||||
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
|
||||
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
|
||||
"LLM_OBSERVABILITY_BASE": "SigNoz | LLM Observability",
|
||||
"LLM_OBSERVABILITY_MODEL_PRICING": "SigNoz | Model Pricing"
|
||||
}
|
||||
"LLM_OBSERVABILITY_OVERVIEW": "SigNoz | LLM Observability Overview",
|
||||
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration"
|
||||
}
|
||||
@@ -87,6 +87,6 @@
|
||||
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
|
||||
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
|
||||
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
|
||||
"LLM_OBSERVABILITY_BASE": "SigNoz | LLM Observability",
|
||||
"LLM_OBSERVABILITY_MODEL_PRICING": "SigNoz | Model Pricing"
|
||||
}
|
||||
"LLM_OBSERVABILITY_OVERVIEW": "SigNoz | LLM Observability Overview",
|
||||
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration"
|
||||
}
|
||||
@@ -329,10 +329,3 @@ export const LLMObservabilityPage = Loadable(
|
||||
/* webpackChunkName: "LLM Observability Page" */ 'pages/LLMObservability'
|
||||
),
|
||||
);
|
||||
|
||||
export const LLMObservabilityModelPricingPage = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "LLM Observability Model Pricing Page" */ 'pages/LLMObservabilityModelPricing'
|
||||
),
|
||||
);
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
LicensePage,
|
||||
ListAllALertsPage,
|
||||
LLMObservabilityPage,
|
||||
LLMObservabilityModelPricingPage,
|
||||
LiveLogs,
|
||||
Login,
|
||||
Logs,
|
||||
@@ -515,17 +514,17 @@ const routes: AppRoutes[] = [
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.LLM_OBSERVABILITY_BASE,
|
||||
path: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
|
||||
exact: true,
|
||||
component: LLMObservabilityPage,
|
||||
key: 'LLM_OBSERVABILITY_BASE',
|
||||
key: 'LLM_OBSERVABILITY_OVERVIEW',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.LLM_OBSERVABILITY_MODEL_PRICING,
|
||||
path: ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
|
||||
exact: true,
|
||||
component: LLMObservabilityModelPricingPage,
|
||||
key: 'LLM_OBSERVABILITY_MODEL_PRICING',
|
||||
component: LLMObservabilityPage,
|
||||
key: 'LLM_OBSERVABILITY_CONFIGURATION',
|
||||
isPrivate: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Button } from 'antd';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
|
||||
interface CheckboxValueRowProps {
|
||||
value: string;
|
||||
@@ -46,9 +47,11 @@ function CheckboxValueRow({
|
||||
{customRendererForValue ? (
|
||||
customRendererForValue(value)
|
||||
) : (
|
||||
<Typography.Text className="value-string" truncate={1}>
|
||||
{String(value)}
|
||||
</Typography.Text>
|
||||
<TooltipSimple title={String(value)} side="top" align="start">
|
||||
<Typography.Text className="value-string" truncate={1}>
|
||||
{String(value)}
|
||||
</Typography.Text>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
<Button type="text" className="only-btn">
|
||||
{onlyButtonLabel}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,12 @@ export enum LOCALSTORAGE {
|
||||
METRICS_LIST_OPTIONS = 'METRICS_LIST_OPTIONS',
|
||||
SHOW_EXCEPTIONS_QUICK_FILTERS = 'SHOW_EXCEPTIONS_QUICK_FILTERS',
|
||||
QUICK_FILTERS_SETTINGS_ANNOUNCEMENT = 'QUICK_FILTERS_SETTINGS_ANNOUNCEMENT',
|
||||
QUICK_FILTERS_WIDTH_LOGS = 'QUICK_FILTERS_WIDTH_LOGS',
|
||||
QUICK_FILTERS_WIDTH_TRACES = 'QUICK_FILTERS_WIDTH_TRACES',
|
||||
QUICK_FILTERS_WIDTH_METER = 'QUICK_FILTERS_WIDTH_METER',
|
||||
QUICK_FILTERS_WIDTH_API_MONITORING = 'QUICK_FILTERS_WIDTH_API_MONITORING',
|
||||
QUICK_FILTERS_WIDTH_EXCEPTIONS = 'QUICK_FILTERS_WIDTH_EXCEPTIONS',
|
||||
QUICK_FILTERS_WIDTH_INFRA = 'QUICK_FILTERS_WIDTH_INFRA',
|
||||
FUNNEL_STEPS = 'FUNNEL_STEPS',
|
||||
SPAN_DETAILS_PINNED_ATTRIBUTES = 'SPAN_DETAILS_PINNED_ATTRIBUTES',
|
||||
LAST_USED_CUSTOM_TIME_RANGES = 'LAST_USED_CUSTOM_TIME_RANGES',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -163,12 +163,23 @@
|
||||
}
|
||||
|
||||
&.filter-visible {
|
||||
// Width is owned by ResizableBox (inline style); this section is the
|
||||
// ResizableBox root, so it stays position: relative for the drag handle.
|
||||
.api-quick-filter-left-section {
|
||||
width: 260px;
|
||||
.resizable-box__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.quick-filters-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.api-module-right-section {
|
||||
width: calc(100% - 260px);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,21 +4,49 @@ import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
|
||||
|
||||
import DomainList from './Domains/DomainList';
|
||||
|
||||
import './Explorer.styles.scss';
|
||||
|
||||
const QUICK_FILTERS_DEFAULT_WIDTH = 260;
|
||||
const QUICK_FILTERS_MIN_WIDTH = 240;
|
||||
const QUICK_FILTERS_MAX_WIDTH = 500;
|
||||
|
||||
function Explorer(): JSX.Element {
|
||||
useEffect(() => {
|
||||
logEvent('API Monitoring: Landing page visited', {});
|
||||
}, []);
|
||||
|
||||
const {
|
||||
initialWidth: quickFiltersInitialWidth,
|
||||
persistWidth: persistQuickFiltersWidth,
|
||||
} = usePanelWidth({
|
||||
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_API_MONITORING,
|
||||
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
|
||||
minWidth: QUICK_FILTERS_MIN_WIDTH,
|
||||
maxWidth: QUICK_FILTERS_MAX_WIDTH,
|
||||
});
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div className={cx('api-monitoring-page', 'filter-visible')}>
|
||||
<section className="api-quick-filter-left-section">
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
|
||||
initialWidth={quickFiltersInitialWidth}
|
||||
minWidth={QUICK_FILTERS_MIN_WIDTH}
|
||||
maxWidth={QUICK_FILTERS_MAX_WIDTH}
|
||||
onResize={persistQuickFiltersWidth}
|
||||
resetToDefaultOnDoubleClick
|
||||
withHandle
|
||||
className="api-quick-filter-left-section"
|
||||
handleTestId="quick-filters-resize-handle"
|
||||
>
|
||||
<QuickFilters
|
||||
className="qf-api-monitoring"
|
||||
source={QuickFiltersSource.API_MONITORING}
|
||||
@@ -27,7 +55,7 @@ function Explorer(): JSX.Element {
|
||||
showQueryName={false}
|
||||
handleFilterVisibilityChange={(): void => {}}
|
||||
/>
|
||||
</section>
|
||||
</ResizableBox>
|
||||
<DomainList />
|
||||
</div>
|
||||
</Sentry.ErrorBoundary>
|
||||
|
||||
@@ -6,6 +6,7 @@ import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource } from 'components/QuickFilters/types';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import K8sBaseDetails from 'container/InfraMonitoringK8s/Base/K8sBaseDetails';
|
||||
import { K8sBaseList } from 'container/InfraMonitoringK8s/Base/K8sBaseList';
|
||||
import { K8sBaseFilters } from 'container/InfraMonitoringK8s/Base/types';
|
||||
@@ -16,6 +17,8 @@ import {
|
||||
} from 'container/InfraMonitoringK8s/hooks';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
@@ -40,8 +43,21 @@ import { getHostsQuickFiltersConfig } from './utils';
|
||||
import styles from './InfraMonitoringHosts.module.scss';
|
||||
import { ArrowUpToLine, Filter } from '@signozhq/icons';
|
||||
|
||||
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
|
||||
const QUICK_FILTERS_MIN_WIDTH = 240;
|
||||
const QUICK_FILTERS_MAX_WIDTH = 500;
|
||||
|
||||
function Hosts(): JSX.Element {
|
||||
const [showFilters, setShowFilters] = useState(true);
|
||||
const {
|
||||
initialWidth: quickFiltersInitialWidth,
|
||||
persistWidth: persistQuickFiltersWidth,
|
||||
} = usePanelWidth({
|
||||
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_INFRA,
|
||||
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
|
||||
minWidth: QUICK_FILTERS_MIN_WIDTH,
|
||||
maxWidth: QUICK_FILTERS_MAX_WIDTH,
|
||||
});
|
||||
const [, setCurrentPage] = useInfraMonitoringPageListing();
|
||||
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
|
||||
|
||||
@@ -139,7 +155,18 @@ function Hosts(): JSX.Element {
|
||||
<div className={styles.infraMonitoringContainer}>
|
||||
<div className={styles.infraContentRow}>
|
||||
{showFilters && (
|
||||
<div className={styles.quickFiltersContainer}>
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
|
||||
initialWidth={quickFiltersInitialWidth}
|
||||
minWidth={QUICK_FILTERS_MIN_WIDTH}
|
||||
maxWidth={QUICK_FILTERS_MAX_WIDTH}
|
||||
onResize={persistQuickFiltersWidth}
|
||||
resetToDefaultOnDoubleClick
|
||||
withHandle
|
||||
className={styles.quickFiltersContainer}
|
||||
handleTestId="quick-filters-resize-handle"
|
||||
>
|
||||
<div className={styles.quickFiltersContainerHeader}>
|
||||
<Typography.Text>Filters</Typography.Text>
|
||||
<Tooltip title="Collapse Filters">
|
||||
@@ -156,7 +183,7 @@ function Hosts(): JSX.Element {
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleQuickFiltersChange}
|
||||
/>
|
||||
</div>
|
||||
</ResizableBox>
|
||||
)}
|
||||
<div
|
||||
className={`${styles.listContainer}${
|
||||
|
||||
@@ -44,26 +44,20 @@
|
||||
}
|
||||
|
||||
.quickFiltersContainer {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
// Width is owned by ResizableBox (inline style). No overflow here: the panel
|
||||
// must not scroll (QuickFilters scrolls its own list internally) — an
|
||||
// overflow on the root would also clip the edge-mounted resize grip.
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
height: 0.1rem;
|
||||
:global(.resizable-box__content) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
|
||||
:global(.quick-filters-container) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-header) {
|
||||
@@ -142,7 +136,9 @@
|
||||
}
|
||||
|
||||
.listContainerFiltersVisible {
|
||||
max-width: calc(100% - 280px);
|
||||
// Width is driven by flex now that the filters panel is resizable; the list
|
||||
// fills whatever space the (variable-width) panel leaves.
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.quickFiltersToggleContainer {
|
||||
|
||||
@@ -44,26 +44,15 @@
|
||||
}
|
||||
|
||||
.quickFiltersContainer {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
// Width is owned by ResizableBox (inline style). No overflow here: the panel
|
||||
// must not scroll (QuickFilters scrolls its own list internally) — an
|
||||
// overflow on the root would also clip the edge-mounted resize grip.
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
height: 0.1rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
|
||||
:global(.resizable-box__content) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
:global(.quick-filters) {
|
||||
@@ -128,7 +117,9 @@
|
||||
}
|
||||
|
||||
.listContainerFiltersVisible {
|
||||
max-width: calc(100% - 280px);
|
||||
// Width is driven by flex now that the filters panel is resizable; the list
|
||||
// fills whatever space the (variable-width) panel leaves.
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.categorySelectorSection {
|
||||
@@ -219,8 +210,16 @@
|
||||
|
||||
.quickFiltersSection {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
|
||||
:global(.quick-filters-container) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import logEvent from 'api/common/logEvent';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource } from 'components/QuickFilters/types';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import {
|
||||
@@ -22,6 +23,8 @@ import {
|
||||
Workflow,
|
||||
} from '@signozhq/icons';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { FeatureKeys } from '../../constants/features';
|
||||
@@ -56,9 +59,23 @@ import K8sVolumesList from './Volumes/K8sVolumesList';
|
||||
|
||||
import styles from './InfraMonitoringK8s.module.scss';
|
||||
|
||||
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
|
||||
const QUICK_FILTERS_MIN_WIDTH = 240;
|
||||
const QUICK_FILTERS_MAX_WIDTH = 500;
|
||||
|
||||
export default function InfraMonitoringK8s(): JSX.Element {
|
||||
const [showFilters, setShowFilters] = useState(true);
|
||||
|
||||
const {
|
||||
initialWidth: quickFiltersInitialWidth,
|
||||
persistWidth: persistQuickFiltersWidth,
|
||||
} = usePanelWidth({
|
||||
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_INFRA,
|
||||
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
|
||||
minWidth: QUICK_FILTERS_MIN_WIDTH,
|
||||
maxWidth: QUICK_FILTERS_MAX_WIDTH,
|
||||
});
|
||||
|
||||
const [selectedCategory, setSelectedCategory] = useInfraMonitoringCategory();
|
||||
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
|
||||
const [, setGroupBy] = useInfraMonitoringGroupBy();
|
||||
@@ -212,7 +229,18 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
<div className={styles.infraMonitoringContainer}>
|
||||
<div className={styles.infraContentRow}>
|
||||
{showFilters && (
|
||||
<div className={styles.quickFiltersContainer}>
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
|
||||
initialWidth={quickFiltersInitialWidth}
|
||||
minWidth={QUICK_FILTERS_MIN_WIDTH}
|
||||
maxWidth={QUICK_FILTERS_MAX_WIDTH}
|
||||
onResize={persistQuickFiltersWidth}
|
||||
resetToDefaultOnDoubleClick
|
||||
withHandle
|
||||
className={styles.quickFiltersContainer}
|
||||
handleTestId="quick-filters-resize-handle"
|
||||
>
|
||||
<div className={styles.categorySelectorSection}>
|
||||
<div className={styles.sectionHeader} data-type="resource">
|
||||
<Typography.Text className={styles.sectionLabel}>
|
||||
@@ -265,7 +293,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ResizableBox>
|
||||
)}
|
||||
|
||||
<div
|
||||
|
||||
@@ -1,27 +1,7 @@
|
||||
.llmObservability {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12) var(--spacing-16);
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.pageHeaderTitle {
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: var(--spacing-2) 0 0;
|
||||
color: var(--text-vanilla-400);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
height: 100%;
|
||||
margin-top: var(--spacing-2);
|
||||
margin-left: var(--spacing-2);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { Tabs } from '@signozhq/ui/tabs';
|
||||
|
||||
import { useLLMObservabilityTabs } from './hooks/useLLMObservabilityTabs';
|
||||
import styles from './LLMObservability.module.scss';
|
||||
|
||||
// Shell for the LLM Observability page: renders the top-level tab bar
|
||||
// (Overview / Configuration) using the SigNoz design-system Tabs, with
|
||||
// route-driven active state from useLLMObservabilityTabs.
|
||||
function LLMObservability(): JSX.Element {
|
||||
const { items, activeTab, onTabChange } = useLLMObservabilityTabs();
|
||||
|
||||
return (
|
||||
<div className={styles.llmObservability} data-testid="llm-observability-page">
|
||||
<header className={styles.pageHeader}>
|
||||
<div className={styles.pageHeaderTitle}>
|
||||
<h1 className={styles.title}>LLM Observability</h1>
|
||||
<p className={styles.subtitle}>
|
||||
Monitor and analyze your LLM usage, costs, and performance
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
<Tabs
|
||||
items={items}
|
||||
value={activeTab}
|
||||
onChange={onTabChange}
|
||||
testId="llm-observability-tabs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
.overview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12) var(--spacing-16);
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.pageHeaderTitle {
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: var(--spacing-2) 0 0;
|
||||
color: var(--text-vanilla-400);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import styles from './Overview.module.scss';
|
||||
|
||||
// Overview tab content for LLM Observability. Currently the feature's landing
|
||||
// surface; usage/cost/performance widgets land in later PRs.
|
||||
function Overview(): JSX.Element {
|
||||
return (
|
||||
<div className={styles.overview} data-testid="llm-observability-overview">
|
||||
<header className={styles.pageHeader}>
|
||||
<div className={styles.pageHeaderTitle}>
|
||||
<h1 className={styles.title}>LLM Observability</h1>
|
||||
<p className={styles.subtitle}>
|
||||
Monitor and analyze your LLM usage, costs, and performance
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Overview;
|
||||
@@ -2,29 +2,4 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12) var(--spacing-16);
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.pageHeaderTitle {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: var(--spacing-2) 0 0;
|
||||
color: var(--text-vanilla-400);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Tabs } from '@signozhq/ui/tabs';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import ModelCostTabPanel from './ModelCostTabPanel';
|
||||
import styles from './LLMObservabilityModelPricing.module.scss';
|
||||
@@ -10,20 +9,9 @@ function LLMObservabilityModelPricing(): JSX.Element {
|
||||
className={styles.llmObservabilityModelPricing}
|
||||
data-testid="llm-observability-model-pricing-page"
|
||||
>
|
||||
<header className={styles.pageHeader}>
|
||||
<div className={styles.pageHeaderTitle}>
|
||||
<Typography.Text as="h1" size="large" weight="semibold">
|
||||
Configuration
|
||||
</Typography.Text>
|
||||
<Typography.Text color="muted">
|
||||
Model pricing and cost estimation settings
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Tabs
|
||||
// Model costs is the only enabled tab for now, so default to it. When
|
||||
// the unpriced-models tab lands, this can become a URL-backed param.
|
||||
// the unpriced-models tab lands in a later PR.
|
||||
defaultValue="model-costs"
|
||||
items={[
|
||||
{
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useMemo } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Plus, Search, X } from '@signozhq/icons';
|
||||
import { useListLLMPricingRules } from 'api/generated/services/llmpricingrules';
|
||||
import { type ListLLMPricingRulesParams } from 'api/generated/services/sigNoz.schemas';
|
||||
@@ -161,12 +160,6 @@ function ModelCostTabPanel(): JSX.Element {
|
||||
onDelete={deletion.requestDelete}
|
||||
/>
|
||||
|
||||
<footer>
|
||||
<Typography.Text color="muted" size="small">
|
||||
All prices per 1M tokens (USD)
|
||||
</Typography.Text>
|
||||
</footer>
|
||||
|
||||
{drawer.isOpen && (
|
||||
<ModelCostDrawer
|
||||
isOpen={drawer.isOpen}
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
import { LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
TOAST_MODEL_COST_DELETED,
|
||||
TOAST_MODEL_COST_UPDATED,
|
||||
} from 'container/LLMObservability/Settings/ModelPricing/constants';
|
||||
import {
|
||||
LLM_PRICING_ENDPOINT,
|
||||
LLM_PRICING_RULE_ENDPOINT,
|
||||
makeListResponse,
|
||||
mockRules,
|
||||
} from 'container/LLMObservability/Settings/ModelPricing/__tests__/fixtures';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
|
||||
import ModelCostTabPanel from '../ModelCostTabPanel';
|
||||
|
||||
const toastSuccess = jest.fn();
|
||||
const toastError = jest.fn();
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: {
|
||||
success: (...args: unknown[]): void => toastSuccess(...args),
|
||||
error: (...args: unknown[]): void => toastError(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
function setupList(items = mockRules, total = items.length): void {
|
||||
server.use(
|
||||
rest.get(LLM_PRICING_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(makeListResponse(items, total))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function resetUrl(): void {
|
||||
window.history.pushState(null, '', '/');
|
||||
}
|
||||
|
||||
async function openRowMenu(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
ruleId: string,
|
||||
): Promise<void> {
|
||||
const row = screen.getByTestId(`model-cell-name-${ruleId}`).closest('tr');
|
||||
await user.click(within(row as HTMLElement).getByRole('button'));
|
||||
}
|
||||
|
||||
describe('ModelCostTabPanel (integration)', () => {
|
||||
beforeEach(() => {
|
||||
resetUrl();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders pricing rules returned by the list API', async () => {
|
||||
setupList();
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
const openaiCell = await screen.findByTestId('model-cell-name-rule-openai');
|
||||
expect(openaiCell).toHaveTextContent('gpt-4o');
|
||||
expect(
|
||||
screen.getByTestId('model-cell-name-rule-anthropic'),
|
||||
).toHaveTextContent('claude-3-5-sonnet');
|
||||
// Canonical id under the model name + provider column.
|
||||
expect(screen.getByText('openai:gpt-4o')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('OpenAI').length).toBeGreaterThan(0);
|
||||
// Source badges reflect the override flag.
|
||||
expect(screen.getByTestId('source-badge-rule-openai')).toHaveTextContent(
|
||||
'User override',
|
||||
);
|
||||
expect(screen.getByTestId('source-badge-rule-anthropic')).toHaveTextContent(
|
||||
'Auto',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the empty state when there are no rules', async () => {
|
||||
setupList([], 0);
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
const empty = await screen.findByTestId('model-costs-empty');
|
||||
expect(empty).toHaveTextContent('No model costs yet.');
|
||||
});
|
||||
|
||||
it('shows an error message when the list request fails', async () => {
|
||||
server.use(
|
||||
rest.get(LLM_PRICING_ENDPOINT, (_req, res, ctx) => res(ctx.status(500))),
|
||||
);
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(alert).toHaveTextContent(
|
||||
'Failed to load pricing rules. Please try again.',
|
||||
);
|
||||
});
|
||||
|
||||
it('sends the debounced search term as the q param', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
let lastParams: URLSearchParams | null = null;
|
||||
server.use(
|
||||
rest.get(LLM_PRICING_ENDPOINT, (req, res, ctx) => {
|
||||
lastParams = req.url.searchParams;
|
||||
return res(ctx.status(200), ctx.json(makeListResponse(mockRules)));
|
||||
}),
|
||||
);
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
await screen.findByTestId('model-cell-name-rule-openai');
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('Search by model or provider'),
|
||||
'claude',
|
||||
);
|
||||
|
||||
await waitFor(() => expect(lastParams?.get('q')).toBe('claude'));
|
||||
});
|
||||
|
||||
it('clears the search via the clear button', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupList();
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
const input = screen.getByPlaceholderText(
|
||||
'Search by model or provider',
|
||||
) as HTMLInputElement;
|
||||
await user.type(input, 'gpt');
|
||||
expect(input.value).toBe('gpt');
|
||||
|
||||
await user.click(screen.getByTestId('model-cost-search-clear'));
|
||||
await waitFor(() => expect(input.value).toBe(''));
|
||||
});
|
||||
|
||||
it('sends isOverride=true when the source filter is set to User override', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
let lastParams: URLSearchParams | null = null;
|
||||
server.use(
|
||||
rest.get(LLM_PRICING_ENDPOINT, (req, res, ctx) => {
|
||||
lastParams = req.url.searchParams;
|
||||
return res(ctx.status(200), ctx.json(makeListResponse(mockRules)));
|
||||
}),
|
||||
);
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
await screen.findByTestId('model-cell-name-rule-openai');
|
||||
await user.click(screen.getByTestId('source-filter'));
|
||||
// Scope to the listbox option — "User override" also appears as a row badge.
|
||||
await user.click(
|
||||
await screen.findByRole('option', { name: 'User override' }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(lastParams?.get('isOverride')).toBe('true'));
|
||||
});
|
||||
|
||||
it('opens the add drawer for a manager (ADMIN)', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupList();
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
await screen.findByTestId('model-cell-name-rule-openai');
|
||||
await user.click(screen.getByTestId('add-model-cost-btn'));
|
||||
|
||||
const modelInput = await screen.findByTestId('drawer-model-id-input');
|
||||
expect(modelInput).toBeInTheDocument();
|
||||
expect(screen.getByTestId('drawer-save-btn')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the add button and row actions for a viewer', async () => {
|
||||
setupList();
|
||||
render(<ModelCostTabPanel />, undefined, { role: 'VIEWER' });
|
||||
|
||||
const row = (
|
||||
await screen.findByTestId('model-cell-name-rule-openai')
|
||||
).closest('tr') as HTMLElement;
|
||||
expect(screen.queryByTestId('add-model-cost-btn')).not.toBeInTheDocument();
|
||||
// View-only rows render no action menu (no buttons in the row).
|
||||
expect(within(row).queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the edit drawer prefilled from the row action menu', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupList();
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
await screen.findByTestId('model-cell-name-rule-openai');
|
||||
await openRowMenu(user, 'rule-openai');
|
||||
await user.click(await screen.findByText('Edit'));
|
||||
|
||||
const drawerTitle = await screen.findByText('Edit model cost');
|
||||
expect(drawerTitle).toBeInTheDocument();
|
||||
const modelInput = screen.getByTestId(
|
||||
'drawer-model-id-input',
|
||||
) as HTMLInputElement;
|
||||
expect(modelInput.value).toBe('gpt-4o');
|
||||
expect(modelInput).toBeDisabled();
|
||||
});
|
||||
|
||||
it('deletes a rule through the confirm dialog', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
let deletedId: string | null = null;
|
||||
setupList();
|
||||
server.use(
|
||||
rest.delete(LLM_PRICING_RULE_ENDPOINT, (req, res, ctx) => {
|
||||
deletedId = req.params.id as string;
|
||||
return res(ctx.status(200), ctx.json({ status: 'success' }));
|
||||
}),
|
||||
);
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
await screen.findByTestId('model-cell-name-rule-openai');
|
||||
await openRowMenu(user, 'rule-openai');
|
||||
await user.click(await screen.findByText('Delete'));
|
||||
|
||||
await user.click(await screen.findByTestId('drawer-delete-confirm-btn'));
|
||||
|
||||
await waitFor(() => expect(deletedId).toBe('rule-openai'));
|
||||
await waitFor(() =>
|
||||
expect(toastSuccess).toHaveBeenCalledWith(TOAST_MODEL_COST_DELETED),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders cache buckets for rules that have cache pricing', async () => {
|
||||
setupList();
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
const anthropicRow = (
|
||||
await screen.findByTestId('model-cell-name-rule-anthropic')
|
||||
).closest('tr') as HTMLElement;
|
||||
expect(within(anthropicRow).getByText(/Cache Read/i)).toBeInTheDocument();
|
||||
expect(within(anthropicRow).getByText(/Cache Write/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('formats per-million prices in the row', async () => {
|
||||
setupList();
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
const openaiRow = (
|
||||
await screen.findByTestId('model-cell-name-rule-openai')
|
||||
).closest('tr') as HTMLElement;
|
||||
// mockRules gpt-4o has input cost 3 → rendered as $3.00.
|
||||
expect(within(openaiRow).getByText('$3.00')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends a normalized create payload when adding a rule', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
let body: Record<string, unknown> | null = null;
|
||||
setupList();
|
||||
server.use(
|
||||
rest.put(LLM_PRICING_ENDPOINT, async (req, res, ctx) => {
|
||||
body = await req.json();
|
||||
return res(ctx.status(200), ctx.json({ status: 'success' }));
|
||||
}),
|
||||
);
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
await screen.findByTestId('model-cell-name-rule-openai');
|
||||
await user.click(screen.getByTestId('add-model-cost-btn'));
|
||||
|
||||
// Leading/trailing whitespace should be trimmed off the model id.
|
||||
await user.type(
|
||||
await screen.findByTestId('drawer-model-id-input'),
|
||||
' gpt-4o-mini ',
|
||||
);
|
||||
await user.type(screen.getByTestId('drawer-input-cost'), '3');
|
||||
await user.type(screen.getByTestId('drawer-output-cost'), '9');
|
||||
await user.click(screen.getByTestId('drawer-save-btn'));
|
||||
|
||||
await waitFor(() => expect(body).not.toBeNull());
|
||||
// The create call submits a bulk `rules` array of normalized payloads.
|
||||
const [payload] = (
|
||||
body as unknown as {
|
||||
rules: Record<string, unknown>[];
|
||||
}
|
||||
).rules;
|
||||
expect(payload).toMatchObject({
|
||||
modelName: 'gpt-4o-mini',
|
||||
provider: 'OpenAI',
|
||||
isOverride: true,
|
||||
enabled: true,
|
||||
unit: UnitDTO.per_million_tokens,
|
||||
pricing: { input: 3, output: 9 },
|
||||
});
|
||||
});
|
||||
|
||||
it('sends an updated payload when editing a rule', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
let body: Record<string, unknown> | null = null;
|
||||
setupList();
|
||||
server.use(
|
||||
rest.put(LLM_PRICING_ENDPOINT, async (req, res, ctx) => {
|
||||
body = await req.json();
|
||||
return res(ctx.status(200), ctx.json({ status: 'success' }));
|
||||
}),
|
||||
);
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
await screen.findByTestId('model-cell-name-rule-openai');
|
||||
await openRowMenu(user, 'rule-openai');
|
||||
await user.click(await screen.findByText('Edit'));
|
||||
|
||||
// Model id + provider are locked in edit mode; change the prefilled input cost.
|
||||
const inputCost = await screen.findByTestId('drawer-input-cost');
|
||||
await user.clear(inputCost);
|
||||
await user.type(inputCost, '5');
|
||||
await user.click(screen.getByTestId('drawer-save-btn'));
|
||||
|
||||
await waitFor(() => expect(body).not.toBeNull());
|
||||
const [payload] = (
|
||||
body as unknown as {
|
||||
rules: Record<string, unknown>[];
|
||||
}
|
||||
).rules;
|
||||
// Edit carries the rule id; disabled model/provider are still submitted and
|
||||
// the edited price flows through, while output keeps its prefilled value.
|
||||
expect(payload).toMatchObject({
|
||||
id: 'rule-openai',
|
||||
modelName: 'gpt-4o',
|
||||
provider: 'OpenAI',
|
||||
isOverride: true,
|
||||
enabled: true,
|
||||
unit: UnitDTO.per_million_tokens,
|
||||
pricing: { input: 5, output: 9 },
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(toastSuccess).toHaveBeenCalledWith(TOAST_MODEL_COST_UPDATED),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
import { makePricingRule } from 'container/LLMObservability/Settings/ModelPricing/__tests__/fixtures';
|
||||
import { EMPTY_DRAFT } from 'container/LLMObservability/Settings/ModelPricing/constants';
|
||||
import { draftFromRule } from 'container/LLMObservability/Settings/ModelPricing/utils';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import ModelCostDrawer from '../ModelCostDrawer';
|
||||
|
||||
const editDraft = draftFromRule(
|
||||
makePricingRule({
|
||||
id: 'rule-openai',
|
||||
modelName: 'gpt-4o',
|
||||
provider: 'OpenAI',
|
||||
}),
|
||||
);
|
||||
|
||||
describe('ModelCostDrawer (integration)', () => {
|
||||
it('renders the add title and a save button for a manager', () => {
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="add"
|
||||
initialDraft={EMPTY_DRAFT}
|
||||
onClose={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Add model cost')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('drawer-save-btn')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables save until the form is dirty', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="add"
|
||||
initialDraft={EMPTY_DRAFT}
|
||||
onClose={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('drawer-save-btn')).toBeDisabled();
|
||||
|
||||
await user.type(screen.getByTestId('drawer-model-id-input'), 'openai:gpt-4o');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('drawer-save-btn')).toBeEnabled(),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the model id required error and does not call onSave when the name is empty', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onSave = jest.fn();
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="add"
|
||||
initialDraft={EMPTY_DRAFT}
|
||||
onClose={jest.fn()}
|
||||
onSave={onSave}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
// Make the form dirty without touching the model id: add a pattern, which
|
||||
// mutates the `patterns` form field while leaving the name empty.
|
||||
await user.type(screen.getByTestId('drawer-pattern-input'), 'gpt');
|
||||
await user.click(screen.getByTestId('drawer-pattern-add-btn'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('drawer-save-btn')).toBeEnabled(),
|
||||
);
|
||||
await user.click(screen.getByTestId('drawer-save-btn'));
|
||||
|
||||
const error = await screen.findByText('Billing model ID is required.');
|
||||
expect(error).toBeInTheDocument();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onSave once on the happy path with valid model id and pricing', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onSave = jest.fn();
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="add"
|
||||
initialDraft={EMPTY_DRAFT}
|
||||
onClose={jest.fn()}
|
||||
onSave={onSave}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.type(screen.getByTestId('drawer-model-id-input'), 'openai:gpt-4o');
|
||||
await user.type(screen.getByTestId('drawer-input-cost'), '3');
|
||||
await user.type(screen.getByTestId('drawer-output-cost'), '9');
|
||||
|
||||
await user.click(screen.getByTestId('drawer-save-btn'));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('renders the edit title with disabled, prefilled model id and disabled provider', () => {
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="edit"
|
||||
initialDraft={editDraft}
|
||||
onClose={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Edit model cost')).toBeInTheDocument();
|
||||
const modelInput = screen.getByTestId(
|
||||
'drawer-model-id-input',
|
||||
) as HTMLInputElement;
|
||||
expect(modelInput.value).toBe('gpt-4o');
|
||||
expect(modelInput).toBeDisabled();
|
||||
expect(screen.getByTestId('drawer-provider-select')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('renders a read-only view with a Close button and no save for a viewer', () => {
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="edit"
|
||||
initialDraft={editDraft}
|
||||
onClose={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('View model cost')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('drawer-save-btn')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('drawer-cancel-btn')).toHaveTextContent('Close');
|
||||
});
|
||||
|
||||
it('renders the save error text', () => {
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="add"
|
||||
initialDraft={EMPTY_DRAFT}
|
||||
onClose={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
isSaving={false}
|
||||
saveError="boom"
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('boom')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds and removes a model pattern from the editor', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="add"
|
||||
initialDraft={EMPTY_DRAFT}
|
||||
onClose={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.type(screen.getByTestId('drawer-pattern-input'), 'gpt-5');
|
||||
await user.click(screen.getByTestId('drawer-pattern-add-btn'));
|
||||
// The added pattern renders as a removable chip.
|
||||
const removeChip = screen.getByRole('button', {
|
||||
name: 'Remove pattern gpt-5',
|
||||
});
|
||||
expect(removeChip).toBeInTheDocument();
|
||||
|
||||
await user.click(removeChip);
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Remove pattern gpt-5' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds a cache pricing bucket via the picker and removes it', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="add"
|
||||
initialDraft={EMPTY_DRAFT}
|
||||
onClose={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('drawer-add-bucket-btn'));
|
||||
await user.click(screen.getByTestId('drawer-add-bucket-cache-read'));
|
||||
|
||||
// Adding the bucket reveals its cost input and the shared cache-mode select.
|
||||
expect(screen.getByTestId('drawer-cache-read-cost')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('drawer-cache-mode')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('drawer-remove-cache-read'));
|
||||
expect(
|
||||
screen.queryByTestId('drawer-cache-read-cost'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('blocks save with a pricing error when an override rule has no input cost', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onSave = jest.fn();
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="add"
|
||||
initialDraft={EMPTY_DRAFT}
|
||||
onClose={jest.fn()}
|
||||
onSave={onSave}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
// EMPTY_DRAFT defaults to an override with empty pricing. Fill only the
|
||||
// model id + output cost so the form is dirty but the input cost is missing.
|
||||
await user.type(screen.getByTestId('drawer-model-id-input'), 'openai:gpt-4o');
|
||||
await user.type(screen.getByTestId('drawer-output-cost'), '9');
|
||||
await user.click(screen.getByTestId('drawer-save-btn'));
|
||||
|
||||
await expect(
|
||||
screen.findByText('Input cost must be greater than 0.'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires confirmation to reset an override rule back to auto', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="edit"
|
||||
initialDraft={editDraft}
|
||||
onClose={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
// Pricing is editable while the rule is an override.
|
||||
expect(screen.getByTestId('drawer-input-cost')).toBeEnabled();
|
||||
|
||||
// Picking "auto" surfaces a confirm step instead of applying immediately.
|
||||
await user.click(screen.getByTestId('drawer-source-auto'));
|
||||
expect(screen.getByTestId('drawer-reset-confirm-btn')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('drawer-input-cost')).toBeEnabled();
|
||||
|
||||
// Keep backs out of the reset.
|
||||
await user.click(screen.getByTestId('drawer-reset-keep-btn'));
|
||||
expect(
|
||||
screen.queryByTestId('drawer-reset-confirm-btn'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Confirming the reset flips the rule to auto and locks pricing.
|
||||
await user.click(screen.getByTestId('drawer-source-auto'));
|
||||
await user.click(screen.getByTestId('drawer-reset-confirm-btn'));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('drawer-input-cost')).toBeDisabled(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,11 @@ import {
|
||||
useCreateOrUpdateLLMPricingRules,
|
||||
} from 'api/generated/services/llmpricingrules';
|
||||
|
||||
import { EMPTY_DRAFT } from '../../../../constants';
|
||||
import {
|
||||
EMPTY_DRAFT,
|
||||
TOAST_MODEL_COST_ADDED,
|
||||
TOAST_MODEL_COST_UPDATED,
|
||||
} from '../../../../constants';
|
||||
import type { DrawerDraft, DrawerMode, PricingRule } from '../../../../types';
|
||||
import { buildRulePayload, draftFromRule } from '../../../../utils';
|
||||
|
||||
@@ -76,7 +80,9 @@ export function useModelCostDrawer(): UseModelCostDrawerResult {
|
||||
await invalidateList();
|
||||
setIsOpen(false);
|
||||
setSelectedRuleId(null);
|
||||
toast.success(mode === 'edit' ? 'Model cost updated' : 'Model cost added');
|
||||
toast.success(
|
||||
mode === 'edit' ? TOAST_MODEL_COST_UPDATED : TOAST_MODEL_COST_ADDED,
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Save failed';
|
||||
setSaveError(message);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.modelCostsTable {
|
||||
margin-top: var(--spacing-8);
|
||||
--tanstack-table-row-height: 48px;
|
||||
height: calc(100vh - 250px);
|
||||
height: calc(100vh - 170px);
|
||||
overflow-y: auto;
|
||||
|
||||
:global(table) tbody tr {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useDeleteLLMPricingRule,
|
||||
} from 'api/generated/services/llmpricingrules';
|
||||
|
||||
import { TOAST_MODEL_COST_DELETED } from '../../constants';
|
||||
import type { PricingRule } from '../../types';
|
||||
|
||||
// The minimal slice of a rule the delete-confirm flow needs: the id to delete
|
||||
@@ -49,7 +50,7 @@ export function useModelCostDelete(): UseModelCostDeleteResult {
|
||||
queryKey: getListLLMPricingRulesQueryKey(),
|
||||
});
|
||||
setPendingDelete(null);
|
||||
toast.success('Model cost deleted');
|
||||
toast.success(TOAST_MODEL_COST_DELETED);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Delete failed';
|
||||
toast.error(message);
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
|
||||
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
|
||||
type ListLLMPricingRules200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { PricingRule } from '../types';
|
||||
|
||||
// Endpoint glob used by MSW handlers. The generated client hits a relative
|
||||
// `/api/v1/llm_pricing_rules`, so the `*` prefix matches regardless of base URL.
|
||||
export const LLM_PRICING_ENDPOINT = '*/api/v1/llm_pricing_rules';
|
||||
export const LLM_PRICING_RULE_ENDPOINT = '*/api/v1/llm_pricing_rules/:id';
|
||||
|
||||
// Builds a valid pricing rule, with overrides merged shallowly. Pricing is
|
||||
// replaced wholesale when provided so callers can shape cache buckets freely.
|
||||
export function makePricingRule(
|
||||
overrides: Partial<PricingRule> = {},
|
||||
): PricingRule {
|
||||
const { pricing, ...rest } = overrides;
|
||||
return {
|
||||
id: 'rule-1',
|
||||
enabled: true,
|
||||
isOverride: true,
|
||||
modelName: 'gpt-4o',
|
||||
modelPattern: ['gpt-4o'],
|
||||
orgId: 'org-1',
|
||||
provider: 'OpenAI',
|
||||
sourceId: 'source-1',
|
||||
unit: UnitDTO.per_million_tokens,
|
||||
createdAt: '2023-10-01T00:00:00.000Z',
|
||||
updatedAt: '2023-10-10T00:00:00.000Z',
|
||||
syncedAt: '2023-10-10T00:00:00.000Z',
|
||||
pricing: {
|
||||
input: 3,
|
||||
output: 9,
|
||||
...pricing,
|
||||
},
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
export const mockRules: PricingRule[] = [
|
||||
makePricingRule({
|
||||
id: 'rule-openai',
|
||||
modelName: 'gpt-4o',
|
||||
provider: 'OpenAI',
|
||||
isOverride: true,
|
||||
pricing: { input: 3, output: 9 },
|
||||
}),
|
||||
makePricingRule({
|
||||
id: 'rule-anthropic',
|
||||
modelName: 'claude-3-5-sonnet',
|
||||
provider: 'Anthropic',
|
||||
isOverride: false,
|
||||
pricing: {
|
||||
input: 2,
|
||||
output: 30,
|
||||
cache: { mode: CacheModeDTO.additive, read: 3, write: 6 },
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
// Wraps items in the list response envelope the list query reads
|
||||
// (`data.data.items` / `data.data.total`).
|
||||
export function makeListResponse(
|
||||
items: PricingRule[],
|
||||
total = items.length,
|
||||
offset = 0,
|
||||
limit = 20,
|
||||
): ListLLMPricingRules200 {
|
||||
return {
|
||||
status: 'success',
|
||||
data: {
|
||||
items,
|
||||
total,
|
||||
offset,
|
||||
limit,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,10 @@ import type { CacheBucketDef, DrawerDraft } from './types';
|
||||
|
||||
export const PAGE_SIZE = 20;
|
||||
|
||||
export const TOAST_MODEL_COST_ADDED = 'Model cost added';
|
||||
export const TOAST_MODEL_COST_UPDATED = 'Model cost updated';
|
||||
export const TOAST_MODEL_COST_DELETED = 'Model cost deleted';
|
||||
|
||||
export const PAGE_KEY = 'page';
|
||||
export const LIMIT_KEY = 'limit';
|
||||
export const SEARCH_KEY = 'search';
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { safeNavigateMock } from '__tests__/safeNavigateMock';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
LLM_PRICING_ENDPOINT,
|
||||
makeListResponse,
|
||||
mockRules,
|
||||
} from 'container/LLMObservability/Settings/ModelPricing/__tests__/fixtures';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import LLMObservability from '../LLMObservability';
|
||||
|
||||
function setupList(items = mockRules): void {
|
||||
server.use(
|
||||
rest.get(LLM_PRICING_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(makeListResponse(items))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe('LLMObservability (integration)', () => {
|
||||
beforeEach(() => {
|
||||
window.history.pushState(null, '', '/');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders the overview panel and the tab bar on the overview route', () => {
|
||||
render(<LLMObservability />, undefined, {
|
||||
initialRoute: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('llm-observability-tabs')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('llm-observability-overview')).toBeInTheDocument();
|
||||
expect(screen.getByText('LLM Observability')).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('tab', { name: 'Model pricing' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates to the configuration route when the Model pricing tab is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<LLMObservability />, undefined, {
|
||||
initialRoute: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Model pricing' }));
|
||||
|
||||
expect(safeNavigateMock).toHaveBeenCalledWith(
|
||||
ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the model-pricing page on the configuration route', async () => {
|
||||
setupList();
|
||||
render(<LLMObservability />, undefined, {
|
||||
initialRoute: ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByTestId('llm-observability-model-pricing-page'),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { type TabItemProps } from '@signozhq/ui/tabs';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
|
||||
import Overview from '../Overview/Overview';
|
||||
import LLMObservabilityModelPricing from '../Settings/ModelPricing/LLMObservabilityModelPricing';
|
||||
|
||||
const OVERVIEW_KEY = ROUTES.LLM_OBSERVABILITY_OVERVIEW;
|
||||
const CONFIGURATION_KEY = ROUTES.LLM_OBSERVABILITY_CONFIGURATION;
|
||||
|
||||
interface UseLLMObservabilityTabsResult {
|
||||
items: TabItemProps[];
|
||||
activeTab: string;
|
||||
onTabChange: (key: string) => void;
|
||||
}
|
||||
|
||||
// Drives the top-level LLM Observability tabs. Route-driven: the active tab is
|
||||
// derived from the pathname (each tab owns a URL) and changing tabs navigates,
|
||||
// so tabs stay shareable/back-button friendly while rendering with the SigNoz
|
||||
// design-system Tabs.
|
||||
export function useLLMObservabilityTabs(): UseLLMObservabilityTabsResult {
|
||||
const { pathname } = useLocation();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const activeTab = pathname.startsWith(CONFIGURATION_KEY)
|
||||
? CONFIGURATION_KEY
|
||||
: OVERVIEW_KEY;
|
||||
|
||||
const onTabChange = useCallback(
|
||||
(key: string): void => {
|
||||
safeNavigate(key);
|
||||
},
|
||||
[safeNavigate],
|
||||
);
|
||||
|
||||
const items: TabItemProps[] = [
|
||||
{
|
||||
key: OVERVIEW_KEY,
|
||||
label: 'Overview',
|
||||
children: <Overview />,
|
||||
},
|
||||
{
|
||||
key: CONFIGURATION_KEY,
|
||||
label: 'Model pricing',
|
||||
children: <LLMObservabilityModelPricing />,
|
||||
},
|
||||
];
|
||||
|
||||
return { items, activeTab, onTabChange };
|
||||
}
|
||||
@@ -3,12 +3,23 @@
|
||||
flex-direction: row;
|
||||
|
||||
.meter-explorer-quick-filters-section {
|
||||
width: 280px;
|
||||
// Width is owned by ResizableBox (inline style).
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.resizable-box__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.quick-filters-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.meter-explorer-content-section {
|
||||
@@ -78,7 +89,9 @@
|
||||
|
||||
&.quick-filters-open {
|
||||
.meter-explorer-content-section {
|
||||
width: calc(100% - 280px);
|
||||
flex: 1;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import cx from 'classnames';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
|
||||
@@ -18,6 +19,8 @@ import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { Filter } from '@signozhq/icons';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -30,6 +33,10 @@ import { splitQueryIntoOneChartPerQuery } from './utils';
|
||||
|
||||
import './Explorer.styles.scss';
|
||||
|
||||
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
|
||||
const QUICK_FILTERS_MIN_WIDTH = 240;
|
||||
const QUICK_FILTERS_MAX_WIDTH = 500;
|
||||
|
||||
function Explorer(): JSX.Element {
|
||||
const {
|
||||
handleRunQuery,
|
||||
@@ -55,6 +62,16 @@ function Explorer(): JSX.Element {
|
||||
|
||||
const [showQuickFilters, setShowQuickFilters] = useState(true);
|
||||
|
||||
const {
|
||||
initialWidth: quickFiltersInitialWidth,
|
||||
persistWidth: persistQuickFiltersWidth,
|
||||
} = usePanelWidth({
|
||||
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_METER,
|
||||
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
|
||||
minWidth: QUICK_FILTERS_MIN_WIDTH,
|
||||
maxWidth: QUICK_FILTERS_MAX_WIDTH,
|
||||
});
|
||||
|
||||
const defaultQuery = useMemo(
|
||||
() =>
|
||||
updateAllQueriesOperators(
|
||||
@@ -127,10 +144,19 @@ function Explorer(): JSX.Element {
|
||||
'quick-filters-open': showQuickFilters,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
|
||||
initialWidth={quickFiltersInitialWidth}
|
||||
minWidth={QUICK_FILTERS_MIN_WIDTH}
|
||||
maxWidth={QUICK_FILTERS_MAX_WIDTH}
|
||||
onResize={persistQuickFiltersWidth}
|
||||
resetToDefaultOnDoubleClick
|
||||
withHandle
|
||||
className={cx('meter-explorer-quick-filters-section', {
|
||||
hidden: !showQuickFilters,
|
||||
})}
|
||||
handleTestId="quick-filters-resize-handle"
|
||||
>
|
||||
<QuickFilters
|
||||
className="qf-meter-explorer"
|
||||
@@ -142,7 +168,7 @@ function Explorer(): JSX.Element {
|
||||
setShowQuickFilters(!showQuickFilters);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ResizableBox>
|
||||
|
||||
<div className="meter-explorer-content-section">
|
||||
<div className="meter-explorer-explore-content">
|
||||
|
||||
@@ -48,6 +48,8 @@ describe('CreateRolePage - AuthZ', () => {
|
||||
isFetching: true,
|
||||
error: null,
|
||||
permissions: null,
|
||||
allowed: false,
|
||||
deniedPermissions: [],
|
||||
refetchPermissions: jest.fn(),
|
||||
});
|
||||
|
||||
|
||||
@@ -77,6 +77,8 @@ describe('EditRolePage - AuthZ', () => {
|
||||
isFetching: true,
|
||||
error: null,
|
||||
permissions: null,
|
||||
allowed: false,
|
||||
deniedPermissions: [],
|
||||
refetchPermissions: jest.fn(),
|
||||
});
|
||||
|
||||
|
||||
@@ -409,6 +409,8 @@ describe('ViewRolePage - AuthZ', () => {
|
||||
isFetching: true,
|
||||
error: null,
|
||||
permissions: null,
|
||||
allowed: false,
|
||||
deniedPermissions: [],
|
||||
refetchPermissions: jest.fn(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
108
frontend/src/lib/authz/devtools/AuthZDevModal/AuthZDevModal.tsx
Normal file
108
frontend/src/lib/authz/devtools/AuthZDevModal/AuthZDevModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
114
frontend/src/lib/authz/devtools/AuthZDevModal/PermissionRow.tsx
Normal file
114
frontend/src/lib/authz/devtools/AuthZDevModal/PermissionRow.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
46
frontend/src/lib/authz/devtools/types.ts
Normal file
46
frontend/src/lib/authz/devtools/types.ts
Normal 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,
|
||||
];
|
||||
137
frontend/src/lib/authz/devtools/useAuthZDevStore.ts
Normal file
137
frontend/src/lib/authz/devtools/useAuthZDevStore.ts
Normal 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();
|
||||
28
frontend/src/lib/authz/devtools/useAuthZQueryInvalidation.ts
Normal file
28
frontend/src/lib/authz/devtools/useAuthZQueryInvalidation.ts
Normal 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]);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
3
frontend/src/lib/env.ts
Normal 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;
|
||||
@@ -20,12 +20,23 @@
|
||||
}
|
||||
|
||||
&.filter-visible {
|
||||
// Width is owned by ResizableBox (inline style); this section is the
|
||||
// ResizableBox root, so it stays position: relative for the drag handle.
|
||||
.all-errors-quick-filter-section {
|
||||
width: 260px;
|
||||
.resizable-box__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.quick-filters-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.all-errors-right-section {
|
||||
width: calc(100% - 260px);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,12 +18,18 @@ import Toolbar from 'container/Toolbar/Toolbar';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import history from 'lib/history';
|
||||
import { isNull } from 'lodash-es';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
|
||||
|
||||
import { routes } from './config';
|
||||
import { useAllErrorsQueryState } from './QueryStateContext';
|
||||
|
||||
import './AllErrors.styles.scss';
|
||||
|
||||
const QUICK_FILTERS_DEFAULT_WIDTH = 260;
|
||||
const QUICK_FILTERS_MIN_WIDTH = 240;
|
||||
const QUICK_FILTERS_MAX_WIDTH = 500;
|
||||
|
||||
function AllErrors(): JSX.Element {
|
||||
const { pathname } = useLocation();
|
||||
const { handleRunQuery } = useQueryBuilder();
|
||||
@@ -55,17 +61,38 @@ function AllErrors(): JSX.Element {
|
||||
setShowFilters((prev) => !prev);
|
||||
};
|
||||
|
||||
const {
|
||||
initialWidth: quickFiltersInitialWidth,
|
||||
persistWidth: persistQuickFiltersWidth,
|
||||
} = usePanelWidth({
|
||||
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_EXCEPTIONS,
|
||||
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
|
||||
minWidth: QUICK_FILTERS_MIN_WIDTH,
|
||||
maxWidth: QUICK_FILTERS_MAX_WIDTH,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cx('all-errors-page', showFilters ? 'filter-visible' : '')}>
|
||||
{showFilters && (
|
||||
<section className={cx('all-errors-quick-filter-section')}>
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
|
||||
initialWidth={quickFiltersInitialWidth}
|
||||
minWidth={QUICK_FILTERS_MIN_WIDTH}
|
||||
maxWidth={QUICK_FILTERS_MAX_WIDTH}
|
||||
onResize={persistQuickFiltersWidth}
|
||||
resetToDefaultOnDoubleClick
|
||||
withHandle
|
||||
className="all-errors-quick-filter-section"
|
||||
handleTestId="quick-filters-resize-handle"
|
||||
>
|
||||
<QuickFilters
|
||||
className="qf-exceptions"
|
||||
source={QuickFiltersSource.EXCEPTIONS}
|
||||
signal={SignalType.EXCEPTIONS}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
/>
|
||||
</section>
|
||||
</ResizableBox>
|
||||
)}
|
||||
<section
|
||||
className={cx(
|
||||
|
||||
@@ -19,6 +19,7 @@ import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import styles from './DashboardInfo.module.scss';
|
||||
import { useVisibleTagCount } from './useVisibleTagCount';
|
||||
import { DASHBOARD_NAME_MAX_LENGTH } from '../../constants';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
|
||||
interface DashboardInfoProps {
|
||||
@@ -99,7 +100,7 @@ function DashboardInfo({
|
||||
autoFocus
|
||||
value={draft}
|
||||
testId="dashboard-title-input"
|
||||
maxLength={120}
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
className={styles.dashboardTitleInput}
|
||||
onChange={(e): void => onDraftChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
|
||||
@@ -63,8 +63,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;
|
||||
@@ -183,6 +188,7 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
open={isPickerOpen}
|
||||
onClose={closePicker}
|
||||
onSelect={createPanel}
|
||||
defaultLayoutIndex={targetLayoutIndex}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Input as AntdInput } from 'antd';
|
||||
import TagKeyValueInput from 'components/TagKeyValueInput/TagKeyValueInput';
|
||||
|
||||
import { Base64Icons } from '../utils';
|
||||
import { DASHBOARD_NAME_MAX_LENGTH } from '../../../constants';
|
||||
import settingsStyles from '../../DashboardSettings.module.scss';
|
||||
import styles from './DashboardInfoForm.module.scss';
|
||||
|
||||
@@ -71,6 +72,7 @@ function DashboardInfoForm({
|
||||
testId="dashboard-name"
|
||||
className={styles.dashboardNameInput}
|
||||
value={title}
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
onChange={(e): void => onTitleChange(e.target.value)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -46,8 +46,12 @@ export interface UseVariableForm {
|
||||
handleSave: () => void;
|
||||
}
|
||||
|
||||
const readDefaultValue = (model: VariableFormModel): string =>
|
||||
((model.defaultValue as { value?: string })?.value ?? '') as string;
|
||||
// `defaultValue` is a string | string[] on the wire; the editor uses a single
|
||||
// string, so take the first when it's an array.
|
||||
const readDefaultValue = (model: VariableFormModel): string => {
|
||||
const dv = model.defaultValue;
|
||||
return Array.isArray(dv) ? (dv[0] ?? '') : (dv ?? '');
|
||||
};
|
||||
|
||||
/** Form state, derivations and handlers for the variable editor. */
|
||||
export function useVariableForm({
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import { Input as AntdInput } from 'antd';
|
||||
|
||||
import styles from './VariableInfoForm.module.scss';
|
||||
import { DASHBOARD_NAME_MAX_LENGTH } from '../../../../constants';
|
||||
import variableFormStyles from '../../VariableForm/VariableForm.module.scss';
|
||||
|
||||
interface VariableInfoFormProps {
|
||||
@@ -30,6 +31,7 @@ function VariableInfoForm({
|
||||
testId="variable-name"
|
||||
className={styles.variableNameInput}
|
||||
value={title}
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
onChange={(e): void => onTitleChange(e.target.value)}
|
||||
placeholder="Unique name of the variable"
|
||||
/>
|
||||
|
||||
@@ -14,6 +14,7 @@ import ConfigActions from './ConfigActions/ConfigActions';
|
||||
import SectionSlot from './SectionSlot/SectionSlot';
|
||||
|
||||
import styles from './ConfigPane.module.scss';
|
||||
import { DASHBOARD_NAME_MAX_LENGTH } from '../../constants';
|
||||
import { PanelKind } from '../../Panels/types/panelKind';
|
||||
|
||||
interface ConfigPaneProps {
|
||||
@@ -86,6 +87,7 @@ function ConfigPane({
|
||||
data-testid="panel-editor-v2-title"
|
||||
value={spec.display.name}
|
||||
placeholder="Panel title"
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
onChange={(e): void => setDisplayField('name', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -11,14 +11,18 @@ import styles from './Header.module.scss';
|
||||
interface HeaderProps {
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
showSwitchToView?: boolean;
|
||||
onSave: () => void;
|
||||
onSwitchToView?: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function Header({
|
||||
isDirty,
|
||||
isSaving,
|
||||
showSwitchToView = false,
|
||||
onSave,
|
||||
onSwitchToView,
|
||||
onClose,
|
||||
}: HeaderProps): JSX.Element {
|
||||
const discard = useConfirmableAction(
|
||||
@@ -49,6 +53,16 @@ function Header({
|
||||
<Typography.Text>Configure panel</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
{showSwitchToView && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
data-testid="panel-editor-v2-switch-to-view"
|
||||
onClick={onSwitchToView}
|
||||
>
|
||||
Switch to View Mode
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PanelMode } from 'container/DashboardContainer/visualization/panels/typ
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import PanelBody from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody';
|
||||
import PanelHeader from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
|
||||
import type { AnyPanelInteractionProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/interactions';
|
||||
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
|
||||
@@ -42,6 +43,10 @@ interface PreviewPaneProps {
|
||||
dashboardPreference?: DashboardPreference;
|
||||
/** Close the standalone View modal — forwarded to the time-series/bar graph manager. */
|
||||
onCloseStandaloneView?: () => void;
|
||||
/** Opens the drill-down context menu; only the View modal wires it (the editor preview omits it). */
|
||||
onClick?: AnyPanelInteractionProps['onClick'];
|
||||
/** Arms the drill-down click on interactive renderers — the View modal enables it, the editor doesn't. */
|
||||
enableDrillDown?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,6 +69,8 @@ function PreviewPane({
|
||||
hideHeader = false,
|
||||
dashboardPreference,
|
||||
onCloseStandaloneView,
|
||||
onClick,
|
||||
enableDrillDown,
|
||||
}: PreviewPaneProps): JSX.Element {
|
||||
const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind];
|
||||
const queryType = getPanelQueryType(panel);
|
||||
@@ -96,6 +103,7 @@ function PreviewPane({
|
||||
<PanelHeader
|
||||
panelId={panelId}
|
||||
panel={panel}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
warning={data.response?.data?.warning}
|
||||
@@ -119,6 +127,8 @@ function PreviewPane({
|
||||
searchTerm={searchable ? searchTerm : undefined}
|
||||
pagination={pagination}
|
||||
onCloseStandaloneView={onCloseStandaloneView}
|
||||
onClick={onClick}
|
||||
enableDrillDown={enableDrillDown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -48,6 +48,10 @@ jest.mock('../hooks/usePanelEditorSave', () => ({
|
||||
jest.mock('../hooks/useSwitchColumnsOnSignalChange', () => ({
|
||||
useSwitchColumnsOnSignalChange: jest.fn(),
|
||||
}));
|
||||
const mockOnSwitchToView = jest.fn();
|
||||
jest.mock('../hooks/useSwitchToViewMode', () => ({
|
||||
useSwitchToViewMode: (): (() => void) => mockOnSwitchToView,
|
||||
}));
|
||||
jest.mock('../hooks/useSeedNewListColumns', () => ({
|
||||
useSeedNewListColumns: jest.fn(),
|
||||
}));
|
||||
@@ -138,13 +142,16 @@ jest.mock('../ListColumnsEditor/ListColumnsEditor', () => ({
|
||||
default: (): JSX.Element => <div data-testid="list-columns" />,
|
||||
}));
|
||||
|
||||
function makePanel(kind: string): DashboardtypesPanelDTO {
|
||||
function makePanel(
|
||||
kind: string,
|
||||
queries: unknown[] = [],
|
||||
): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'CPU' },
|
||||
plugin: { kind, spec: {} },
|
||||
queries: [],
|
||||
queries,
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
@@ -159,12 +166,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 +248,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 +292,30 @@ describe('PanelEditorContainer composition', () => {
|
||||
await userEvent.click(screen.getByTestId('editor-save'));
|
||||
|
||||
await waitFor(() => expect(baseProps.onSaved).toHaveBeenCalled());
|
||||
|
||||
expect(mockBuildSaveSpec).toHaveBeenCalledWith(panel.spec);
|
||||
expect(mockSave).toHaveBeenCalledWith(panel.spec);
|
||||
});
|
||||
|
||||
it('offers Switch to View Mode for an existing panel', () => {
|
||||
setup(makePanel('signoz/TimeSeriesPanel'));
|
||||
|
||||
expect(mockHeaderProps).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
showSwitchToView: true,
|
||||
onSwitchToView: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('hides Switch to View Mode for a new (unsaved) panel', () => {
|
||||
setup(makePanel('signoz/TimeSeriesPanel'), { isNew: true });
|
||||
|
||||
expect(mockHeaderProps).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ showSwitchToView: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the list-columns editor only for list panels', () => {
|
||||
setup(makePanel('signoz/ListPanel'));
|
||||
expect(screen.getByTestId('list-columns')).toBeInTheDocument();
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { useSwitchToViewMode } from '../useSwitchToViewMode';
|
||||
|
||||
const mockSafeNavigate = jest.fn();
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
let mockSearch = '';
|
||||
jest.mock('hooks/useUrlQuery', () => ({
|
||||
__esModule: true,
|
||||
default: (): URLSearchParams => new URLSearchParams(mockSearch),
|
||||
}));
|
||||
|
||||
const query = { queryType: 'builder' } as unknown as Query;
|
||||
|
||||
describe('useSwitchToViewMode', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockSearch = '';
|
||||
});
|
||||
|
||||
function invoke(): void {
|
||||
const { result } = renderHook(() =>
|
||||
useSwitchToViewMode({
|
||||
dashboardId: 'dash-1',
|
||||
panelId: 'panel-1',
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
query,
|
||||
}),
|
||||
);
|
||||
result.current();
|
||||
}
|
||||
|
||||
it('opens the dashboard with the View modal seeded from the live query', () => {
|
||||
invoke();
|
||||
|
||||
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
|
||||
const target = new URL(mockSafeNavigate.mock.calls[0][0], 'http://x');
|
||||
expect(target.pathname).toBe('/dashboard/dash-1');
|
||||
expect(target.searchParams.get('expandedWidgetId')).toBe('panel-1');
|
||||
expect(target.searchParams.get('graphType')).toBe(PANEL_TYPES.TIME_SERIES);
|
||||
expect(
|
||||
JSON.parse(
|
||||
decodeURIComponent(target.searchParams.get('compositeQuery') || ''),
|
||||
),
|
||||
).toStrictEqual(query);
|
||||
});
|
||||
|
||||
it('carries dashboard variables through and drops other editor URL state', () => {
|
||||
mockSearch = 'variables=%7B%22a%22%3A1%7D&compositeQuery=stale';
|
||||
invoke();
|
||||
|
||||
const target = new URL(mockSafeNavigate.mock.calls[0][0], 'http://x');
|
||||
expect(target.searchParams.get('variables')).toBe('{"a":1}');
|
||||
// The stale editor query is replaced with the live one, not the URL leftover.
|
||||
expect(target.searchParams.get('compositeQuery')).not.toBe('stale');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useCallback } from 'react';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
interface UseSwitchToViewModeArgs {
|
||||
dashboardId: string;
|
||||
panelId: string;
|
||||
panelType: PANEL_TYPES;
|
||||
query: Query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback that leaves the editor for the dashboard with this panel expanded in the
|
||||
* View modal, seeded with the live (un-saved) query — V1's "Switch to View Mode".
|
||||
*/
|
||||
export function useSwitchToViewMode({
|
||||
dashboardId,
|
||||
panelId,
|
||||
panelType,
|
||||
query,
|
||||
}: UseSwitchToViewModeArgs): () => void {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
return useCallback((): void => {
|
||||
const params = new URLSearchParams();
|
||||
const variables = urlQuery.get(QueryParams.variables);
|
||||
if (variables) {
|
||||
params.set(QueryParams.variables, variables);
|
||||
}
|
||||
params.set(QueryParams.expandedWidgetId, panelId);
|
||||
params.set(QueryParams.graphType, panelType);
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(query)),
|
||||
);
|
||||
safeNavigate(
|
||||
`${generatePath(ROUTES.DASHBOARD, { dashboardId })}?${params.toString()}`,
|
||||
);
|
||||
}, [safeNavigate, urlQuery, dashboardId, panelId, panelType, query]);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
|
||||
import { getExecStats } from '../queryV5/v5ResponseData';
|
||||
@@ -28,6 +29,7 @@ import { usePanelEditSession } from './hooks/usePanelEditSession';
|
||||
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
|
||||
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
|
||||
import { useSwitchColumnsOnSignalChange } from './hooks/useSwitchColumnsOnSignalChange';
|
||||
import { useSwitchToViewMode } from './hooks/useSwitchToViewMode';
|
||||
import { useTableColumns } from './hooks/useTableColumns';
|
||||
import ListColumnsEditor from './ListColumnsEditor/ListColumnsEditor';
|
||||
|
||||
@@ -143,9 +145,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,6 +190,13 @@ 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> => {
|
||||
try {
|
||||
// Bake the live query into the spec so unstaged edits are saved too.
|
||||
@@ -200,7 +213,9 @@ function PanelEditorContainer({
|
||||
<Header
|
||||
isDirty={isDirty}
|
||||
isSaving={isSaving}
|
||||
showSwitchToView={!isNew}
|
||||
onSave={onSave}
|
||||
onSwitchToView={onSwitchToView}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<ResizablePanelGroup
|
||||
|
||||
@@ -1,37 +1,60 @@
|
||||
import { Clock, RotateCw } from '@signozhq/icons';
|
||||
import { CalendarRange, Clock, RotateCw } from '@signozhq/icons';
|
||||
|
||||
import PanelMessage from '../PanelMessage/PanelMessage';
|
||||
import {
|
||||
selectViewPanelExtendWindow,
|
||||
useViewPanelStore,
|
||||
} from '../../../store/useViewPanelStore';
|
||||
import PanelLoader from '../PanelLoader/PanelLoader';
|
||||
import PanelMessage, { PanelMessageAction } from '../PanelMessage/PanelMessage';
|
||||
import { useExtendTimeWindow } from './useExtendTimeWindow';
|
||||
|
||||
interface NoDataProps {
|
||||
/** Title override. Defaults to the time-range empty-state copy. */
|
||||
title?: string;
|
||||
/** Description override. Defaults to the "widen the range" hint. */
|
||||
description?: string;
|
||||
/** In flight over empty data → show the loader, not the empty state. */
|
||||
isFetching?: boolean;
|
||||
/** When provided, renders a Retry button that re-runs the query. */
|
||||
onRetry?: () => void;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared empty-state for panel renderers: wraps `PanelMessage` so every panel
|
||||
* kind surfaces the same "no data" affordance when a query returns nothing.
|
||||
* Shared empty-state for panel renderers. The query succeeded but returned nothing,
|
||||
* so we offer to widen the time window — global by default, or the View modal's
|
||||
* local window when it publishes one to the store — alongside a Retry that re-runs
|
||||
* the query.
|
||||
*/
|
||||
function NoData({
|
||||
title = 'No data in this time range',
|
||||
description = 'Nothing in the selected window. Try widening the range.',
|
||||
isFetching = false,
|
||||
onRetry,
|
||||
'data-testid': testId = 'panel-no-data',
|
||||
}: NoDataProps): JSX.Element {
|
||||
const viewExtend = useViewPanelStore(selectViewPanelExtendWindow);
|
||||
const globalExtend = useExtendTimeWindow();
|
||||
const { canExtend, actionLabel, extend } = viewExtend ?? globalExtend;
|
||||
|
||||
if (isFetching) {
|
||||
return <PanelLoader />;
|
||||
}
|
||||
|
||||
const extendAction: PanelMessageAction | undefined =
|
||||
canExtend && actionLabel
|
||||
? { label: actionLabel, onClick: extend, icon: <CalendarRange size={14} /> }
|
||||
: undefined;
|
||||
|
||||
const retryAction: PanelMessageAction | undefined = onRetry
|
||||
? { label: 'Retry', onClick: onRetry, icon: <RotateCw size={14} /> }
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<PanelMessage
|
||||
icon={<Clock size={18} />}
|
||||
title={title}
|
||||
description={description}
|
||||
action={
|
||||
onRetry
|
||||
? { label: 'Retry', onClick: onRetry, icon: <RotateCw size={14} /> }
|
||||
: undefined
|
||||
}
|
||||
action={extendAction ?? retryAction}
|
||||
secondaryAction={extendAction ? retryAction : undefined}
|
||||
data-testid={testId}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import { useViewPanelStore } from '../../../../store/useViewPanelStore';
|
||||
import { ExtendTimeWindow } from '../extendWindow';
|
||||
import NoData from '../NoData';
|
||||
|
||||
const mockUseExtendTimeWindow = jest.fn();
|
||||
jest.mock('../useExtendTimeWindow', () => ({
|
||||
useExtendTimeWindow: (): ExtendTimeWindow => mockUseExtendTimeWindow(),
|
||||
}));
|
||||
|
||||
const inert: ExtendTimeWindow = {
|
||||
canExtend: false,
|
||||
actionLabel: null,
|
||||
extend: jest.fn(),
|
||||
};
|
||||
|
||||
function extender(over?: Partial<ExtendTimeWindow>): ExtendTimeWindow {
|
||||
return {
|
||||
canExtend: true,
|
||||
actionLabel: 'Extend time range',
|
||||
extend: jest.fn(),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('NoData', () => {
|
||||
beforeEach(() => {
|
||||
mockUseExtendTimeWindow.mockReturnValue(inert);
|
||||
useViewPanelStore.setState({ viewPanelExtendWindow: null });
|
||||
});
|
||||
|
||||
it('renders the empty-state title and hint', () => {
|
||||
render(<NoData />);
|
||||
|
||||
expect(screen.getByTestId('panel-no-data')).toBeInTheDocument();
|
||||
expect(screen.getByText('No data in this time range')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Nothing in the selected window. Try widening the range.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers to extend the window as the primary action', () => {
|
||||
mockUseExtendTimeWindow.mockReturnValue(extender());
|
||||
render(<NoData />);
|
||||
|
||||
const action = screen.getByTestId('panel-no-data-action');
|
||||
expect(action).toHaveTextContent('Extend time range');
|
||||
// No retry handler → no secondary Retry button.
|
||||
expect(
|
||||
screen.queryByTestId('panel-no-data-secondary-action'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders both Extend (primary) and Retry (secondary) when a retry handler is given', () => {
|
||||
const onRetry = jest.fn();
|
||||
mockUseExtendTimeWindow.mockReturnValue(extender());
|
||||
render(<NoData onRetry={onRetry} />);
|
||||
|
||||
expect(screen.getByTestId('panel-no-data-action')).toHaveTextContent(
|
||||
'Extend time range',
|
||||
);
|
||||
const retry = screen.getByTestId('panel-no-data-secondary-action');
|
||||
expect(retry).toHaveTextContent('Retry');
|
||||
|
||||
fireEvent.click(retry);
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('falls back to Retry as the sole action when the window cannot be widened', () => {
|
||||
const onRetry = jest.fn();
|
||||
render(<NoData onRetry={onRetry} />);
|
||||
|
||||
const action = screen.getByTestId('panel-no-data-action');
|
||||
expect(action).toHaveTextContent('Retry');
|
||||
expect(
|
||||
screen.queryByTestId('panel-no-data-secondary-action'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(action);
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('prefers the View modal extender (store) over the global one', () => {
|
||||
const globalExtend = jest.fn();
|
||||
const storeExtend = jest.fn();
|
||||
mockUseExtendTimeWindow.mockReturnValue(extender({ extend: globalExtend }));
|
||||
useViewPanelStore.setState({
|
||||
viewPanelExtendWindow: extender({ extend: storeExtend }),
|
||||
});
|
||||
render(<NoData />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('panel-no-data-action'));
|
||||
expect(storeExtend).toHaveBeenCalledTimes(1);
|
||||
expect(globalExtend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders no action when nothing can be widened and no retry handler', () => {
|
||||
render(<NoData />);
|
||||
|
||||
expect(screen.queryByTestId('panel-no-data-action')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('panel-no-data-secondary-action'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the panel loader (not the empty state) while refetching', () => {
|
||||
mockUseExtendTimeWindow.mockReturnValue(extender());
|
||||
render(<NoData isFetching />);
|
||||
|
||||
expect(screen.getByTestId('panel-loading')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('panel-no-data')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('panel-no-data-action')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('honours the data-testid override for the number panel', () => {
|
||||
mockUseExtendTimeWindow.mockReturnValue(extender());
|
||||
render(<NoData data-testid="number-panel-no-data" />);
|
||||
|
||||
expect(screen.getByTestId('number-panel-no-data')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { ReactNode } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { UPDATE_TIME_INTERVAL } from 'types/actions/globalTime';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
|
||||
import { useExtendTimeWindow } from '../useExtendTimeWindow';
|
||||
|
||||
const mockSafeNavigate = jest.fn();
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
const NS_PER_MS = 1e6;
|
||||
const MINUTE_MS = 60 * 1000;
|
||||
const DAY_MS = 24 * 60 * MINUTE_MS;
|
||||
|
||||
// A window anchored in the past so the zoom-out ladder is deterministic (real
|
||||
// Date.now() is far ahead, so it always center-anchors into a custom range).
|
||||
const PAST_END_MS = 1_600_003_600_000;
|
||||
|
||||
const mockStore = configureStore([thunk]);
|
||||
|
||||
function globalTimeState(
|
||||
partial: Pick<GlobalReducer, 'selectedTime' | 'minTime' | 'maxTime'>,
|
||||
): GlobalReducer {
|
||||
return {
|
||||
loading: false,
|
||||
isAutoRefreshDisabled: false,
|
||||
selectedAutoRefreshInterval: '',
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
function setup(globalTime: GlobalReducer): {
|
||||
result: { current: ReturnType<typeof useExtendTimeWindow> };
|
||||
store: ReturnType<typeof mockStore>;
|
||||
} {
|
||||
const store = mockStore({ globalTime });
|
||||
const { result } = renderHook(() => useExtendTimeWindow(), {
|
||||
wrapper: ({ children }: { children: ReactNode }): JSX.Element => (
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>{children}</MemoryRouter>
|
||||
</Provider>
|
||||
),
|
||||
});
|
||||
return { result, store };
|
||||
}
|
||||
|
||||
describe('useExtendTimeWindow', () => {
|
||||
beforeEach(() => {
|
||||
mockSafeNavigate.mockClear();
|
||||
});
|
||||
|
||||
it('reports it can extend and dispatches a wider range', () => {
|
||||
const { result, store } = setup(
|
||||
globalTimeState({
|
||||
selectedTime: '30m',
|
||||
minTime: (PAST_END_MS - 30 * MINUTE_MS) * NS_PER_MS,
|
||||
maxTime: PAST_END_MS * NS_PER_MS,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.canExtend).toBe(true);
|
||||
expect(result.current.actionLabel).toBe('Extend time range');
|
||||
|
||||
act(() => result.current.extend());
|
||||
|
||||
const [action] = store.getActions();
|
||||
expect(action.type).toBe(UPDATE_TIME_INTERVAL);
|
||||
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('cannot extend past the widest window', () => {
|
||||
const { result, store } = setup(
|
||||
globalTimeState({
|
||||
selectedTime: 'custom',
|
||||
minTime: (PAST_END_MS - 40 * DAY_MS) * NS_PER_MS,
|
||||
maxTime: PAST_END_MS * NS_PER_MS,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.canExtend).toBe(false);
|
||||
expect(result.current.actionLabel).toBeNull();
|
||||
|
||||
act(() => result.current.extend());
|
||||
expect(store.getActions()).toHaveLength(0);
|
||||
expect(mockSafeNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ZoomOutResult } from 'lib/zoomOutUtils';
|
||||
|
||||
export interface ExtendTimeWindow {
|
||||
canExtend: boolean;
|
||||
/** Button copy; null when the window can't be widened further. */
|
||||
actionLabel: string | null;
|
||||
extend: () => void;
|
||||
}
|
||||
|
||||
const EXTEND_ACTION_LABEL = 'Extend time range';
|
||||
|
||||
/**
|
||||
* Adapts a zoom-out ladder result (`getNextZoomOutRange`) plus its apply callback
|
||||
* into the panel empty-state extender. A `null` result means the window is already
|
||||
* at the widest ladder step, so there is nothing to extend to.
|
||||
*/
|
||||
export function buildExtendWindow(
|
||||
result: ZoomOutResult | null,
|
||||
extend: () => void,
|
||||
): ExtendTimeWindow {
|
||||
return {
|
||||
canExtend: result !== null,
|
||||
actionLabel: result ? EXTEND_ACTION_LABEL : null,
|
||||
extend,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// eslint-disable-next-line no-restricted-imports -- global time lives in redux; V2 time dispatch is not yet migrated
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useZoomOut } from 'hooks/useZoomOut';
|
||||
import { getNextZoomOutRange } from 'lib/zoomOutUtils';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { buildExtendWindow, ExtendTimeWindow } from './extendWindow';
|
||||
import { NANO_SECOND_MULTIPLIER } from '@/store/globalTime';
|
||||
|
||||
/**
|
||||
* Default empty-state extender: widen the dashboard's global time via the shared
|
||||
* zoom-out ladder. Dispatch + URL sync live in `useZoomOut`; the View modal
|
||||
* overrides this with its own local extender via the store.
|
||||
*/
|
||||
export function useExtendTimeWindow(): ExtendTimeWindow {
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
const extend = useZoomOut();
|
||||
const result = getNextZoomOutRange(
|
||||
Math.floor(minTime / NANO_SECOND_MULTIPLIER),
|
||||
Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
|
||||
);
|
||||
return buildExtendWindow(result, extend);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Centred full-body spinner (first-fetch + refetch-over-empty).
|
||||
.loader {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Spin } from 'antd';
|
||||
import { Loader } from '@signozhq/icons';
|
||||
|
||||
import styles from './PanelLoader.module.scss';
|
||||
|
||||
interface PanelLoaderProps {
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
/** Centred full-panel spinner shown while a query is in flight with nothing to display yet. */
|
||||
function PanelLoader({
|
||||
'data-testid': testId = 'panel-loading',
|
||||
}: PanelLoaderProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.loader} data-testid={testId}>
|
||||
<Spin indicator={<Loader size={14} className="animate-spin" />} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelLoader;
|
||||
@@ -45,6 +45,10 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.action {
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ interface PanelMessageProps {
|
||||
description?: string;
|
||||
/** Optional call-to-action (e.g. Retry). Omitted → no button. */
|
||||
action?: PanelMessageAction;
|
||||
/** Second call-to-action rendered beside `action` (e.g. Extend + Retry). */
|
||||
secondaryAction?: PanelMessageAction;
|
||||
/** `danger` tints the icon for failure states; `neutral` for empty states. */
|
||||
tone?: 'neutral' | 'danger';
|
||||
'data-testid'?: string;
|
||||
@@ -34,9 +36,18 @@ function PanelMessage({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
secondaryAction,
|
||||
tone = 'neutral',
|
||||
'data-testid': testId,
|
||||
}: PanelMessageProps): JSX.Element {
|
||||
const actions: Array<{ suffix: string; action: PanelMessageAction }> = [];
|
||||
if (action) {
|
||||
actions.push({ suffix: 'action', action });
|
||||
}
|
||||
if (secondaryAction) {
|
||||
actions.push({ suffix: 'secondary-action', action: secondaryAction });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.message} data-testid={testId}>
|
||||
<div className={cx(styles.icon, { [styles.iconDanger]: tone === 'danger' })}>
|
||||
@@ -48,18 +59,22 @@ function PanelMessage({
|
||||
{description}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{action && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={action.icon}
|
||||
onClick={action.onClick}
|
||||
className={styles.action}
|
||||
data-testid={testId ? `${testId}-action` : undefined}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
{actions.length > 0 && (
|
||||
<div className={styles.actions}>
|
||||
{actions.map(({ suffix, action: item }) => (
|
||||
<Button
|
||||
key={suffix}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={item.icon}
|
||||
onClick={item.onClick}
|
||||
data-testid={testId ? `${testId}-${suffix}` : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -43,4 +43,28 @@ describe('PanelMessage', () => {
|
||||
fireEvent.click(button);
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders both actions side by side and wires each onClick', () => {
|
||||
const onPrimary = jest.fn();
|
||||
const onSecondary = jest.fn();
|
||||
render(
|
||||
<PanelMessage
|
||||
icon={null}
|
||||
title="No data in this time range"
|
||||
action={{ label: 'Extend time range', onClick: onPrimary }}
|
||||
secondaryAction={{ label: 'Retry', onClick: onSecondary }}
|
||||
data-testid="panel-no-data"
|
||||
/>,
|
||||
);
|
||||
|
||||
const primary = screen.getByTestId('panel-no-data-action');
|
||||
const secondary = screen.getByTestId('panel-no-data-secondary-action');
|
||||
expect(primary).toHaveTextContent('Extend time range');
|
||||
expect(secondary).toHaveTextContent('Retry');
|
||||
|
||||
fireEvent.click(primary);
|
||||
fireEvent.click(secondary);
|
||||
expect(onPrimary).toHaveBeenCalledTimes(1);
|
||||
expect(onSecondary).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ function BarPanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
isFetching,
|
||||
refetch,
|
||||
onClick,
|
||||
onDragSelect,
|
||||
@@ -186,7 +187,9 @@ function BarPanelRenderer({
|
||||
data-testid="bar-panel-renderer"
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{flatSeries.length === 0 && <NoData onRetry={refetch} />}
|
||||
{flatSeries.length === 0 && (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
)}
|
||||
{flatSeries.length > 0 &&
|
||||
containerDimensions.width > 0 &&
|
||||
containerDimensions.height > 0 && (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -25,6 +25,7 @@ function HistogramPanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
isFetching,
|
||||
refetch,
|
||||
panelMode,
|
||||
}: PanelRendererProps<'signoz/HistogramPanel'>): JSX.Element {
|
||||
@@ -104,7 +105,9 @@ function HistogramPanelRenderer({
|
||||
data-testid="histogram-panel-renderer"
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{flatSeries.length === 0 && <NoData onRetry={refetch} />}
|
||||
{flatSeries.length === 0 && (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
)}
|
||||
{flatSeries.length > 0 &&
|
||||
containerDimensions.width > 0 &&
|
||||
containerDimensions.height > 0 && (
|
||||
|
||||
@@ -24,7 +24,7 @@ export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: false,
|
||||
download: { csv: false, png: true, svg: true },
|
||||
createAlert: true,
|
||||
search: false,
|
||||
drilldown: false,
|
||||
|
||||
@@ -21,6 +21,11 @@
|
||||
|
||||
// Logs: drop the row separators for a denser log view (V1 logs table parity).
|
||||
.logRows {
|
||||
// Every row opens the log detail drawer, so flag it as clickable.
|
||||
:global(.ant-table-tbody) > tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:global(.ant-table-tbody) > tr > td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ function ListPanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
isFetching,
|
||||
refetch,
|
||||
searchTerm = '',
|
||||
pagination,
|
||||
@@ -142,7 +143,7 @@ function ListPanelRenderer({
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{!table || dataSource.length === 0 ? (
|
||||
<NoData onRetry={refetch} />
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
|
||||
@@ -34,7 +34,7 @@ export const definition: PanelDefinition<'signoz/ListPanel'> = {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: false,
|
||||
download: { csv: false, png: true, svg: true },
|
||||
createAlert: false,
|
||||
search: true,
|
||||
drilldown: false,
|
||||
|
||||
@@ -24,6 +24,7 @@ import ValueDisplay from './components/ValueDisplay/ValueDisplay';
|
||||
function NumberPanelRenderer({
|
||||
panel,
|
||||
data,
|
||||
isFetching,
|
||||
refetch,
|
||||
onClick,
|
||||
enableDrillDown,
|
||||
@@ -125,7 +126,11 @@ function NumberPanelRenderer({
|
||||
: {})}
|
||||
>
|
||||
{value === null ? (
|
||||
<NoData data-testid="number-panel-no-data" onRetry={refetch} />
|
||||
<NoData
|
||||
data-testid="number-panel-no-data"
|
||||
isFetching={isFetching}
|
||||
onRetry={refetch}
|
||||
/>
|
||||
) : (
|
||||
<ValueDisplay
|
||||
value={formattedValue}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -27,6 +27,7 @@ function PiePanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
isFetching,
|
||||
refetch,
|
||||
onClick,
|
||||
enableDrillDown,
|
||||
@@ -94,7 +95,7 @@ function PiePanelRenderer({
|
||||
return (
|
||||
<div data-testid="pie-panel-renderer" className={PanelStyles.panelContainer}>
|
||||
{slices.length === 0 ? (
|
||||
<NoData onRetry={refetch} />
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
) : (
|
||||
<Pie
|
||||
data={slices}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -35,6 +35,7 @@ function TablePanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
isFetching,
|
||||
refetch,
|
||||
searchTerm = '',
|
||||
onClick,
|
||||
@@ -159,7 +160,7 @@ function TablePanelRenderer({
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{!table || dataSource.length === 0 ? (
|
||||
<NoData onRetry={refetch} />
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
) : (
|
||||
<div className={styles.container}>
|
||||
<Table
|
||||
|
||||
@@ -18,3 +18,11 @@
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
}
|
||||
|
||||
.clickableCell {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--primary-background);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user