mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-07 23:20:37 +01:00
Compare commits
6 Commits
fix/dashbo
...
tvats-quer
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50357dde40 | ||
|
|
e36d927c9f | ||
|
|
f03a611ace | ||
|
|
211e81ab78 | ||
|
|
0695bc3bd4 | ||
|
|
85aea31bee |
6
.github/workflows/integrationci.yaml
vendored
6
.github/workflows/integrationci.yaml
vendored
@@ -48,7 +48,11 @@ jobs:
|
||||
- logspipelines
|
||||
- passwordauthn
|
||||
- preference
|
||||
- querier
|
||||
- querierlogs
|
||||
- queriertraces
|
||||
- queriermetrics
|
||||
- querierscalar
|
||||
- queriercommon
|
||||
- rawexportdata
|
||||
- role
|
||||
- rootuser
|
||||
|
||||
@@ -79,7 +79,6 @@
|
||||
"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,9 +164,6 @@ 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
|
||||
@@ -5454,9 +5451,6 @@ 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==}
|
||||
|
||||
@@ -14501,8 +14495,6 @@ 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_OVERVIEW": "SigNoz | LLM Observability Overview",
|
||||
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration"
|
||||
}
|
||||
"LLM_OBSERVABILITY_BASE": "SigNoz | LLM Observability",
|
||||
"LLM_OBSERVABILITY_MODEL_PRICING": "SigNoz | Model Pricing"
|
||||
}
|
||||
|
||||
@@ -87,6 +87,6 @@
|
||||
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
|
||||
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
|
||||
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
|
||||
"LLM_OBSERVABILITY_OVERVIEW": "SigNoz | LLM Observability Overview",
|
||||
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration"
|
||||
}
|
||||
"LLM_OBSERVABILITY_BASE": "SigNoz | LLM Observability",
|
||||
"LLM_OBSERVABILITY_MODEL_PRICING": "SigNoz | Model Pricing"
|
||||
}
|
||||
|
||||
@@ -329,3 +329,10 @@ export const LLMObservabilityPage = Loadable(
|
||||
/* webpackChunkName: "LLM Observability Page" */ 'pages/LLMObservability'
|
||||
),
|
||||
);
|
||||
|
||||
export const LLMObservabilityModelPricingPage = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "LLM Observability Model Pricing Page" */ 'pages/LLMObservabilityModelPricing'
|
||||
),
|
||||
);
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
LicensePage,
|
||||
ListAllALertsPage,
|
||||
LLMObservabilityPage,
|
||||
LLMObservabilityModelPricingPage,
|
||||
LiveLogs,
|
||||
Login,
|
||||
Logs,
|
||||
@@ -514,17 +515,17 @@ const routes: AppRoutes[] = [
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
|
||||
path: ROUTES.LLM_OBSERVABILITY_BASE,
|
||||
exact: true,
|
||||
component: LLMObservabilityPage,
|
||||
key: 'LLM_OBSERVABILITY_OVERVIEW',
|
||||
key: 'LLM_OBSERVABILITY_BASE',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
|
||||
path: ROUTES.LLM_OBSERVABILITY_MODEL_PRICING,
|
||||
exact: true,
|
||||
component: LLMObservabilityPage,
|
||||
key: 'LLM_OBSERVABILITY_CONFIGURATION',
|
||||
component: LLMObservabilityModelPricingPage,
|
||||
key: 'LLM_OBSERVABILITY_MODEL_PRICING',
|
||||
isPrivate: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -47,7 +47,7 @@ function CheckboxValueRow({
|
||||
{customRendererForValue ? (
|
||||
customRendererForValue(value)
|
||||
) : (
|
||||
<TooltipSimple title={String(value)} side="top" align="start">
|
||||
<TooltipSimple title={value} side="top" align="center" arrow>
|
||||
<Typography.Text className="value-string" truncate={1}>
|
||||
{String(value)}
|
||||
</Typography.Text>
|
||||
|
||||
@@ -32,11 +32,6 @@ export enum LOCALSTORAGE {
|
||||
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,8 +90,7 @@ const ROUTES = {
|
||||
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
|
||||
MCP_SERVER: '/settings/mcp-server',
|
||||
LLM_OBSERVABILITY_BASE: '/llm-observability',
|
||||
LLM_OBSERVABILITY_OVERVIEW: '/llm-observability/overview',
|
||||
LLM_OBSERVABILITY_CONFIGURATION: '/llm-observability/configuration',
|
||||
LLM_OBSERVABILITY_MODEL_PRICING: '/llm-observability/settings/model-pricing',
|
||||
} as const;
|
||||
|
||||
export default ROUTES;
|
||||
|
||||
@@ -163,23 +163,12 @@
|
||||
}
|
||||
|
||||
&.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 {
|
||||
.resizable-box__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.quick-filters-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.api-module-right-section {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: calc(100% - 260px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,49 +4,21 @@ 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')}>
|
||||
<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"
|
||||
>
|
||||
<section className="api-quick-filter-left-section">
|
||||
<QuickFilters
|
||||
className="qf-api-monitoring"
|
||||
source={QuickFiltersSource.API_MONITORING}
|
||||
@@ -55,7 +27,7 @@ function Explorer(): JSX.Element {
|
||||
showQueryName={false}
|
||||
handleFilterVisibilityChange={(): void => {}}
|
||||
/>
|
||||
</ResizableBox>
|
||||
</section>
|
||||
<DomainList />
|
||||
</div>
|
||||
</Sentry.ErrorBoundary>
|
||||
|
||||
@@ -6,7 +6,6 @@ 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';
|
||||
@@ -17,8 +16,6 @@ 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';
|
||||
|
||||
@@ -43,21 +40,8 @@ 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();
|
||||
|
||||
@@ -155,18 +139,7 @@ function Hosts(): JSX.Element {
|
||||
<div className={styles.infraMonitoringContainer}>
|
||||
<div className={styles.infraContentRow}>
|
||||
{showFilters && (
|
||||
<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.quickFiltersContainer}>
|
||||
<div className={styles.quickFiltersContainerHeader}>
|
||||
<Typography.Text>Filters</Typography.Text>
|
||||
<Tooltip title="Collapse Filters">
|
||||
@@ -183,7 +156,7 @@ function Hosts(): JSX.Element {
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleQuickFiltersChange}
|
||||
/>
|
||||
</ResizableBox>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`${styles.listContainer}${
|
||||
|
||||
@@ -44,20 +44,26 @@
|
||||
}
|
||||
|
||||
.quickFiltersContainer {
|
||||
// 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;
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
overflow-y: auto;
|
||||
|
||||
:global(.resizable-box__content) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
height: 0.1rem;
|
||||
}
|
||||
|
||||
:global(.quick-filters-container) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
&::-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(.ant-collapse-header) {
|
||||
@@ -136,9 +142,7 @@
|
||||
}
|
||||
|
||||
.listContainerFiltersVisible {
|
||||
// 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%;
|
||||
max-width: calc(100% - 280px);
|
||||
}
|
||||
|
||||
.quickFiltersToggleContainer {
|
||||
|
||||
@@ -44,15 +44,26 @@
|
||||
}
|
||||
|
||||
.quickFiltersContainer {
|
||||
// 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;
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
overflow-y: auto;
|
||||
|
||||
:global(.resizable-box__content) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
&::-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(.quick-filters) {
|
||||
@@ -117,9 +128,7 @@
|
||||
}
|
||||
|
||||
.listContainerFiltersVisible {
|
||||
// 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%;
|
||||
max-width: calc(100% - 280px);
|
||||
}
|
||||
|
||||
.categorySelectorSection {
|
||||
@@ -210,16 +219,8 @@
|
||||
|
||||
.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,7 +6,6 @@ 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 {
|
||||
@@ -23,8 +22,6 @@ 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';
|
||||
@@ -59,23 +56,9 @@ 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();
|
||||
@@ -229,18 +212,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
<div className={styles.infraMonitoringContainer}>
|
||||
<div className={styles.infraContentRow}>
|
||||
{showFilters && (
|
||||
<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.quickFiltersContainer}>
|
||||
<div className={styles.categorySelectorSection}>
|
||||
<div className={styles.sectionHeader} data-type="resource">
|
||||
<Typography.Text className={styles.sectionLabel}>
|
||||
@@ -293,7 +265,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ResizableBox>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
.llmObservability {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
margin-top: var(--spacing-2);
|
||||
margin-left: var(--spacing-2);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
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">
|
||||
<Tabs
|
||||
items={items}
|
||||
value={activeTab}
|
||||
onChange={onTabChange}
|
||||
testId="llm-observability-tabs"
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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,4 +2,29 @@
|
||||
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,4 +1,5 @@
|
||||
import { Tabs } from '@signozhq/ui/tabs';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import ModelCostTabPanel from './ModelCostTabPanel';
|
||||
import styles from './LLMObservabilityModelPricing.module.scss';
|
||||
@@ -9,9 +10,20 @@ 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 in a later PR.
|
||||
// the unpriced-models tab lands, this can become a URL-backed param.
|
||||
defaultValue="model-costs"
|
||||
items={[
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ 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';
|
||||
@@ -160,6 +161,12 @@ 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}
|
||||
|
||||
@@ -1,326 +0,0 @@
|
||||
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),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,295 +0,0 @@
|
||||
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,11 +6,7 @@ import {
|
||||
useCreateOrUpdateLLMPricingRules,
|
||||
} from 'api/generated/services/llmpricingrules';
|
||||
|
||||
import {
|
||||
EMPTY_DRAFT,
|
||||
TOAST_MODEL_COST_ADDED,
|
||||
TOAST_MODEL_COST_UPDATED,
|
||||
} from '../../../../constants';
|
||||
import { EMPTY_DRAFT } from '../../../../constants';
|
||||
import type { DrawerDraft, DrawerMode, PricingRule } from '../../../../types';
|
||||
import { buildRulePayload, draftFromRule } from '../../../../utils';
|
||||
|
||||
@@ -80,9 +76,7 @@ export function useModelCostDrawer(): UseModelCostDrawerResult {
|
||||
await invalidateList();
|
||||
setIsOpen(false);
|
||||
setSelectedRuleId(null);
|
||||
toast.success(
|
||||
mode === 'edit' ? TOAST_MODEL_COST_UPDATED : TOAST_MODEL_COST_ADDED,
|
||||
);
|
||||
toast.success(mode === 'edit' ? 'Model cost updated' : '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 - 170px);
|
||||
height: calc(100vh - 250px);
|
||||
overflow-y: auto;
|
||||
|
||||
:global(table) tbody tr {
|
||||
|
||||
@@ -6,7 +6,6 @@ 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
|
||||
@@ -50,7 +49,7 @@ export function useModelCostDelete(): UseModelCostDeleteResult {
|
||||
queryKey: getListLLMPricingRulesQueryKey(),
|
||||
});
|
||||
setPendingDelete(null);
|
||||
toast.success(TOAST_MODEL_COST_DELETED);
|
||||
toast.success('Model cost deleted');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Delete failed';
|
||||
toast.error(message);
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
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,10 +4,6 @@ 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';
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
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,23 +3,12 @@
|
||||
flex-direction: row;
|
||||
|
||||
.meter-explorer-quick-filters-section {
|
||||
// Width is owned by ResizableBox (inline style).
|
||||
flex-shrink: 0;
|
||||
width: 280px;
|
||||
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 {
|
||||
@@ -89,9 +78,7 @@
|
||||
|
||||
&.quick-filters-open {
|
||||
.meter-explorer-content-section {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
width: calc(100% - 280px);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ 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';
|
||||
@@ -19,8 +18,6 @@ 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';
|
||||
@@ -33,10 +30,6 @@ 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,
|
||||
@@ -62,16 +55,6 @@ 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(
|
||||
@@ -144,19 +127,10 @@ function Explorer(): JSX.Element {
|
||||
'quick-filters-open': showQuickFilters,
|
||||
})}
|
||||
>
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
|
||||
initialWidth={quickFiltersInitialWidth}
|
||||
minWidth={QUICK_FILTERS_MIN_WIDTH}
|
||||
maxWidth={QUICK_FILTERS_MAX_WIDTH}
|
||||
onResize={persistQuickFiltersWidth}
|
||||
resetToDefaultOnDoubleClick
|
||||
withHandle
|
||||
<div
|
||||
className={cx('meter-explorer-quick-filters-section', {
|
||||
hidden: !showQuickFilters,
|
||||
})}
|
||||
handleTestId="quick-filters-resize-handle"
|
||||
>
|
||||
<QuickFilters
|
||||
className="qf-meter-explorer"
|
||||
@@ -168,7 +142,7 @@ function Explorer(): JSX.Element {
|
||||
setShowQuickFilters(!showQuickFilters);
|
||||
}}
|
||||
/>
|
||||
</ResizableBox>
|
||||
</div>
|
||||
|
||||
<div className="meter-explorer-content-section">
|
||||
<div className="meter-explorer-explore-content">
|
||||
|
||||
@@ -203,8 +203,8 @@ export const routesToSkip = [
|
||||
ROUTES.METER_EXPLORER_VIEWS,
|
||||
ROUTES.METRICS_EXPLORER_VOLUME_CONTROL,
|
||||
ROUTES.SOMETHING_WENT_WRONG,
|
||||
ROUTES.LLM_OBSERVABILITY_OVERVIEW,
|
||||
ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
|
||||
ROUTES.LLM_OBSERVABILITY_BASE,
|
||||
ROUTES.LLM_OBSERVABILITY_MODEL_PRICING,
|
||||
];
|
||||
|
||||
export const routesToDisable = [ROUTES.LOGS_EXPLORER, ROUTES.LIVE_LOGS];
|
||||
|
||||
@@ -30,6 +30,4 @@ export const useGetFieldKeys = ({
|
||||
queryKey: ['fieldKeys', signal, name],
|
||||
queryFn: () => getFieldKeys(signal, name),
|
||||
enabled,
|
||||
// Keep prior keys during a search refetch so the dropdown doesn't blank out.
|
||||
keepPreviousData: true,
|
||||
});
|
||||
|
||||
@@ -20,23 +20,12 @@
|
||||
}
|
||||
|
||||
&.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 {
|
||||
.resizable-box__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.quick-filters-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.all-errors-right-section {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: calc(100% - 260px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,18 +18,12 @@ 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();
|
||||
@@ -61,38 +55,17 @@ 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 && (
|
||||
<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"
|
||||
>
|
||||
<section className={cx('all-errors-quick-filter-section')}>
|
||||
<QuickFilters
|
||||
className="qf-exceptions"
|
||||
source={QuickFiltersSource.EXCEPTIONS}
|
||||
signal={SignalType.EXCEPTIONS}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
/>
|
||||
</ResizableBox>
|
||||
</section>
|
||||
)}
|
||||
<section
|
||||
className={cx(
|
||||
|
||||
@@ -5,9 +5,3 @@
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Actions / Configure / Edit-as-JSON: a flat l3 outline — no depth/shadow. */
|
||||
.toolbarButton {
|
||||
border: 1px solid var(--l3-border) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { FullScreenHandle } from 'react-full-screen';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import {
|
||||
@@ -59,8 +59,6 @@ function DashboardActions({
|
||||
onOpenRename,
|
||||
}: DashboardActionsProps): JSX.Element {
|
||||
const canEdit = useDashboardStore((s) => s.isEditable);
|
||||
const settingsRequest = useDashboardStore((s) => s.settingsRequest);
|
||||
const clearSettingsRequest = useDashboardStore((s) => s.clearSettingsRequest);
|
||||
const { user } = useAppContext();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
@@ -74,14 +72,6 @@ function DashboardActions({
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState<boolean>(false);
|
||||
const deleteDashboardMutation = useDeleteDashboard(dashboard.id);
|
||||
|
||||
// Open the settings drawer when something in the tree requests it (e.g. the
|
||||
// variables bar's "Add variable" button).
|
||||
useEffect(() => {
|
||||
if (settingsRequest) {
|
||||
setIsSettingsDrawerOpen(true);
|
||||
}
|
||||
}, [settingsRequest]);
|
||||
|
||||
const { addSection, isSaving: isAddingSection } = useAddSection({
|
||||
layouts: dashboard.spec.layouts,
|
||||
});
|
||||
@@ -211,7 +201,6 @@ function DashboardActions({
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="md"
|
||||
className={styles.toolbarButton}
|
||||
prefix={<Grid3X3 size="md" />}
|
||||
testId="options"
|
||||
>
|
||||
@@ -223,7 +212,6 @@ function DashboardActions({
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
className={styles.toolbarButton}
|
||||
prefix={<Configure size="md" />}
|
||||
testId="show-drawer"
|
||||
onClick={(): void => setIsSettingsDrawerOpen(true)}
|
||||
@@ -234,11 +222,7 @@ function DashboardActions({
|
||||
<SettingsDrawer
|
||||
drawerTitle="Dashboard Configuration"
|
||||
isOpen={isSettingsDrawerOpen}
|
||||
destroyOnClose
|
||||
onClose={(): void => {
|
||||
setIsSettingsDrawerOpen(false);
|
||||
clearSettingsRequest();
|
||||
}}
|
||||
onClose={(): void => setIsSettingsDrawerOpen(false)}
|
||||
>
|
||||
<DashboardSettings dashboard={dashboard} />
|
||||
</SettingsDrawer>
|
||||
@@ -247,7 +231,6 @@ function DashboardActions({
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
className={styles.toolbarButton}
|
||||
prefix={<Braces size="md" />}
|
||||
testId="edit-json"
|
||||
onClick={(): void => setIsJsonEditorOpen(true)}
|
||||
|
||||
@@ -19,7 +19,6 @@ 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 {
|
||||
@@ -100,7 +99,7 @@ function DashboardInfo({
|
||||
autoFocus
|
||||
value={draft}
|
||||
testId="dashboard-title-input"
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
maxLength={120}
|
||||
className={styles.dashboardTitleInput}
|
||||
onChange={(e): void => onDraftChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
|
||||
@@ -8,8 +8,6 @@ type SettingsDrawerProps = PropsWithChildren<{
|
||||
drawerTitle: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
/** Unmount the content on close so it re-initializes on the next open. */
|
||||
destroyOnClose?: boolean;
|
||||
}>;
|
||||
|
||||
function SettingsDrawer({
|
||||
@@ -17,7 +15,6 @@ function SettingsDrawer({
|
||||
drawerTitle,
|
||||
isOpen,
|
||||
onClose,
|
||||
destroyOnClose = false,
|
||||
}: SettingsDrawerProps): JSX.Element {
|
||||
return (
|
||||
<Drawer
|
||||
@@ -26,7 +23,6 @@ function SettingsDrawer({
|
||||
width="50%"
|
||||
onClose={onClose}
|
||||
open={isOpen}
|
||||
destroyOnClose={destroyOnClose}
|
||||
rootClassName={styles.settingsContainerRoot}
|
||||
>
|
||||
{/* Need to type cast because of OverlayScrollbar type definition. We should be good once we remove it. */}
|
||||
|
||||
@@ -12,7 +12,6 @@ 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';
|
||||
|
||||
@@ -72,7 +71,6 @@ function DashboardInfoForm({
|
||||
testId="dashboard-name"
|
||||
className={styles.dashboardNameInput}
|
||||
value={title}
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
onChange={(e): void => onTitleChange(e.target.value)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -40,7 +40,7 @@ function DynamicVariableFields({
|
||||
attributeError,
|
||||
}: DynamicVariableFieldsProps): JSX.Element {
|
||||
const [search, setSearch] = useState('');
|
||||
const debouncedSearch = useDebounce(search, 500);
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
const apiSignal = signalForApi(signal);
|
||||
|
||||
const {
|
||||
@@ -82,34 +82,17 @@ function DynamicVariableFields({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Combined row retained from V1: the field on the left, `from` + the
|
||||
telemetry source on the right. */}
|
||||
<div className={cx(styles.row, styles.dynamicCombinedRow)}>
|
||||
<CustomSelect
|
||||
className={styles.dynamicFieldSelect}
|
||||
showSearch
|
||||
value={attribute || undefined}
|
||||
placeholder="Select a field"
|
||||
loading={isLoading}
|
||||
options={options}
|
||||
onSearch={setSearch}
|
||||
onChange={(value): void => onChange({ dynamicAttribute: value as string })}
|
||||
noDataMessage="No fields found"
|
||||
errorMessage={errorMessage}
|
||||
onRetry={(): void => {
|
||||
void refetch();
|
||||
}}
|
||||
showRetryButton={error ? isRetryableError(error) : true}
|
||||
data-testid="variable-field-select"
|
||||
/>
|
||||
<Typography.Text className={styles.fromText}>from</Typography.Text>
|
||||
<TextToolTip
|
||||
text="By default, this searches across logs, traces, and metrics, which can be slow. Selecting a single source improves performance. Many fields share the same values across different signals (for example, `k8s.pod.name` is identical in logs, traces and metrics) making one source enough. Only use `All telemetry` when you need fields that have different values in different signal types."
|
||||
useFilledIcon={false}
|
||||
outlinedIcon={<Info size={14} />}
|
||||
/>
|
||||
<div className={cx(styles.row, styles.sortSection)}>
|
||||
<div className={cx(styles.labelContainer, styles.sourceLabel)}>
|
||||
<Typography.Text className={styles.label}>Source</Typography.Text>
|
||||
<TextToolTip
|
||||
text="By default, this searches across logs, traces, and metrics, which can be slow. Selecting a single source improves performance. Many fields share the same values across different signals (for example, `k8s.pod.name` is identical in logs, traces and metrics) making one source enough. Only use `All telemetry` when you need fields that have different values in different signal types."
|
||||
useFilledIcon={false}
|
||||
outlinedIcon={<Info size={14} />}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
className={styles.dynamicSourceSelect}
|
||||
className={styles.sortSelect}
|
||||
popupMatchSelectWidth={false}
|
||||
value={signal}
|
||||
options={DYNAMIC_SIGNALS.map((s) => ({
|
||||
@@ -122,6 +105,28 @@ function DynamicVariableFields({
|
||||
data-testid="variable-signal-select"
|
||||
/>
|
||||
</div>
|
||||
<div className={cx(styles.row, styles.sortSection)}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>Attribute</Typography.Text>
|
||||
</div>
|
||||
<CustomSelect
|
||||
className={styles.searchSelect}
|
||||
showSearch
|
||||
value={attribute || undefined}
|
||||
placeholder="Select a telemetry field"
|
||||
loading={isLoading}
|
||||
options={options}
|
||||
onSearch={setSearch}
|
||||
onChange={(value): void => onChange({ dynamicAttribute: value as string })}
|
||||
noDataMessage="No fields found"
|
||||
errorMessage={errorMessage}
|
||||
onRetry={(): void => {
|
||||
void refetch();
|
||||
}}
|
||||
showRetryButton={error ? isRetryableError(error) : true}
|
||||
data-testid="variable-field-select"
|
||||
/>
|
||||
</div>
|
||||
{attributeError ? (
|
||||
<Typography.Text className={styles.errorText}>
|
||||
{attributeError}
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 24px;
|
||||
padding: 4px 16px !important;
|
||||
padding: 6px 14px;
|
||||
white-space: nowrap;
|
||||
border-radius: 0;
|
||||
color: var(--l2-foreground);
|
||||
@@ -157,7 +157,6 @@
|
||||
|
||||
.betaTag {
|
||||
margin-left: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Query */
|
||||
@@ -265,7 +264,6 @@
|
||||
flex-flow: wrap;
|
||||
gap: 8px;
|
||||
padding: 4.5px 11px;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -330,54 +328,16 @@
|
||||
|
||||
/* All variable selects (Source / Attribute / Sort / Default Value) share width
|
||||
and a consistent --l2-border outline. */
|
||||
.sortSelect,
|
||||
.searchSelect,
|
||||
.dynamicFieldSelect,
|
||||
.dynamicSourceSelect {
|
||||
:global(.ant-select-selector) {
|
||||
border-color: var(--l2-border) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.sortSelect,
|
||||
.searchSelect {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
|
||||
// The clear (X) is an SVG, so antd's font-glyph offset mis-centers it — center
|
||||
// it on the row and make it clearly visible.
|
||||
:global(.ant-select-clear) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 0;
|
||||
transform: translateY(-50%);
|
||||
color: var(--l2-foreground);
|
||||
opacity: 1;
|
||||
:global(.ant-select-selector) {
|
||||
border-color: var(--l2-border) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dynamic variable: field | "from" | info | source, retained from V1. */
|
||||
.dynamicCombinedRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 16px 180px;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dynamicFieldSelect,
|
||||
.dynamicSourceSelect {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fromText {
|
||||
font-family: 'Space Mono';
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Check, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
@@ -7,7 +6,6 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- TextArea/Collapse: no @signozhq/ui equivalent
|
||||
import { Collapse, Input as AntdInput } from 'antd';
|
||||
import { CustomMultiSelect } from 'components/NewSelect';
|
||||
|
||||
import type { VariableType } from '../variableFormModel';
|
||||
import DynamicVariableFields from './DynamicVariableFields';
|
||||
@@ -32,19 +30,9 @@ function VariableForm({
|
||||
siblings,
|
||||
isNew,
|
||||
isSaving,
|
||||
panelOptions,
|
||||
appliedPanelIds,
|
||||
onClose,
|
||||
onSave,
|
||||
}: VariableFormProps): JSX.Element {
|
||||
// The "apply to panels" selection is transient form state, seeded from the
|
||||
// panels that already reference this variable. Held in a ref so the save
|
||||
// callback (owned by useVariableForm) reads the latest value.
|
||||
const [selectedPanelIds, setSelectedPanelIds] =
|
||||
useState<string[]>(appliedPanelIds);
|
||||
const selectedPanelIdsRef = useRef(selectedPanelIds);
|
||||
selectedPanelIdsRef.current = selectedPanelIds;
|
||||
|
||||
const {
|
||||
model,
|
||||
set,
|
||||
@@ -66,12 +54,7 @@ function VariableForm({
|
||||
showAllOptionField,
|
||||
payloadVariables,
|
||||
handleSave,
|
||||
} = useVariableForm({
|
||||
initial,
|
||||
siblings,
|
||||
isNew,
|
||||
onSave: (next): void => onSave(next, selectedPanelIdsRef.current),
|
||||
});
|
||||
} = useVariableForm({ initial, siblings, isNew, onSave });
|
||||
|
||||
// Shared list rows (preview/sort/multi/default) for the list-type variables;
|
||||
// rendered as a sibling inside each list-type panel. Only the active panel
|
||||
@@ -118,20 +101,6 @@ function VariableForm({
|
||||
attributeError={attributeError}
|
||||
/>
|
||||
{listFields}
|
||||
<div className={styles.row}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>
|
||||
Apply to panels
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<CustomMultiSelect
|
||||
placeholder="Select panels"
|
||||
options={panelOptions}
|
||||
value={selectedPanelIds}
|
||||
onChange={(value): void => setSelectedPanelIds(value as string[])}
|
||||
data-testid="variable-apply-panels"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
@@ -46,12 +46,8 @@ export interface UseVariableForm {
|
||||
handleSave: () => void;
|
||||
}
|
||||
|
||||
// `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 ?? '');
|
||||
};
|
||||
const readDefaultValue = (model: VariableFormModel): string =>
|
||||
((model.defaultValue as { value?: string })?.value ?? '') as string;
|
||||
|
||||
/** Form state, derivations and handlers for the variable editor. */
|
||||
export function useVariableForm({
|
||||
|
||||
@@ -3,13 +3,18 @@ import { Check, GripVertical, PenLine, Trash2, X } from '@signozhq/icons';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
import type { VariableFormModel } from './variableFormModel';
|
||||
import styles from './Variables.module.scss';
|
||||
|
||||
const TYPE_LABEL: Record<VariableFormModel['type'], string> = {
|
||||
QUERY: 'Query',
|
||||
CUSTOM: 'Custom',
|
||||
TEXT: 'Text',
|
||||
DYNAMIC: 'Dynamic',
|
||||
};
|
||||
|
||||
interface VariableRowProps {
|
||||
variable: VariableFormModel;
|
||||
index: number;
|
||||
@@ -20,11 +25,9 @@ interface VariableRowProps {
|
||||
onRequestDelete: (index: number) => void;
|
||||
onConfirmDelete: (index: number) => void;
|
||||
onCancelDelete: () => void;
|
||||
/** Apply this variable's filter to all panels. Dynamic variables only. */
|
||||
onApplyToAll: (index: number) => void;
|
||||
}
|
||||
|
||||
/** A single draggable variable row in the two-column (name / description) table. */
|
||||
/** A single draggable variable row (drag handle + meta + inline actions). */
|
||||
function VariableRow({
|
||||
variable,
|
||||
index,
|
||||
@@ -34,7 +37,6 @@ function VariableRow({
|
||||
onRequestDelete,
|
||||
onConfirmDelete,
|
||||
onCancelDelete,
|
||||
onApplyToAll,
|
||||
}: VariableRowProps): JSX.Element {
|
||||
const {
|
||||
attributes,
|
||||
@@ -59,7 +61,7 @@ function VariableRow({
|
||||
className={styles.row}
|
||||
data-testid={`variable-row-${variable.name}`}
|
||||
>
|
||||
<div className={styles.varCell}>
|
||||
<div className={styles.rowMain}>
|
||||
{canEdit ? (
|
||||
<span
|
||||
ref={setActivatorNodeRef}
|
||||
@@ -72,94 +74,65 @@ function VariableRow({
|
||||
</span>
|
||||
) : null}
|
||||
<Typography.Text className={styles.varName}>
|
||||
{variable.name}
|
||||
${variable.name}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div className={styles.descCell}>
|
||||
<span className={styles.typeTag}>{TYPE_LABEL[variable.type]}</span>
|
||||
{variable.description ? (
|
||||
<Typography.Text className={styles.varDesc}>
|
||||
{variable.description}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<span className={styles.varDescEmpty}>—</span>
|
||||
)}
|
||||
|
||||
{canEdit ? (
|
||||
<div
|
||||
className={cx(styles.rowActions, {
|
||||
[styles.rowActionsVisible]: isConfirmingDelete,
|
||||
})}
|
||||
>
|
||||
{isConfirmingDelete ? (
|
||||
<>
|
||||
<Typography.Text className={styles.confirmText}>
|
||||
Delete?
|
||||
</Typography.Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
size="icon"
|
||||
onClick={(): void => onConfirmDelete(index)}
|
||||
aria-label="Confirm delete"
|
||||
testId={`variable-delete-confirm-${variable.name}`}
|
||||
>
|
||||
<Check size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={onCancelDelete}
|
||||
aria-label="Cancel delete"
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{variable.type === 'DYNAMIC' ? (
|
||||
<TooltipSimple
|
||||
side="top"
|
||||
title="Add this variable as a filter to every panel"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className={styles.applyAllButton}
|
||||
onClick={(): void => onApplyToAll(index)}
|
||||
testId={`variable-apply-all-${variable.name}`}
|
||||
>
|
||||
Apply to all
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={(): void => onEdit(index)}
|
||||
aria-label="Edit variable"
|
||||
testId={`variable-edit-${variable.name}`}
|
||||
>
|
||||
<PenLine size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={(): void => onRequestDelete(index)}
|
||||
aria-label="Delete variable"
|
||||
testId={`variable-delete-${variable.name}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{canEdit && isConfirmingDelete ? (
|
||||
<div className={styles.rowActions}>
|
||||
<Typography.Text className={styles.confirmText}>Delete?</Typography.Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
size="icon"
|
||||
onClick={(): void => onConfirmDelete(index)}
|
||||
aria-label="Confirm delete"
|
||||
testId={`variable-delete-confirm-${variable.name}`}
|
||||
>
|
||||
<Check size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={onCancelDelete}
|
||||
aria-label="Cancel delete"
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canEdit && !isConfirmingDelete ? (
|
||||
<div className={styles.rowActions}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={(): void => onEdit(index)}
|
||||
aria-label="Edit variable"
|
||||
testId={`variable-edit-${variable.name}`}
|
||||
>
|
||||
<PenLine size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={(): void => onRequestDelete(index)}
|
||||
aria-label="Delete variable"
|
||||
testId={`variable-delete-${variable.name}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
@@ -28,125 +28,69 @@
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
// Two-column (Variable / Description) table matching the V1 variables listing.
|
||||
$grid-columns: minmax(0, 2fr) minmax(0, 3fr);
|
||||
|
||||
.table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.headerRow {
|
||||
display: grid;
|
||||
grid-template-columns: $grid-columns;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.headerCell {
|
||||
padding: 12px 0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--l1-foreground);
|
||||
|
||||
&:first-child {
|
||||
padding-right: 16px;
|
||||
border-right: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
padding-left: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: $grid-columns;
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
background: var(--l1-background);
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
}
|
||||
|
||||
.varCell {
|
||||
.rowMain {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
padding: 12px 16px 12px 0;
|
||||
border-right: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.dragHandle {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
margin-top: 1px;
|
||||
color: var(--l3-foreground);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.varName {
|
||||
min-width: 0;
|
||||
font-family: 'Space Mono', monospace;
|
||||
font-size: 13px;
|
||||
color: var(--bg-robin-400);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.descCell {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
padding: 12px 0 12px 16px;
|
||||
font-weight: 500;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.varDesc {
|
||||
font-family: 'Space Mono', monospace;
|
||||
font-size: 13px;
|
||||
color: var(--bg-sienna-400);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.varDescEmpty {
|
||||
color: var(--l3-foreground);
|
||||
.typeTag {
|
||||
flex-shrink: 0;
|
||||
padding: 1px 8px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--l2-foreground);
|
||||
text-transform: uppercase;
|
||||
background: var(--l2-background);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding-left: 16px;
|
||||
background: var(--l2-background);
|
||||
opacity: 0;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.row:hover .rowActions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.rowActionsVisible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.confirmText {
|
||||
@@ -154,8 +98,3 @@ $grid-columns: minmax(0, 2fr) minmax(0, 3fr);
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.applyAllButton {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ interface VariablesListProps {
|
||||
onConfirmDelete: (index: number) => void;
|
||||
onCancelDelete: () => void;
|
||||
onMove: (from: number, to: number) => void;
|
||||
onApplyToAll: (index: number) => void;
|
||||
}
|
||||
|
||||
function VariablesList({
|
||||
@@ -37,7 +36,6 @@ function VariablesList({
|
||||
onConfirmDelete,
|
||||
onCancelDelete,
|
||||
onMove,
|
||||
onApplyToAll,
|
||||
}: VariablesListProps): JSX.Element {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 1 } }),
|
||||
@@ -55,39 +53,32 @@ function VariablesList({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.table} data-testid="variables-list">
|
||||
<div className={styles.headerRow}>
|
||||
<span className={styles.headerCell}>Variable</span>
|
||||
<span className={styles.headerCell}>Description</span>
|
||||
</div>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
onDragEnd={handleDragEnd}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={variables.map((v) => v.name)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<SortableContext
|
||||
items={variables.map((v) => v.name)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className={styles.list}>
|
||||
{variables.map((variable, index) => (
|
||||
<VariableRow
|
||||
key={variable.name || `variable-${index}`}
|
||||
variable={variable}
|
||||
index={index}
|
||||
canEdit={canEdit}
|
||||
isConfirmingDelete={confirmingIndex === index}
|
||||
onEdit={onEdit}
|
||||
onRequestDelete={onRequestDelete}
|
||||
onConfirmDelete={onConfirmDelete}
|
||||
onCancelDelete={onCancelDelete}
|
||||
onApplyToAll={onApplyToAll}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
<div className={styles.list} data-testid="variables-list">
|
||||
{variables.map((variable, index) => (
|
||||
<VariableRow
|
||||
key={variable.name || `variable-${index}`}
|
||||
variable={variable}
|
||||
index={index}
|
||||
canEdit={canEdit}
|
||||
isConfirmingDelete={confirmingIndex === index}
|
||||
onEdit={onEdit}
|
||||
onRequestDelete={onRequestDelete}
|
||||
onConfirmDelete={onConfirmDelete}
|
||||
onCancelDelete={onCancelDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
import type {
|
||||
DashboardtypesDashboardSpecDTOPanels,
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
buildApplyVariableToPanelsPatch,
|
||||
buildSyncVariableToPanelsPatch,
|
||||
getPanelIdsReferencingVariable,
|
||||
} from '../applyVariableToPanelsPatch';
|
||||
|
||||
/** Minimal builder-query panel wrapped in a CompositeQuery, with a given filter. */
|
||||
function compositePanel(filterExpression: string): unknown {
|
||||
return {
|
||||
kind: 'panel',
|
||||
spec: {
|
||||
display: { title: '' },
|
||||
plugin: { kind: 'signoz/TimeSeries', spec: {} },
|
||||
queries: [
|
||||
{
|
||||
kind: 'builder_query',
|
||||
spec: {
|
||||
plugin: {
|
||||
kind: 'signoz/CompositeQuery',
|
||||
spec: {
|
||||
queries: [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: 'logs',
|
||||
filter: { expression: filterExpression },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A bare LIST-style BuilderQuery panel (filter lives directly on the plugin spec). */
|
||||
function listPanel(filterExpression: string): unknown {
|
||||
return {
|
||||
kind: 'panel',
|
||||
spec: {
|
||||
display: { title: '' },
|
||||
plugin: { kind: 'signoz/List', spec: {} },
|
||||
queries: [
|
||||
{
|
||||
kind: 'builder_query',
|
||||
spec: {
|
||||
plugin: {
|
||||
kind: 'signoz/BuilderQuery',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: 'logs',
|
||||
filter: { expression: filterExpression },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A PromQL panel — no builder filter, must be skipped. */
|
||||
function promqlPanel(): unknown {
|
||||
return {
|
||||
kind: 'panel',
|
||||
spec: {
|
||||
display: { title: '' },
|
||||
plugin: { kind: 'signoz/TimeSeries', spec: {} },
|
||||
queries: [
|
||||
{
|
||||
kind: 'promql',
|
||||
spec: {
|
||||
plugin: { kind: 'signoz/PromQLQuery', spec: { query: 'up' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Reads the first builder query's filter expression out of a patch op's value. */
|
||||
function expressionOf(
|
||||
op: DashboardtypesJSONPatchOperationDTO,
|
||||
): string | undefined {
|
||||
const queries = op.value as Array<{
|
||||
spec: { plugin: { kind: string; spec: any } };
|
||||
}>;
|
||||
const { plugin } = queries[0].spec;
|
||||
const builderSpec =
|
||||
plugin.kind === 'signoz/CompositeQuery'
|
||||
? plugin.spec.queries[0].spec
|
||||
: plugin.spec;
|
||||
return builderSpec.filter?.expression;
|
||||
}
|
||||
|
||||
function panels(
|
||||
map: Record<string, unknown>,
|
||||
): DashboardtypesDashboardSpecDTOPanels {
|
||||
return map as DashboardtypesDashboardSpecDTOPanels;
|
||||
}
|
||||
|
||||
describe('buildApplyVariableToPanelsPatch', () => {
|
||||
it('appends the clause to an existing filter (AND-joined)', () => {
|
||||
const ops = buildApplyVariableToPanelsPatch(
|
||||
panels({ p1: compositePanel('env = "prod"') }),
|
||||
'service.name',
|
||||
'svc',
|
||||
);
|
||||
expect(ops).toHaveLength(1);
|
||||
expect(ops[0]).toMatchObject({
|
||||
op: 'replace',
|
||||
path: '/spec/panels/p1/spec/queries',
|
||||
});
|
||||
expect(expressionOf(ops[0])).toBe('env = "prod" AND service.name IN $svc');
|
||||
});
|
||||
|
||||
it('sets the clause when the filter is empty', () => {
|
||||
const ops = buildApplyVariableToPanelsPatch(
|
||||
panels({ p1: compositePanel('') }),
|
||||
'service.name',
|
||||
'svc',
|
||||
);
|
||||
expect(expressionOf(ops[0])).toBe('service.name IN $svc');
|
||||
});
|
||||
|
||||
it('applies to a bare BuilderQuery (LIST) panel', () => {
|
||||
const ops = buildApplyVariableToPanelsPatch(
|
||||
panels({ p1: listPanel('') }),
|
||||
'k8s.pod.name',
|
||||
'pod',
|
||||
);
|
||||
expect(expressionOf(ops[0])).toBe('k8s.pod.name IN $pod');
|
||||
});
|
||||
|
||||
it('is idempotent — re-applying does not duplicate the clause', () => {
|
||||
const ops = buildApplyVariableToPanelsPatch(
|
||||
panels({ p1: compositePanel('service.name IN $svc') }),
|
||||
'service.name',
|
||||
'svc',
|
||||
);
|
||||
expect(ops).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('only targets the requested panels', () => {
|
||||
const ops = buildApplyVariableToPanelsPatch(
|
||||
panels({ p1: compositePanel(''), p2: compositePanel('') }),
|
||||
'service.name',
|
||||
'svc',
|
||||
['p2'],
|
||||
);
|
||||
expect(ops).toHaveLength(1);
|
||||
expect(ops[0].path).toBe('/spec/panels/p2/spec/queries');
|
||||
});
|
||||
|
||||
it('skips PromQL panels (no builder filter)', () => {
|
||||
const ops = buildApplyVariableToPanelsPatch(
|
||||
panels({ p1: promqlPanel() }),
|
||||
'service.name',
|
||||
'svc',
|
||||
);
|
||||
expect(ops).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns nothing when attribute or name is missing', () => {
|
||||
expect(
|
||||
buildApplyVariableToPanelsPatch(
|
||||
panels({ p1: compositePanel('') }),
|
||||
'',
|
||||
'svc',
|
||||
),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSyncVariableToPanelsPatch', () => {
|
||||
it('adds to selected panels and removes from the rest', () => {
|
||||
const ops = buildSyncVariableToPanelsPatch(
|
||||
panels({
|
||||
p1: compositePanel('service.name IN $svc'), // has it, not selected → remove
|
||||
p2: compositePanel(''), // selected → add
|
||||
p3: compositePanel('service.name IN $svc'), // has it, selected → unchanged
|
||||
}),
|
||||
'service.name',
|
||||
'svc',
|
||||
['p2', 'p3'],
|
||||
);
|
||||
const byPath = Object.fromEntries(ops.map((op) => [op.path, op]));
|
||||
expect(Object.keys(byPath).sort()).toStrictEqual([
|
||||
'/spec/panels/p1/spec/queries',
|
||||
'/spec/panels/p2/spec/queries',
|
||||
]);
|
||||
expect(expressionOf(byPath['/spec/panels/p1/spec/queries'])).toBe('');
|
||||
expect(expressionOf(byPath['/spec/panels/p2/spec/queries'])).toBe(
|
||||
'service.name IN $svc',
|
||||
);
|
||||
});
|
||||
|
||||
it('removing keeps other clauses intact', () => {
|
||||
const ops = buildSyncVariableToPanelsPatch(
|
||||
panels({ p1: compositePanel('env = "prod" AND service.name IN $svc') }),
|
||||
'service.name',
|
||||
'svc',
|
||||
[],
|
||||
);
|
||||
expect(expressionOf(ops[0])).toBe('env = "prod"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPanelIdsReferencingVariable', () => {
|
||||
it('returns only panels whose filter references the variable', () => {
|
||||
const ids = getPanelIdsReferencingVariable(
|
||||
panels({
|
||||
p1: compositePanel('service.name IN $svc'),
|
||||
p2: compositePanel('env = "prod"'),
|
||||
p3: listPanel('service.name IN $svc'),
|
||||
}),
|
||||
'service.name',
|
||||
'svc',
|
||||
);
|
||||
expect(ids.sort()).toStrictEqual(['p1', 'p3']);
|
||||
});
|
||||
});
|
||||
@@ -1,179 +0,0 @@
|
||||
import type {
|
||||
DashboardtypesDashboardSpecDTOPanels,
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
DashboardtypesQueryDTO,
|
||||
Querybuildertypesv5BuilderQuerySpecDTO,
|
||||
Querybuildertypesv5CompositeQueryDTO,
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
|
||||
// Injects/removes a dynamic variable's filter (`attribute IN $name`) in panel
|
||||
// builder queries as JSON-Patch ops. Only builder queries carry a filter.
|
||||
|
||||
function clauseFor(attribute: string, variableName: string): string {
|
||||
return `${attribute} IN $${variableName}`;
|
||||
}
|
||||
|
||||
/** Runs `fn` on every builder-query spec in a panel's single query (Composite or bare Builder). */
|
||||
function forEachBuilderSpec(
|
||||
queries: DashboardtypesQueryDTO[],
|
||||
fn: (spec: Querybuildertypesv5BuilderQuerySpecDTO) => void,
|
||||
): void {
|
||||
const plugin = queries[0]?.spec?.plugin;
|
||||
if (!plugin?.spec) {
|
||||
return;
|
||||
}
|
||||
if (plugin.kind === 'signoz/CompositeQuery') {
|
||||
const composite = plugin.spec as Querybuildertypesv5CompositeQueryDTO;
|
||||
(composite.queries ?? [])
|
||||
.filter((envelope) => envelope.type === 'builder_query')
|
||||
.forEach((envelope) => {
|
||||
const { spec } = envelope as Querybuildertypesv5QueryEnvelopeBuilderDTO;
|
||||
if (spec) {
|
||||
fn(spec as Querybuildertypesv5BuilderQuerySpecDTO);
|
||||
}
|
||||
});
|
||||
} else if (plugin.kind === 'signoz/BuilderQuery') {
|
||||
fn(plugin.spec as Querybuildertypesv5BuilderQuerySpecDTO);
|
||||
}
|
||||
}
|
||||
|
||||
/** Appends the clause to every builder query's filter. Returns whether anything changed. */
|
||||
function addClause(queries: DashboardtypesQueryDTO[], clause: string): boolean {
|
||||
let changed = false;
|
||||
forEachBuilderSpec(queries, (spec) => {
|
||||
const existing = spec.filter?.expression?.trim();
|
||||
// Idempotent: a repeated apply must not stack duplicate clauses.
|
||||
if (existing?.includes(clause)) {
|
||||
return;
|
||||
}
|
||||
spec.filter = {
|
||||
expression: existing ? `${existing} AND ${clause}` : clause,
|
||||
};
|
||||
changed = true;
|
||||
});
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** Removes the managed clause (an ` AND `-joined part) from every builder filter. */
|
||||
function removeClause(
|
||||
queries: DashboardtypesQueryDTO[],
|
||||
clause: string,
|
||||
): boolean {
|
||||
let changed = false;
|
||||
forEachBuilderSpec(queries, (spec) => {
|
||||
const existing = spec.filter?.expression;
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
const parts = existing
|
||||
.split(' AND ')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
const kept = parts.filter((part) => part !== clause);
|
||||
if (kept.length !== parts.length) {
|
||||
spec.filter = { expression: kept.join(' AND ') };
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** Whether any builder query in the panel already carries the clause. */
|
||||
function panelHasClause(
|
||||
queries: DashboardtypesQueryDTO[],
|
||||
clause: string,
|
||||
): boolean {
|
||||
let has = false;
|
||||
forEachBuilderSpec(queries, (spec) => {
|
||||
if (spec.filter?.expression?.includes(clause)) {
|
||||
has = true;
|
||||
}
|
||||
});
|
||||
return has;
|
||||
}
|
||||
|
||||
function replaceQueriesOp(
|
||||
panelId: string,
|
||||
queries: DashboardtypesQueryDTO[],
|
||||
): DashboardtypesJSONPatchOperationDTO {
|
||||
return {
|
||||
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
|
||||
path: `/spec/panels/${panelId}/spec/queries`,
|
||||
value: queries,
|
||||
};
|
||||
}
|
||||
|
||||
/** Add-only: inject the variable's filter into the given panels (default: all). */
|
||||
export function buildApplyVariableToPanelsPatch(
|
||||
panels: DashboardtypesDashboardSpecDTOPanels,
|
||||
attribute: string,
|
||||
variableName: string,
|
||||
targetPanelIds?: string[],
|
||||
): DashboardtypesJSONPatchOperationDTO[] {
|
||||
if (!attribute || !variableName) {
|
||||
return [];
|
||||
}
|
||||
const clause = clauseFor(attribute, variableName);
|
||||
const ids = targetPanelIds ?? Object.keys(panels);
|
||||
|
||||
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
|
||||
ids.forEach((id) => {
|
||||
const panel = panels[id];
|
||||
if (!panel?.spec?.queries?.length) {
|
||||
return;
|
||||
}
|
||||
const queries = cloneDeep(panel.spec.queries);
|
||||
if (addClause(queries, clause)) {
|
||||
ops.push(replaceQueriesOp(id, queries));
|
||||
}
|
||||
});
|
||||
return ops;
|
||||
}
|
||||
|
||||
/** Full sync: selected panels get the clause; every other panel has it removed. */
|
||||
export function buildSyncVariableToPanelsPatch(
|
||||
panels: DashboardtypesDashboardSpecDTOPanels,
|
||||
attribute: string,
|
||||
variableName: string,
|
||||
selectedPanelIds: string[],
|
||||
): DashboardtypesJSONPatchOperationDTO[] {
|
||||
if (!attribute || !variableName) {
|
||||
return [];
|
||||
}
|
||||
const clause = clauseFor(attribute, variableName);
|
||||
const selected = new Set(selectedPanelIds);
|
||||
|
||||
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
|
||||
Object.keys(panels).forEach((id) => {
|
||||
const panel = panels[id];
|
||||
if (!panel?.spec?.queries?.length) {
|
||||
return;
|
||||
}
|
||||
const queries = cloneDeep(panel.spec.queries);
|
||||
const changed = selected.has(id)
|
||||
? addClause(queries, clause)
|
||||
: removeClause(queries, clause);
|
||||
if (changed) {
|
||||
ops.push(replaceQueriesOp(id, queries));
|
||||
}
|
||||
});
|
||||
return ops;
|
||||
}
|
||||
|
||||
/** Panel ids whose queries currently reference the variable — pre-populates the picker. */
|
||||
export function getPanelIdsReferencingVariable(
|
||||
panels: DashboardtypesDashboardSpecDTOPanels,
|
||||
attribute: string,
|
||||
variableName: string,
|
||||
): string[] {
|
||||
if (!attribute || !variableName) {
|
||||
return [];
|
||||
}
|
||||
const clause = clauseFor(attribute, variableName);
|
||||
return Object.keys(panels).filter((id) => {
|
||||
const queries = panels[id]?.spec?.queries;
|
||||
return queries?.length ? panelHasClause(queries, clause) : false;
|
||||
});
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
.body {
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.variableName {
|
||||
font-family: 'Space Mono', monospace;
|
||||
color: var(--bg-robin-400);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { Check, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
|
||||
import styles from './ApplyToAllDialog.module.scss';
|
||||
|
||||
interface ApplyToAllDialogProps {
|
||||
open: boolean;
|
||||
variableName: string;
|
||||
isLoading: boolean;
|
||||
onConfirm: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** Confirms applying a dynamic variable as a filter to every panel. */
|
||||
function ApplyToAllDialog({
|
||||
open,
|
||||
variableName,
|
||||
isLoading,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: ApplyToAllDialogProps): JSX.Element {
|
||||
const footer = (
|
||||
<div className={styles.footer}>
|
||||
<Button variant="solid" color="secondary" onClick={onClose}>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
loading={isLoading}
|
||||
onClick={onConfirm}
|
||||
testId="confirm-apply-to-all"
|
||||
>
|
||||
<Check size={12} />
|
||||
Apply to all
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title="Apply variable to all panels"
|
||||
width="narrow"
|
||||
showCloseButton={false}
|
||||
// Lift above the settings drawer (z ~1000); overlay off (it would only half-dim).
|
||||
style={{ zIndex: 1100 }}
|
||||
showOverlay={false}
|
||||
footer={footer}
|
||||
>
|
||||
<div className={styles.body}>
|
||||
Add <span className={styles.variableName}>${variableName}</span> as a filter
|
||||
to every panel on this dashboard. Panels that already reference it are left
|
||||
unchanged.
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default ApplyToAllDialog;
|
||||
@@ -4,7 +4,6 @@ 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 {
|
||||
@@ -31,7 +30,6 @@ 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"
|
||||
/>
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
|
||||
import settingsStyles from '../DashboardSettings.module.scss';
|
||||
import { useOptimisticPatch } from '../../hooks/useOptimisticPatch';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import {
|
||||
buildApplyVariableToPanelsPatch,
|
||||
buildSyncVariableToPanelsPatch,
|
||||
getPanelIdsReferencingVariable,
|
||||
} from './applyVariableToPanelsPatch';
|
||||
import { useSaveVariables } from './useSaveVariables';
|
||||
import { dtoToFormModel } from './variableAdapters';
|
||||
import {
|
||||
@@ -21,7 +14,6 @@ import VariableForm from './VariableForm/VariableForm';
|
||||
import VariablesList from './VariablesList';
|
||||
import styles from './Variables.module.scss';
|
||||
import AddVariableButton from './components/AddVariableButton';
|
||||
import ApplyToAllDialog from './components/ApplyToAllDialog/ApplyToAllDialog';
|
||||
import NoVariablesCard from './components/NoVariablesCard/NoVariablesCard';
|
||||
import { EditingState } from './types';
|
||||
|
||||
@@ -31,13 +23,7 @@ interface VariablesSettingsProps {
|
||||
|
||||
function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
// The drawer destroys on close, so reading this once on mount is enough to
|
||||
// open the add-form when deep-linked (e.g. the bar's "Add variable" button).
|
||||
const openAddOnMount = useDashboardStore(
|
||||
(s) => s.settingsRequest?.addVariable ?? false,
|
||||
);
|
||||
const { save, isSaving } = useSaveVariables();
|
||||
const { patchAsync, isPatching } = useOptimisticPatch();
|
||||
|
||||
const initialFormModels = useMemo(
|
||||
() => dashboard.spec.variables.map(dtoToFormModel),
|
||||
@@ -52,13 +38,10 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dashboard.updatedAt]);
|
||||
|
||||
const [isEditing, setIsEditing] = useState<EditingState>(
|
||||
openAddOnMount && isEditable ? { type: 'new' } : null,
|
||||
);
|
||||
const [isEditing, setIsEditing] = useState<EditingState>(null);
|
||||
const [confirmDeleteIndex, setConfirmDeleteIndex] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [applyToAllIndex, setApplyToAllIndex] = useState<number | null>(null);
|
||||
|
||||
const editingFormModel: VariableFormModel | null = useMemo(() => {
|
||||
if (!isEditing) {
|
||||
@@ -74,64 +57,20 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
return variables.filter((_, i) => i !== self);
|
||||
}, [variables, isEditing]);
|
||||
|
||||
const panelOptions = useMemo(
|
||||
() =>
|
||||
Object.entries(dashboard.spec.panels ?? {}).map(([id, panel]) => ({
|
||||
value: id,
|
||||
label: panel.spec?.display?.name || id,
|
||||
})),
|
||||
[dashboard.spec.panels],
|
||||
);
|
||||
|
||||
// Panels the edited variable is already applied to — pre-checks the picker.
|
||||
const appliedPanelIds = useMemo(() => {
|
||||
if (!editingFormModel || editingFormModel.type !== 'DYNAMIC') {
|
||||
return [];
|
||||
}
|
||||
return getPanelIdsReferencingVariable(
|
||||
dashboard.spec.panels,
|
||||
editingFormModel.dynamicAttribute,
|
||||
editingFormModel.name,
|
||||
);
|
||||
}, [editingFormModel, dashboard.spec.panels]);
|
||||
|
||||
const persist = (next: VariableFormModel[]): void => {
|
||||
setVariables(next);
|
||||
void save(next);
|
||||
};
|
||||
|
||||
const handleFormSave = (
|
||||
formModel: VariableFormModel,
|
||||
selectedPanelIds: string[],
|
||||
): void => {
|
||||
const handleFormSave = (Formmodel: VariableFormModel): void => {
|
||||
const next = [...variables];
|
||||
if (isEditing?.type === 'new') {
|
||||
next.push(formModel);
|
||||
next.push(Formmodel);
|
||||
} else if (isEditing?.type === 'edit') {
|
||||
next[isEditing.index] = formModel;
|
||||
next[isEditing.index] = Formmodel;
|
||||
}
|
||||
setIsEditing(null);
|
||||
setVariables(next);
|
||||
void (async (): Promise<void> => {
|
||||
const saved = await save(next);
|
||||
if (!saved || formModel.type !== 'DYNAMIC') {
|
||||
return;
|
||||
}
|
||||
const ops = buildSyncVariableToPanelsPatch(
|
||||
dashboard.spec.panels,
|
||||
formModel.dynamicAttribute,
|
||||
formModel.name,
|
||||
selectedPanelIds,
|
||||
);
|
||||
if (ops.length === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await patchAsync(ops);
|
||||
} catch {
|
||||
toast.error('Could not update panels');
|
||||
}
|
||||
})();
|
||||
persist(next);
|
||||
};
|
||||
|
||||
const handleMove = (from: number, to: number): void => {
|
||||
@@ -149,32 +88,6 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
setConfirmDeleteIndex(null);
|
||||
};
|
||||
|
||||
const applyToAllVariable =
|
||||
applyToAllIndex === null ? null : variables[applyToAllIndex];
|
||||
|
||||
const handleConfirmApplyToAll = async (): Promise<void> => {
|
||||
if (!applyToAllVariable) {
|
||||
return;
|
||||
}
|
||||
const ops = buildApplyVariableToPanelsPatch(
|
||||
dashboard.spec.panels,
|
||||
applyToAllVariable.dynamicAttribute,
|
||||
applyToAllVariable.name,
|
||||
);
|
||||
if (ops.length === 0) {
|
||||
toast.info('No panels needed this filter.');
|
||||
setApplyToAllIndex(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await patchAsync(ops);
|
||||
toast.success(`Applied $${applyToAllVariable.name} to all panels`);
|
||||
} catch {
|
||||
toast.error('Could not apply the variable to panels');
|
||||
}
|
||||
setApplyToAllIndex(null);
|
||||
};
|
||||
|
||||
if (editingFormModel) {
|
||||
return (
|
||||
<VariableForm
|
||||
@@ -182,8 +95,6 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
siblings={siblings}
|
||||
isNew={isEditing?.type === 'new'}
|
||||
isSaving={isSaving}
|
||||
panelOptions={panelOptions}
|
||||
appliedPanelIds={appliedPanelIds}
|
||||
onClose={(): void => setIsEditing(null)}
|
||||
onSave={handleFormSave}
|
||||
/>
|
||||
@@ -197,6 +108,9 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
<NoVariablesCard isEditable={isEditable} setIsEditing={setIsEditing} />
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.header}>
|
||||
<AddVariableButton isEditable={isEditable} setIsEditing={setIsEditing} />
|
||||
</div>
|
||||
<VariablesList
|
||||
variables={variables}
|
||||
canEdit={isEditable}
|
||||
@@ -206,20 +120,9 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
onConfirmDelete={handleConfirmDelete}
|
||||
onCancelDelete={(): void => setConfirmDeleteIndex(null)}
|
||||
onMove={handleMove}
|
||||
onApplyToAll={(index): void => setApplyToAllIndex(index)}
|
||||
/>
|
||||
<div className={styles.footer}>
|
||||
<AddVariableButton isEditable={isEditable} setIsEditing={setIsEditing} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<ApplyToAllDialog
|
||||
open={applyToAllVariable !== null}
|
||||
variableName={applyToAllVariable?.name ?? ''}
|
||||
isLoading={isPatching}
|
||||
onConfirm={(): void => void handleConfirmApplyToAll()}
|
||||
onClose={(): void => setApplyToAllIndex(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,11 +6,6 @@ export type EditingState =
|
||||
| { type: 'edit'; index: number }
|
||||
| null;
|
||||
|
||||
export interface PanelOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface VariableFormProps {
|
||||
initial: VariableFormModel;
|
||||
/** The other variables (excluding this one), for uniqueness & cycle checks. */
|
||||
@@ -18,10 +13,6 @@ export interface VariableFormProps {
|
||||
/** True when adding a new variable (enables auto-naming from the attribute). */
|
||||
isNew: boolean;
|
||||
isSaving: boolean;
|
||||
/** All panels, for the dynamic "apply to panels" picker. */
|
||||
panelOptions: PanelOption[];
|
||||
/** Panels this variable is already applied to — pre-checks the picker. */
|
||||
appliedPanelIds: string[];
|
||||
onClose: () => void;
|
||||
onSave: (model: VariableFormModel, selectedPanelIds: string[]) => void;
|
||||
onSave: (model: VariableFormModel) => void;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import type {
|
||||
|
||||
import {
|
||||
DYNAMIC_SIGNAL_ALL,
|
||||
DYNAMIC_SIGNALS,
|
||||
type DynamicSignalOption,
|
||||
emptyVariableFormModel,
|
||||
signalForApi,
|
||||
@@ -69,12 +68,9 @@ export function dtoToFormModel(
|
||||
...listCommon,
|
||||
type: 'DYNAMIC',
|
||||
dynamicAttribute: plugin.spec.name ?? '',
|
||||
// Unrecognized/empty signal → "all telemetry", so the source always shows.
|
||||
dynamicSignal: DYNAMIC_SIGNALS.includes(
|
||||
plugin.spec.signal as DynamicSignalOption,
|
||||
)
|
||||
? (plugin.spec.signal as DynamicSignalOption)
|
||||
: DYNAMIC_SIGNAL_ALL,
|
||||
// An omitted wire signal means "all telemetry".
|
||||
dynamicSignal:
|
||||
(plugin.spec.signal as DynamicSignalOption) ?? DYNAMIC_SIGNAL_ALL,
|
||||
};
|
||||
}
|
||||
// Default to Query (also covers a query plugin or a missing/unknown plugin).
|
||||
|
||||
@@ -17,7 +17,6 @@ import { useAppContext } from 'providers/App/App';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import styles from './DashboardSettings.module.scss';
|
||||
|
||||
interface DashboardSettingsProps {
|
||||
@@ -39,9 +38,6 @@ const prefixIcons: Record<TabKeys, JSX.Element> = {
|
||||
function DashboardSettings({ dashboard }: DashboardSettingsProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
|
||||
// Opened once per drawer mount (the drawer destroys on close); a deep-link
|
||||
// request lands us on the right tab.
|
||||
const settingsRequest = useDashboardStore((s) => s.settingsRequest);
|
||||
|
||||
const enablePublicDashboard = isCloudUser || isEnterpriseSelfHostedUser;
|
||||
|
||||
@@ -73,7 +69,7 @@ function DashboardSettings({ dashboard }: DashboardSettingsProps): JSX.Element {
|
||||
);
|
||||
|
||||
return (
|
||||
<TabsRoot defaultValue={settingsRequest?.tab ?? TabKeys.OVERVIEW}>
|
||||
<TabsRoot defaultValue={TabKeys.OVERVIEW}>
|
||||
<TabsList variant="primary">
|
||||
{Object.values(TabKeys).map((key) => (
|
||||
<TabsTrigger value={key} key={key}>
|
||||
|
||||
@@ -14,7 +14,6 @@ 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 {
|
||||
@@ -87,7 +86,6 @@ 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>
|
||||
|
||||
@@ -103,7 +103,6 @@ function PreviewPane({
|
||||
<PanelHeader
|
||||
panelId={panelId}
|
||||
panel={panel}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
warning={data.response?.data?.warning}
|
||||
|
||||
@@ -1,60 +1,37 @@
|
||||
import { CalendarRange, Clock, RotateCw } from '@signozhq/icons';
|
||||
import { Clock, RotateCw } from '@signozhq/icons';
|
||||
|
||||
import {
|
||||
selectViewPanelExtendWindow,
|
||||
useViewPanelStore,
|
||||
} from '../../../store/useViewPanelStore';
|
||||
import PanelLoader from '../PanelLoader/PanelLoader';
|
||||
import PanelMessage, { PanelMessageAction } from '../PanelMessage/PanelMessage';
|
||||
import { useExtendTimeWindow } from './useExtendTimeWindow';
|
||||
import PanelMessage from '../PanelMessage/PanelMessage';
|
||||
|
||||
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. 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.
|
||||
* Shared empty-state for panel renderers: wraps `PanelMessage` so every panel
|
||||
* kind surfaces the same "no data" affordance when a query returns nothing.
|
||||
*/
|
||||
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={extendAction ?? retryAction}
|
||||
secondaryAction={extendAction ? retryAction : undefined}
|
||||
action={
|
||||
onRetry
|
||||
? { label: 'Retry', onClick: onRetry, icon: <RotateCw size={14} /> }
|
||||
: undefined
|
||||
}
|
||||
data-testid={testId}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,96 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
// 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);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
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,10 +45,6 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
.action {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
@@ -20,8 +20,6 @@ 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;
|
||||
@@ -36,18 +34,9 @@ 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' })}>
|
||||
@@ -59,22 +48,18 @@ function PanelMessage({
|
||||
{description}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{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>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -43,28 +43,4 @@ 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,7 +35,6 @@ function BarPanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
isFetching,
|
||||
refetch,
|
||||
onClick,
|
||||
onDragSelect,
|
||||
@@ -187,9 +186,7 @@ function BarPanelRenderer({
|
||||
data-testid="bar-panel-renderer"
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{flatSeries.length === 0 && (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
)}
|
||||
{flatSeries.length === 0 && <NoData 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: { csv: false, png: true, svg: true },
|
||||
download: false,
|
||||
createAlert: true,
|
||||
search: false,
|
||||
drilldown: true,
|
||||
|
||||
@@ -25,7 +25,6 @@ function HistogramPanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
isFetching,
|
||||
refetch,
|
||||
panelMode,
|
||||
}: PanelRendererProps<'signoz/HistogramPanel'>): JSX.Element {
|
||||
@@ -105,9 +104,7 @@ function HistogramPanelRenderer({
|
||||
data-testid="histogram-panel-renderer"
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{flatSeries.length === 0 && (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
)}
|
||||
{flatSeries.length === 0 && <NoData 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: { csv: false, png: true, svg: true },
|
||||
download: false,
|
||||
createAlert: true,
|
||||
search: false,
|
||||
drilldown: false,
|
||||
|
||||
@@ -33,7 +33,6 @@ function ListPanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
isFetching,
|
||||
refetch,
|
||||
searchTerm = '',
|
||||
pagination,
|
||||
@@ -143,7 +142,7 @@ function ListPanelRenderer({
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{!table || dataSource.length === 0 ? (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
<NoData onRetry={refetch} />
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
|
||||
@@ -34,7 +34,7 @@ export const definition: PanelDefinition<'signoz/ListPanel'> = {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: { csv: false, png: true, svg: true },
|
||||
download: false,
|
||||
createAlert: false,
|
||||
search: true,
|
||||
drilldown: false,
|
||||
|
||||
@@ -24,7 +24,6 @@ import ValueDisplay from './components/ValueDisplay/ValueDisplay';
|
||||
function NumberPanelRenderer({
|
||||
panel,
|
||||
data,
|
||||
isFetching,
|
||||
refetch,
|
||||
onClick,
|
||||
enableDrillDown,
|
||||
@@ -126,11 +125,7 @@ function NumberPanelRenderer({
|
||||
: {})}
|
||||
>
|
||||
{value === null ? (
|
||||
<NoData
|
||||
data-testid="number-panel-no-data"
|
||||
isFetching={isFetching}
|
||||
onRetry={refetch}
|
||||
/>
|
||||
<NoData data-testid="number-panel-no-data" onRetry={refetch} />
|
||||
) : (
|
||||
<ValueDisplay
|
||||
value={formattedValue}
|
||||
|
||||
@@ -24,7 +24,7 @@ export const definition: PanelDefinition<'signoz/NumberPanel'> = {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: { csv: false, png: true, svg: true },
|
||||
download: false,
|
||||
createAlert: true,
|
||||
search: false,
|
||||
drilldown: true,
|
||||
|
||||
@@ -27,7 +27,6 @@ function PiePanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
isFetching,
|
||||
refetch,
|
||||
onClick,
|
||||
enableDrillDown,
|
||||
@@ -95,7 +94,7 @@ function PiePanelRenderer({
|
||||
return (
|
||||
<div data-testid="pie-panel-renderer" className={PanelStyles.panelContainer}>
|
||||
{slices.length === 0 ? (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
<NoData onRetry={refetch} />
|
||||
) : (
|
||||
<Pie
|
||||
data={slices}
|
||||
|
||||
@@ -20,7 +20,7 @@ export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: { csv: false, png: true, svg: true },
|
||||
download: false,
|
||||
createAlert: false,
|
||||
search: false,
|
||||
drilldown: true,
|
||||
|
||||
@@ -35,7 +35,6 @@ function TablePanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
isFetching,
|
||||
refetch,
|
||||
searchTerm = '',
|
||||
onClick,
|
||||
@@ -160,7 +159,7 @@ function TablePanelRenderer({
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{!table || dataSource.length === 0 ? (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
<NoData onRetry={refetch} />
|
||||
) : (
|
||||
<div className={styles.container}>
|
||||
<Table
|
||||
|
||||
@@ -17,10 +17,7 @@ function panelWith(
|
||||
): PanelOfKind<'signoz/TablePanel'> {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'Table panel' },
|
||||
plugin: { kind: 'signoz/TablePanel', spec },
|
||||
},
|
||||
spec: { plugin: { kind: 'signoz/TablePanel', spec } },
|
||||
} as unknown as PanelOfKind<'signoz/TablePanel'>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import type {
|
||||
PanelQueryData,
|
||||
PanelTable,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
|
||||
import type { PanelOfKind } from '../../../types/rendererProps';
|
||||
import { prepareScalarTables } from '../../../../queryV5/prepareScalarTables';
|
||||
import { buildTableCsvRows, getTableCsvRows } from '../tableCsv';
|
||||
|
||||
// Stub number/unit formatting so assertions cover only the row-building.
|
||||
jest.mock('../../../utils/formatPanelValue', () => ({
|
||||
formatPanelValue: (value: number, unit?: string): string =>
|
||||
`${value}${unit ?? ''}`,
|
||||
}));
|
||||
|
||||
jest.mock('../../../../queryV5/prepareScalarTables', () => ({
|
||||
prepareScalarTables: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../../queryV5/v5ResponseData', () => ({
|
||||
getScalarResults: jest.fn(() => []),
|
||||
}));
|
||||
|
||||
const mockPrepareScalarTables = prepareScalarTables as jest.MockedFunction<
|
||||
typeof prepareScalarTables
|
||||
>;
|
||||
|
||||
const table: PanelTable = {
|
||||
queryName: 'A',
|
||||
legend: '',
|
||||
columns: [
|
||||
{ name: 'service', queryName: 'A', isValueColumn: false, id: 'service' },
|
||||
{ name: 'p99', queryName: 'A', isValueColumn: true, id: 'A' },
|
||||
],
|
||||
rows: [
|
||||
{ data: { service: 'frontend', A: 1234 } },
|
||||
{ data: { service: 'cart', A: 56 } },
|
||||
],
|
||||
};
|
||||
|
||||
describe('buildTableCsvRows', () => {
|
||||
it('keys rows by column name in display order and formats value columns', () => {
|
||||
const rows = buildTableCsvRows({
|
||||
table,
|
||||
columnUnits: { A: 'ms' },
|
||||
decimalPrecision: undefined,
|
||||
});
|
||||
|
||||
expect(rows).toStrictEqual([
|
||||
{ service: 'frontend', p99: '1234ms' },
|
||||
{ service: 'cart', p99: '56ms' },
|
||||
]);
|
||||
expect(Object.keys(rows[0])).toStrictEqual(['service', 'p99']);
|
||||
});
|
||||
|
||||
it('renders group columns and non-numeric value cells as raw text', () => {
|
||||
const rows = buildTableCsvRows({
|
||||
table: {
|
||||
...table,
|
||||
rows: [{ data: { service: 'api', A: 'n/a' } }],
|
||||
},
|
||||
columnUnits: {},
|
||||
decimalPrecision: undefined,
|
||||
});
|
||||
|
||||
expect(rows).toStrictEqual([{ service: 'api', p99: 'n/a' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTableCsvRows', () => {
|
||||
const panel = {
|
||||
spec: { plugin: { spec: { formatting: { columnUnits: { A: 'ms' } } } } },
|
||||
} as unknown as PanelOfKind<'signoz/TablePanel'>;
|
||||
const data = {} as PanelQueryData;
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('prepares the scalar table and flattens the first non-empty one to rows', () => {
|
||||
mockPrepareScalarTables.mockReturnValue([table]);
|
||||
|
||||
expect(getTableCsvRows(panel, data)).toStrictEqual([
|
||||
{ service: 'frontend', p99: '1234ms' },
|
||||
{ service: 'cart', p99: '56ms' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns no rows when the response has no table', () => {
|
||||
mockPrepareScalarTables.mockReturnValue([]);
|
||||
|
||||
expect(getTableCsvRows(panel, data)).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,7 @@ export const definition: PanelDefinition<'signoz/TablePanel'> = {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: { csv: true, png: true, svg: true },
|
||||
download: true,
|
||||
createAlert: false,
|
||||
// V1 parity: only tables (and lists) expose the header search box.
|
||||
search: true,
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import type { TableProps } from 'antd';
|
||||
import type { DashboardtypesTableThresholdDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PrecisionOption } from 'components/Graph/types';
|
||||
import type {
|
||||
PanelTable,
|
||||
PanelTableColumn,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import type { PanelTable } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import { coerceToString } from 'utils/stringUtils';
|
||||
|
||||
import type { PanelThreshold } from '../../types/threshold';
|
||||
@@ -33,26 +30,6 @@ export function mapTableThresholds(
|
||||
return byColumn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain-text value of a table cell (value columns formatted through unit +
|
||||
* precision, group columns raw). Shared by the renderer and the CSV export.
|
||||
*/
|
||||
export function formatTableCellText(
|
||||
col: PanelTableColumn,
|
||||
raw: unknown,
|
||||
unit: string | undefined,
|
||||
decimalPrecision?: PrecisionOption,
|
||||
): string {
|
||||
if (!col.isValueColumn) {
|
||||
return coerceToString(raw);
|
||||
}
|
||||
const num = Number(raw);
|
||||
if (!Number.isFinite(num)) {
|
||||
return coerceToString(raw);
|
||||
}
|
||||
return formatPanelValue(num, unit, decimalPrecision);
|
||||
}
|
||||
|
||||
// Sort comparator: numeric when both cells parse as numbers (value columns and
|
||||
// numeric group keys), otherwise a locale string compare. Nullish sorts last.
|
||||
function compareCells(a: unknown, b: unknown): number {
|
||||
@@ -112,13 +89,15 @@ export function buildTableColumns({
|
||||
sorter: (a: TableRowData, b: TableRowData): number =>
|
||||
compareCells(a[key], b[key]),
|
||||
render: (raw: unknown): React.ReactNode => {
|
||||
const text = formatTableCellText(col, raw, unit, decimalPrecision);
|
||||
if (!col.isValueColumn) {
|
||||
return coerceToString(raw);
|
||||
}
|
||||
const num = Number(raw);
|
||||
if (
|
||||
!col.isValueColumn ||
|
||||
colThresholds.length === 0 ||
|
||||
!Number.isFinite(num)
|
||||
) {
|
||||
if (!Number.isFinite(num)) {
|
||||
return coerceToString(raw);
|
||||
}
|
||||
const text = formatPanelValue(num, unit, decimalPrecision);
|
||||
if (colThresholds.length === 0) {
|
||||
return text;
|
||||
}
|
||||
const { threshold } = resolveActiveThreshold(colThresholds, num, unit);
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import type { PrecisionOption } from 'components/Graph/types';
|
||||
import { prepareScalarTables } from 'pages/DashboardPageV2/DashboardContainer/queryV5/prepareScalarTables';
|
||||
import type {
|
||||
PanelQueryData,
|
||||
PanelTable,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import { getScalarResults } from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
|
||||
|
||||
import { resolveDecimalPrecision } from '../../utils/chartAppearance/resolvers';
|
||||
import { getColumnUnit } from '../../utils/getColumnUnit';
|
||||
import type { PanelOfKind } from '../../types/rendererProps';
|
||||
|
||||
import { formatTableCellText } from './tableColumns';
|
||||
|
||||
interface BuildTableCsvRowsArgs {
|
||||
table: PanelTable;
|
||||
/** Per-column display unit (`formatting.columnUnits`), keyed by column key. */
|
||||
columnUnits: Record<string, string>;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens a prepared table into CSV rows keyed by column name, reusing the
|
||||
* on-screen cell formatting in display column order. Exports the full result
|
||||
* set, not the paginated view (V1 parity).
|
||||
*/
|
||||
export function buildTableCsvRows({
|
||||
table,
|
||||
columnUnits,
|
||||
decimalPrecision,
|
||||
}: BuildTableCsvRowsArgs): Record<string, string>[] {
|
||||
return table.rows.map((row) => {
|
||||
const csvRow: Record<string, string> = {};
|
||||
table.columns.forEach((col) => {
|
||||
const key = col.id || col.name;
|
||||
const unit = getColumnUnit(key, columnUnits);
|
||||
csvRow[col.name] = formatTableCellText(
|
||||
col,
|
||||
row.data[key],
|
||||
unit,
|
||||
decimalPrecision,
|
||||
);
|
||||
});
|
||||
return csvRow;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the scalar table from the query response and flattens it to CSV rows,
|
||||
* reusing the on-screen formatting. Returns [] when the response has no table.
|
||||
*/
|
||||
export function getTableCsvRows(
|
||||
panel: PanelOfKind<'signoz/TablePanel'>,
|
||||
data: PanelQueryData,
|
||||
): Record<string, string>[] {
|
||||
const spec = panel.spec.plugin.spec;
|
||||
const table = prepareScalarTables({
|
||||
results: getScalarResults(data.response),
|
||||
legendMap: data.legendMap ?? {},
|
||||
requestPayload: data.requestPayload,
|
||||
}).find((candidate) => candidate.columns.length > 0);
|
||||
if (!table) {
|
||||
return [];
|
||||
}
|
||||
return buildTableCsvRows({
|
||||
table,
|
||||
columnUnits: spec.formatting?.columnUnits ?? {},
|
||||
decimalPrecision: resolveDecimalPrecision(spec.formatting?.decimalPrecision),
|
||||
});
|
||||
}
|
||||
@@ -35,7 +35,6 @@ function TimeSeriesPanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
isFetching,
|
||||
refetch,
|
||||
onClick,
|
||||
onDragSelect,
|
||||
@@ -188,9 +187,7 @@ function TimeSeriesPanelRenderer({
|
||||
data-testid="time-series-renderer"
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{flatSeries.length === 0 && (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} />
|
||||
)}
|
||||
{flatSeries.length === 0 && <NoData onRetry={refetch} />}
|
||||
{flatSeries.length > 0 &&
|
||||
containerDimensions.width > 0 &&
|
||||
containerDimensions.height > 0 && (
|
||||
|
||||
@@ -24,7 +24,7 @@ export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: { csv: false, png: true, svg: true },
|
||||
download: false,
|
||||
createAlert: true,
|
||||
search: false,
|
||||
drilldown: true,
|
||||
|
||||
@@ -8,29 +8,27 @@ import type { PanelKind } from './panelKind';
|
||||
import type { QueryBuilderFieldRule } from './panelCapabilities';
|
||||
import type { BaseRendererProps, PanelRendererProps } from './rendererProps';
|
||||
|
||||
/** Export formats offered under the single "Download" action. */
|
||||
export enum DownloadFormat {
|
||||
CSV = 'csv',
|
||||
PNG = 'png',
|
||||
SVG = 'svg',
|
||||
}
|
||||
|
||||
/**
|
||||
* Which actions a kind supports, declared per-kind in `kinds/<Kind>/definition.ts`.
|
||||
* Chrome actions (move, clone, delete) are layout concerns and aren't declared here.
|
||||
* Which panel actions a kind supports. Required field, so registering a new
|
||||
* kind forces an explicit decision for every action. Chrome actions (move to
|
||||
* section, clone, delete) are dashboard-layout concerns available to every
|
||||
* panel and are intentionally not declarable here.
|
||||
*/
|
||||
export interface PanelActionCapabilities {
|
||||
/** Gates the "View" action. */
|
||||
/** Kind has a full-screen view — gates the "View" action. */
|
||||
view: boolean;
|
||||
/** Gates the "Edit panel" action. */
|
||||
/** Kind is editable in the V2 panel editor — gates the "Edit panel" action. */
|
||||
edit: boolean;
|
||||
/** Gates the "Clone" action. */
|
||||
/** Kind can be cloned — gates the "Clone" action. */
|
||||
clone: boolean;
|
||||
/** Which formats this kind can be downloaded as (CSV is table-only). */
|
||||
download: Record<DownloadFormat, boolean>;
|
||||
/** Gates "Create Alerts". */
|
||||
/** Gates "Download as CSV". V1 parity: only table panels carry exportable data. */
|
||||
download: boolean;
|
||||
/** Kind's query can seed a new alert — gates "Create Alerts". */
|
||||
createAlert: boolean;
|
||||
/** Client-side header search box, consumed by the renderer via `searchTerm`. */
|
||||
/**
|
||||
* Header search box that filters rendered rows client-side (V1 parity: only
|
||||
* tabular kinds). Not a menu action — the renderer must consume `searchTerm`.
|
||||
*/
|
||||
search: boolean;
|
||||
/**
|
||||
* Kind supports click-to-drilldown (context menu + View/Breakout). V1 parity: charts + scalar
|
||||
@@ -53,10 +51,13 @@ export interface PanelDefinition<K extends PanelKind = PanelKind> {
|
||||
actions: PanelActionCapabilities;
|
||||
}
|
||||
|
||||
// Every kind must be registered, so getPanelDefinition never returns undefined.
|
||||
// Total over PanelKind: every kind must be registered (missing → compile error),
|
||||
// so getPanelDefinition never returns undefined.
|
||||
export type PanelRegistry = { [K in PanelKind]: PanelDefinition<K> };
|
||||
|
||||
// PanelDefinition with its Renderer widened to the kind-agnostic prop surface.
|
||||
// getPanelDefinition resolves to this, concentrating the unavoidable cast in one
|
||||
// place rather than leaking it to every call site (the kind isn't known statically).
|
||||
export interface RenderablePanelDefinition extends Omit<
|
||||
PanelDefinition,
|
||||
'Renderer'
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { unparse } from 'papaparse';
|
||||
|
||||
import { toSafeFileName } from './toSafeFileName';
|
||||
|
||||
/** Serializes rows (keyed by column header) to CSV and downloads the file. */
|
||||
export function downloadCsv(
|
||||
rows: Record<string, string>[],
|
||||
fileBaseName: string,
|
||||
): void {
|
||||
const csv = unparse(rows);
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${toSafeFileName(fileBaseName)}.csv`;
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
/** Makes a download filename safe across OSes; falls back when empty. */
|
||||
export function toSafeFileName(name: string): string {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
return 'panel';
|
||||
}
|
||||
return trimmed.replace(/[\\/:*?"<>|]+/g, '-');
|
||||
}
|
||||
@@ -25,6 +25,7 @@
|
||||
|
||||
.welcome {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
@@ -33,56 +34,52 @@
|
||||
|
||||
.welcomeInfo {
|
||||
color: var(--l3-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.step {
|
||||
.addPanel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
border: 1px dashed var(--l3-border);
|
||||
border: 1px dashed var(--l1-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.stepText {
|
||||
.addPanelText {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
|
||||
.stepIcon {
|
||||
.icon {
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
margin-top: 2px;
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
|
||||
.stepCopy {
|
||||
.addPanelCopy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.stepTitle {
|
||||
.addPanelTitle {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.stepInfo {
|
||||
.addPanelInfo {
|
||||
color: var(--l3-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Configure, Plus } from '@signozhq/icons';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
@@ -6,7 +6,6 @@ import dashboardEmojiUrl from '@/assets/Icons/dashboard_emoji.svg';
|
||||
import landscapeUrl from '@/assets/Icons/landscape.svg';
|
||||
|
||||
import { useCreatePanel } from '../../hooks/useCreatePanel';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import PanelTypeSelectionModal from '../Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
|
||||
import styles from './DashboardEmptyState.module.scss';
|
||||
|
||||
@@ -19,8 +18,6 @@ function DashboardEmptyState({
|
||||
}: DashboardEmptyStateProps): JSX.Element {
|
||||
const { isPickerOpen, openPicker, closePicker, createPanel } =
|
||||
useCreatePanel();
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const requestSettings = useDashboardStore((s) => s.requestSettings);
|
||||
|
||||
return (
|
||||
<section className={styles.emptyState}>
|
||||
@@ -35,55 +32,28 @@ function DashboardEmptyState({
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div className={styles.steps}>
|
||||
<div className={styles.step}>
|
||||
<div className={styles.stepText}>
|
||||
<Configure size={14} className={styles.stepIcon} />
|
||||
<div className={styles.stepCopy}>
|
||||
<Typography.Text className={styles.stepTitle}>
|
||||
Configure your new dashboard
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.stepInfo}>
|
||||
Give it a name, add description, tags and variables
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.addPanel}>
|
||||
<div className={styles.addPanelText}>
|
||||
<img src={landscapeUrl} alt="" className={styles.icon} />
|
||||
<div className={styles.addPanelCopy}>
|
||||
<Typography.Text className={styles.addPanelTitle}>
|
||||
Add panels
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.addPanelInfo}>
|
||||
Add panels to visualize your data
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{isEditable && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<Configure size="md" />}
|
||||
onClick={(): void => requestSettings({ tab: 'Overview' })}
|
||||
testId="empty-configure"
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.step}>
|
||||
<div className={styles.stepText}>
|
||||
<img src={landscapeUrl} alt="" className={styles.stepIcon} />
|
||||
<div className={styles.stepCopy}>
|
||||
<Typography.Text className={styles.stepTitle}>
|
||||
Add panels
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.stepInfo}>
|
||||
Add panels to visualize your data
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
{canAddPanel && (
|
||||
<Button
|
||||
color="primary"
|
||||
prefix={<Plus size="md" />}
|
||||
onClick={(): void => openPicker()}
|
||||
testId="add-panel"
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{canAddPanel && (
|
||||
<Button
|
||||
color="primary"
|
||||
prefix={<Plus size="md" />}
|
||||
onClick={(): void => openPicker()}
|
||||
testId="add-panel"
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<PanelTypeSelectionModal
|
||||
|
||||
@@ -76,14 +76,10 @@ function Panel({
|
||||
<div
|
||||
className={styles.panel}
|
||||
data-panel-visible={isVisible ? 'true' : 'false'}
|
||||
// Stable locator so the "Download as PNG" action can find this node to
|
||||
// capture, without threading a ref through the header/actions chain.
|
||||
data-panel-root={panelId}
|
||||
>
|
||||
<PanelHeader
|
||||
panelId={panelId}
|
||||
panel={panel}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
warning={data.response?.data?.warning}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { EllipsisVertical } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
|
||||
import ConfirmDeleteDialog from '../../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
|
||||
import type { PanelActionsConfig } from '../Panel';
|
||||
@@ -11,10 +10,8 @@ import styles from './PanelActionsMenu.module.scss';
|
||||
|
||||
interface PanelActionsMenuProps {
|
||||
panelId: string;
|
||||
/** The panel itself — seeds "Create Alerts" and the download filename. */
|
||||
/** The panel itself — its query seeds "Create Alerts". */
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** The panel's query response — the source for "Download as CSV". */
|
||||
data: PanelQueryData;
|
||||
/** Layout context for move/delete — absent outside editable sectioned mode. */
|
||||
panelActions?: PanelActionsConfig;
|
||||
}
|
||||
@@ -27,13 +24,11 @@ interface PanelActionsMenuProps {
|
||||
function PanelActionsMenu({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
panelActions,
|
||||
}: PanelActionsMenuProps): JSX.Element | null {
|
||||
const { items, deleteConfirm } = usePanelActionItems({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
panelActions,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import type { ROLES } from 'types/roles';
|
||||
|
||||
import type { DashboardSection } from '../../../../utils';
|
||||
@@ -48,13 +47,6 @@ jest.mock('../../hooks/useCreateAlertFromPanel', () => ({
|
||||
useCreateAlertFromPanel: (): jest.Mock => mockCreateAlert,
|
||||
}));
|
||||
|
||||
const mockDownloadImage = jest.fn();
|
||||
jest.mock('../../hooks/useDownloadPanelImage', () => ({
|
||||
useDownloadPanelImage: (): { downloadPanelImage: jest.Mock } => ({
|
||||
downloadPanelImage: mockDownloadImage,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Role is the only thing read off the app context; useComponentPermission runs
|
||||
// for real so the tests exercise the actual role → permission mapping.
|
||||
let mockRole: ROLES = 'ADMIN';
|
||||
@@ -92,16 +84,9 @@ const mockPanel = {
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
|
||||
const mockData = {
|
||||
response: undefined,
|
||||
requestPayload: undefined,
|
||||
legendMap: {},
|
||||
} as PanelQueryData;
|
||||
|
||||
const baseArgs = {
|
||||
panelId: 'panel-1',
|
||||
panel: mockPanel,
|
||||
data: mockData,
|
||||
panelActions: { currentLayoutIndex: 0, sections: TWO_TITLED_SECTIONS },
|
||||
};
|
||||
|
||||
@@ -125,15 +110,14 @@ describe('usePanelActionItems', () => {
|
||||
'edit-panel',
|
||||
'clone-panel',
|
||||
'divider',
|
||||
'download',
|
||||
'create-alert',
|
||||
'divider',
|
||||
'move',
|
||||
'divider',
|
||||
'delete-panel',
|
||||
]);
|
||||
// The single "Download" entry is a submenu (PNG/SVG, plus CSV on tables);
|
||||
// it's present for every renderable kind.
|
||||
// download stays hidden: no current kind declares the capability
|
||||
// (V1 parity — CSV export was table-only).
|
||||
});
|
||||
|
||||
it('AUTHOR loses edit and clone (edit_widget excludes AUTHOR) but keeps the rest', () => {
|
||||
@@ -142,7 +126,6 @@ describe('usePanelActionItems', () => {
|
||||
expect(itemKeys(result.current)).toStrictEqual([
|
||||
'view-panel',
|
||||
'divider',
|
||||
'download',
|
||||
'create-alert',
|
||||
'divider',
|
||||
'move',
|
||||
@@ -151,28 +134,26 @@ describe('usePanelActionItems', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('VIEWER keeps only the role-ungated actions (view, download, create-alert)', () => {
|
||||
it('VIEWER keeps only the role-ungated actions (view, create-alert)', () => {
|
||||
mockRole = 'VIEWER';
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
expect(itemKeys(result.current)).toStrictEqual([
|
||||
'view-panel',
|
||||
'divider',
|
||||
'download',
|
||||
'create-alert',
|
||||
]);
|
||||
});
|
||||
|
||||
it('read-only dashboard keeps the non-mutating actions (View, Download, Create Alerts)', () => {
|
||||
it('read-only dashboard keeps View and Create Alerts (V1 parity: both survive a lock)', () => {
|
||||
useDashboardStore.setState({ isEditable: false });
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({ ...baseArgs, panelActions: undefined }),
|
||||
);
|
||||
// View, the Download submenu (PNG/SVG) and Create Alerts are all
|
||||
// non-mutating, so they survive on a read-only dashboard (V1 parity).
|
||||
// Create Alerts opens a new tab and never mutates the dashboard, so it
|
||||
// isn't gated on edit access — matching V1's locked-dashboard menu.
|
||||
expect(itemKeys(result.current)).toStrictEqual([
|
||||
'view-panel',
|
||||
'divider',
|
||||
'download',
|
||||
'create-alert',
|
||||
]);
|
||||
});
|
||||
@@ -296,25 +277,6 @@ describe('usePanelActionItems', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('the Download submenu captures the panel by id, name and chosen format', () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
const download = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'download',
|
||||
) as { children: { key: string; onClick: () => void }[] };
|
||||
|
||||
// TimeSeries declares no CSV capability, so the submenu is just PNG + SVG.
|
||||
expect(download.children.map((c) => c.key)).toStrictEqual([
|
||||
'download-png',
|
||||
'download-svg',
|
||||
]);
|
||||
|
||||
download.children.find((c) => c.key === 'download-png')?.onClick();
|
||||
expect(mockDownloadImage).toHaveBeenCalledWith('panel-1', 'CPU', 'png');
|
||||
|
||||
download.children.find((c) => c.key === 'download-svg')?.onClick();
|
||||
expect(mockDownloadImage).toHaveBeenCalledWith('panel-1', 'CPU', 'svg');
|
||||
});
|
||||
|
||||
it('view opens the View modal for the panel', () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
const view = result.current.items.find(
|
||||
@@ -332,4 +294,13 @@ describe('usePanelActionItems', () => {
|
||||
(createAlert as { onClick: () => void }).onClick();
|
||||
expect(mockCreateAlert).toHaveBeenCalledWith(mockPanel, 'panel-1');
|
||||
});
|
||||
|
||||
it('create-alert seeds an alert from this panel', () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
const createAlert = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'create-alert',
|
||||
);
|
||||
(createAlert as { onClick: () => void }).onClick();
|
||||
expect(mockCreateAlert).toHaveBeenCalledWith(mockPanel, 'panel-1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,8 +37,6 @@ export const PANEL_ACTION_META: Record<PanelActionId, PanelActionMeta> = {
|
||||
view: { capability: 'view' },
|
||||
edit: { permission: 'edit_widget', capability: 'edit' },
|
||||
clone: { permission: 'edit_widget' },
|
||||
// Single entry for every export format (CSV/PNG/SVG); like view it isn't
|
||||
// role-gated (V1 parity). The per-format options live in usePanelActionItems.
|
||||
download: { capability: 'download' },
|
||||
createAlert: { capability: 'createAlert' },
|
||||
// Moving a panel between sections mutates the dashboard layout.
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Bell, Copy, Fullscreen, PenLine, Trash2 } from '@signozhq/icons';
|
||||
import {
|
||||
Bell,
|
||||
CloudDownload,
|
||||
Copy,
|
||||
FolderInput,
|
||||
FolderOutput,
|
||||
Fullscreen,
|
||||
PenLine,
|
||||
Trash2,
|
||||
} from '@signozhq/icons';
|
||||
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
@@ -9,7 +18,6 @@ import {
|
||||
} from 'hooks/useConfirmableAction';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import { useOpenPanelEditor } from 'pages/DashboardPageV2/DashboardContainer/hooks/useOpenPanelEditor';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
@@ -18,22 +26,87 @@ import type { PanelActionsConfig } from '../Panel';
|
||||
import { useClonePanel } from '../hooks/useClonePanel';
|
||||
import { useCreateAlertFromPanel } from '../hooks/useCreateAlertFromPanel';
|
||||
import { useDeletePanel } from '../hooks/useDeletePanel';
|
||||
import { useDownloadPanelMenuItem } from '../hooks/useDownloadPanelMenuItem';
|
||||
import { useMovePanelToSection } from '../hooks/useMovePanelToSection';
|
||||
import {
|
||||
type MovePanelArgs,
|
||||
useMovePanelToSection,
|
||||
} from '../hooks/useMovePanelToSection';
|
||||
import { useViewPanel } from '../hooks/useViewPanel';
|
||||
import { buildMoveItems } from '../utils/buildMoveItems';
|
||||
import { PANEL_ACTION_META } from './panelActionMeta';
|
||||
|
||||
// Stable fallback so renders without layout context don't churn the mutation
|
||||
// hooks' deps (a fresh [] each render would re-create their callbacks).
|
||||
const EMPTY_SECTIONS: DashboardSection[] = [];
|
||||
|
||||
/** Placeholder for V1-parity actions whose V2 implementations land later. */
|
||||
function notImplementedYet(feature: string): void {
|
||||
// eslint-disable-next-line no-alert -- temporary placeholder, see above
|
||||
alert(`${feature} option clicked`);
|
||||
}
|
||||
|
||||
interface MoveItemsArgs {
|
||||
sections: DashboardSection[];
|
||||
currentLayoutIndex: number;
|
||||
panelId: string;
|
||||
movePanel: (args: MovePanelArgs) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Move to section" submenu (other titled sections) plus a direct "Move out
|
||||
* of section" to the untitled root, shown only when the panel sits in a titled
|
||||
* section and a root section exists to receive it.
|
||||
*/
|
||||
function buildMoveItems({
|
||||
sections,
|
||||
currentLayoutIndex,
|
||||
panelId,
|
||||
movePanel,
|
||||
}: MoveItemsArgs): MenuItem[] {
|
||||
const targets = sections.filter(
|
||||
(s) => s.title && s.layoutIndex !== currentLayoutIndex,
|
||||
);
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
key: 'move',
|
||||
label: 'Move to section',
|
||||
icon: <FolderInput size={14} />,
|
||||
...(targets.length === 0
|
||||
? { disabled: true }
|
||||
: {
|
||||
children: targets.map((s) => ({
|
||||
key: `move-${s.layoutIndex}`,
|
||||
label: s.title,
|
||||
onClick: (): void =>
|
||||
void movePanel({
|
||||
panelId,
|
||||
fromLayoutIndex: currentLayoutIndex,
|
||||
toLayoutIndex: s.layoutIndex,
|
||||
}),
|
||||
})),
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const rootSection = sections.find((s) => !s.title);
|
||||
if (rootSection && rootSection.layoutIndex !== currentLayoutIndex) {
|
||||
items.push({
|
||||
key: 'move-to-root',
|
||||
label: 'Move out of section',
|
||||
icon: <FolderOutput size={14} />,
|
||||
onClick: (): void =>
|
||||
void movePanel({
|
||||
panelId,
|
||||
fromLayoutIndex: currentLayoutIndex,
|
||||
toLayoutIndex: rootSection.layoutIndex,
|
||||
}),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
interface UsePanelActionItemsArgs {
|
||||
panelId: string;
|
||||
/** The panel itself — seeds "Create Alerts" and the download filename. */
|
||||
/** The panel itself — its query seeds the "Create Alerts" action. */
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** The panel's query response — the source for "Download as CSV". */
|
||||
data: PanelQueryData;
|
||||
/** Layout context for move/delete — absent outside editable mode. */
|
||||
panelActions?: PanelActionsConfig;
|
||||
}
|
||||
@@ -45,15 +118,19 @@ export interface PanelActionItems {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the panel actions menu items. Each action passes three gates before
|
||||
* it appears: kind (PanelDefinition.actions), role (useComponentPermission) and
|
||||
* context (dashboard editable + layout config present). View and Download stay
|
||||
* available on read-only dashboards, as in V1.
|
||||
* Resolves the panel actions menu items (V1 WidgetHeader set plus V2's "Move to
|
||||
* section"). Every action passes three gates before it appears:
|
||||
*
|
||||
* kind — what the panel kind declares it supports (PanelDefinition.actions);
|
||||
* unknown kinds support no kind-gated actions.
|
||||
* role — componentPermission lookup for the current user (PANEL_ACTION_META;
|
||||
* actions without a permission key are open to every role, V1 parity).
|
||||
* context — runtime state: dashboard editable (store), layout config present.
|
||||
* View and Download remain available on read-only dashboards, as in V1.
|
||||
*/
|
||||
export function usePanelActionItems({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
panelActions,
|
||||
}: UsePanelActionItemsArgs): PanelActionItems {
|
||||
const panelKind = panel.spec.plugin.kind;
|
||||
@@ -72,19 +149,14 @@ export function usePanelActionItems({
|
||||
const createAlert = useCreateAlertFromPanel();
|
||||
const { openView } = useViewPanel();
|
||||
|
||||
// Mutations are store-backed; the layout tree only supplies `sections`.
|
||||
// Mutations are store-backed (dashboardId/refetch) — the layout tree only
|
||||
// supplies data (`sections`), so no callbacks are threaded through it.
|
||||
const sections = panelActions?.sections ?? EMPTY_SECTIONS;
|
||||
const movePanel = useMovePanelToSection({ sections });
|
||||
const deletePanel = useDeletePanel({ sections });
|
||||
const clonePanel = useClonePanel({ sections });
|
||||
|
||||
const panelCapabilities = getPanelDefinition(panelKind).actions;
|
||||
const downloadItem = useDownloadPanelMenuItem({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
actions: panelCapabilities,
|
||||
});
|
||||
|
||||
// Delete runs on confirm, not on click — the menu item opens a prompt.
|
||||
const deleteConfirm = useConfirmableAction(
|
||||
@@ -119,8 +191,8 @@ export function usePanelActionItems({
|
||||
onClick: (): void => openPanelEditor(panelId),
|
||||
});
|
||||
}
|
||||
// Clone needs the section context to place the copy, so — unlike Edit —
|
||||
// it requires panelActions.
|
||||
// Clone needs the section context (source spec + dimensions) to place the
|
||||
// copy, so — unlike Edit — it requires panelActions.
|
||||
if (isEditable && canEditWidget && panelActions && panelCapabilities.clone) {
|
||||
panelGroup.push({
|
||||
key: 'clone-panel',
|
||||
@@ -135,12 +207,17 @@ export function usePanelActionItems({
|
||||
}
|
||||
|
||||
const dataGroup: MenuItem[] = [];
|
||||
if (downloadItem) {
|
||||
dataGroup.push(downloadItem);
|
||||
if (panelCapabilities.download) {
|
||||
dataGroup.push({
|
||||
key: 'download-panel',
|
||||
label: 'Download as CSV',
|
||||
icon: <CloudDownload size={14} />,
|
||||
onClick: (): void => notImplementedYet('Download'),
|
||||
});
|
||||
}
|
||||
|
||||
// Create Alerts opens a new tab and never mutates the dashboard, so —
|
||||
// unlike edit/clone — it isn't gated on `isEditable` (V1 parity).
|
||||
// Seeding an alert opens a new tab and never mutates the dashboard, so —
|
||||
// unlike edit/clone — it isn't gated on `isEditable` (V1 parity: available
|
||||
// on locked dashboards too).
|
||||
if (panelCapabilities.createAlert) {
|
||||
dataGroup.push({
|
||||
key: 'create-alert',
|
||||
@@ -188,7 +265,6 @@ export function usePanelActionItems({
|
||||
panelActions,
|
||||
sections,
|
||||
panelId,
|
||||
downloadItem,
|
||||
openView,
|
||||
openPanelEditor,
|
||||
createAlert,
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
.panelContainer {
|
||||
// Generic centred body — used by the loading indicator.
|
||||
.body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// Container for the rendered chart — fills the panel below the header and lets
|
||||
// the chart shrink (min-* 0) so it resizes with the grid cell.
|
||||
.chartContainer {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user