Compare commits

..

31 Commits

Author SHA1 Message Date
Ashwin Bhatkal
b8b7c330cb Merge branch 'feat/dashboard-v2-variables-redesign' into fix/dashboard-v2-variable-selection 2026-07-08 01:45:07 +05:30
Ashwin Bhatkal
a949bacc90 Merge branch 'feat/dashboard-v2-variable-orchestration' into feat/dashboard-v2-variables-redesign 2026-07-08 01:44:56 +05:30
Ashwin Bhatkal
90e0cb5bb5 Merge branch 'main' into feat/dashboard-v2-variable-orchestration 2026-07-08 01:41:42 +05:30
Ashwin Bhatkal
cec72e25fd feat(dashboard-v2): link variables to panels (#12003)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* feat(dashboard-v2): scope panel refetch to referenced variables

A panel now refetches only when a variable its queries reference changes (the cache key is scoped to referenced variables; the full payload is still substituted), and stays in its loading state on first load until those variables resolve.

* feat(dashboard-v2): surface dashboard variables in query builder suggestions

Publish the dashboard's variables into the shared store the query builder autocomplete reads, so $variable suggestions appear in the dashboards-page builder and the panel editor.
2026-07-07 20:01:15 +00:00
Ashwin Bhatkal
fbd2272bef fix(dashboard-v2): variable selection edge cases
- Read the __ALL__ URL sentinel as "ALL" only for variables that support it, so a
  literal "__ALL__" value (e.g. a text variable) isn't misread.
- Prune URL selections for variables that no longer exist (rename/remove), so a
  shared link can't leak a stale value into a later variable.
- Bound option-query cache churn with a cacheTime on the query/dynamic fetches.
2026-07-08 01:26:40 +05:30
Ashwin Bhatkal
a5d81f5b56 fix(dashboard-v2): variable fetch-engine correctness
- Ignore a stale/late fetch settle for a variable no longer actively fetching
  (restores V1's active-state guard).
- Unblock a waiting query child only once ALL its parents are settled (diamond
  dependencies no longer fetch against a stale parent).
- A value change no longer refetches the changed variable's own options, and only
  a dynamic change refreshes the other dynamics (query/custom/text changes don't
  touch dynamics — they don't depend on those values).
- Enqueue cycle-dropped query variables as best-effort roots instead of leaving
  them (and any waiting dynamics) silently stuck.
2026-07-08 01:26:40 +05:30
Ashwin Bhatkal
277ba6718f fix(dashboard-v2): variable selector robustness
- Surface option-fetch errors with a retry in the variable bar (Query/Dynamic) —
  a first-fetch failure is no longer a silent, stuck-empty selector.
- Custom variables now auto-select their default/first option (via a CustomSelector
  that runs useAutoSelect) instead of rendering blank.
2026-07-08 01:25:19 +05:30
Ashwin Bhatkal
744351753b fix(dashboard-v2): correct "ALL" variable selection end-to-end
- Materialize a query/custom ALL selection to the full option array so it reaches
  the query payload (was stored as value:null → dropped); tracks option growth.
- Default a multi-select allow-ALL variable (and the __ALL__ sentinel) to ALL,
  matching V1.
- ValueSelector hands CustomMultiSelect the expanded option set when all-selected,
  so it no longer renders a literal __ALL__ row.
- Keep a still-valid multi-select subset when options re-scope (was collapsing to
  the first option).
- Fix the dead configuredDefault fallback (read defaultValue as string|string[]).
2026-07-08 01:25:19 +05:30
Ashwin Bhatkal
b8ac334b3c fix(dashboard-v2): keep the add-variable + on one line when the bar is collapsed 2026-07-08 01:24:33 +05:30
Ashwin Bhatkal
401b3ba98a fix(dashboard-v2): center and show the default-value clear icon 2026-07-08 01:24:33 +05:30
Ashwin Bhatkal
30e3a72438 fix(dashboard-v2): keep field-key options during search refetch (smooth typing) 2026-07-08 01:24:33 +05:30
Ashwin Bhatkal
e4694f6411 fix(dashboard-v2): normalize dynamic variable signal so the source field always shows 2026-07-08 01:24:33 +05:30
Ashwin Bhatkal
2a1b1516d0 feat(dashboard-v2): two-column variables table with dynamic apply-to-panels 2026-07-08 01:24:33 +05:30
Ashwin Bhatkal
91fca0b395 feat(dashboard-v2): patch builders to apply/sync a dynamic variable's filter across panels 2026-07-08 01:24:32 +05:30
Ashwin Bhatkal
256289614b refactor(dashboard-v2): extract add-variable buttons into components 2026-07-08 01:23:04 +05:30
Ashwin Bhatkal
d1b99c203f feat(dashboard-v2): hide dashboard chrome in full screen
Show only the sections and panels in full screen — the header and toolbar
(actions, variables bar, time selector) are hidden for a clean presentation
view; exit with Esc. Also drops a stray blank line that failed the format check.
2026-07-08 01:23:04 +05:30
Ashwin Bhatkal
4bbef56d00 fix(dashboard-v2): flow the variables bar under the time selector + reposition add button
Revert the bar wrapper to block so the strip wraps around the floated time
selector (one line beside it collapsed; full-width below it when expanded) — a
flex wrapper had made it a BFC that trapped the rows on the left. The add-variable
"+" now sits after the +N/Less trigger, kept inline and always mounted so the
overflow measurement no longer toggles it (which caused a layout shift), with the
collapse math reserving space for it.
2026-07-08 01:21:45 +05:30
Ashwin Bhatkal
bee2b01d91 feat(dashboard-v2): add a Configure step to the dashboard empty state 2026-07-08 01:21:45 +05:30
Ashwin Bhatkal
fcd7196f37 feat(dashboard-v2): raise dynamic-variable attribute search debounce to 500ms 2026-07-08 01:21:45 +05:30
Ashwin Bhatkal
b66b8167d6 fix(dashboard-v2): keep add-variable button out of collapsed bar + flat toolbar borders
- Move the add-variable + icon inside the strip and only show it when the bar
  isn't collapsed (no overflow) or is expanded, so it never breaks the layout
  mid-wrap; expand to reveal it.
- Make the Actions/Configure/Edit-as-JSON border a flat l3 outline with no
  box-shadow/depth.
2026-07-08 01:21:45 +05:30
Ashwin Bhatkal
6e74eaee74 style(dashboard-v2): use the l3 border on toolbar buttons and the empty state
Give Actions/Configure/Edit-as-JSON and the dashboard empty-state dashed border
the more visible --l3-border.
2026-07-08 01:21:45 +05:30
Ashwin Bhatkal
cc69ada22c feat(dashboard-v2): collapse the add-variable button once variables exist
Full labelled button when there are no variables; once some exist it shrinks to
a + icon (label on hover) placed after the collapsed +N. Dashed l3 border.
2026-07-08 01:21:45 +05:30
Ashwin Bhatkal
3ca7b6da89 feat(dashboard-v2): add-variable button in the variables bar
Add a dashed outlined 'Add variable' button on the left of the variables bar
(shown for editors even with no variables). It deep-links into the settings
drawer at the Variables tab with the add-variable form open, via a transient
settingsRequest store slice; the drawer destroys on close so it re-initializes
cleanly each open.
2026-07-08 01:21:45 +05:30
Ashwin Bhatkal
bf82650c9d feat(dashboard-v2): restore the V1 combined field/source row for dynamic variables
Replace the separate Source and Attribute rows with the single V1-style row:
the telemetry field on the left, then "from" and the source select on the right.
2026-07-08 01:21:45 +05:30
Ashwin Bhatkal
78720ff018 fix(dashboard-v2): cap the variable preview-values box height
The 'Preview of Values' box only had a min-height, so a field with many values
grew the box tall. Add a max-height so it scrolls instead.
2026-07-08 01:21:45 +05:30
Ashwin Bhatkal
49e5b61362 feat(dashboard-v2): surface dashboard variables in query builder suggestions
Publish the dashboard's variables into the shared store the query builder autocomplete reads, so $variable suggestions appear in the dashboards-page builder and the panel editor.
2026-07-08 01:15:16 +05:30
Ashwin Bhatkal
695d50c270 feat(dashboard-v2): scope panel refetch to referenced variables
A panel now refetches only when a variable its queries reference changes (the cache key is scoped to referenced variables; the full payload is still substituted), and stays in its loading state on first load until those variables resolve.
2026-07-08 01:02:09 +05:30
Abhi kumar
82c833044d feat(dashboard-v2): extend time window from empty panel state (#11999)
* feat(dashboard-v2): add extend-time-window logic for empty panels

Add the empty-state "extend the time window" building block: useExtendTimeWindow
widens the dashboard's global time via the shared zoom-out ladder (getNextZoomOutRange
/ useZoomOut), and extendWindow.ts exposes the ExtendTimeWindow type plus a thin
buildExtendWindow adapter over the ladder result.

* feat(dashboard-v2): add view-session store slice for the modal extender

The View modal owns a local time window; add a transient dashboard-store
slice so it can publish its extend-window behaviour for the deeply-nested
NoData empty state to read, instead of threading it through every renderer.

* feat(dashboard-v2): offer an extend-window action in the panel no-data state

A panel with no data in the selected window showed only a Retry, which can't
help when the query succeeded and there's just nothing in range. Add an
"Extend time range" action that widens the window (global time on the
grid/editor; the View modal's local window in place, read from the store),
shown alongside the retained Retry.

Also fix the stale empty state: keepPreviousData retained the empty response so
NoData lingered through a refetch then snapped to the chart — a refetch over
empty data now shows the shared PanelLoader instead.

PanelMessage gains a secondaryAction so the empty state can render both buttons;
refetch stays on the renderer contract to drive Retry (standalone call sites
like the editor preview may still omit it, showing Extend only).

* chore: pr review fixes

* chore: pr review changes

* chore: pr review changes
2026-07-07 19:06:38 +00:00
Ashwin Bhatkal
f98b8bb1fd feat(dashboard-v2): runtime variable fetch engine & selectors (#12002)
* feat(dashboard-v2): add runtime variable fetch-state engine

Port V1's variable fetch orchestration natively onto the dashboard store: a fetch-state slice (idle/loading/revalidating/waiting/error + per-variable cycle ids) plus the dependency context derivation (query/dynamic order) needed to schedule fetches. Query variables load in dependency order; dynamics wait for query values. enqueueFetchAll drives first load/time change, enqueueDescendants drives a single value change.

* feat(dashboard-v2): drive variable selectors off the fetch engine

Gate the Query/Dynamic option fetches on the engine's per-variable state and key them by cycle id (so a parent change refetches only its dependents, in order, with no duplicate calls), and report completion/failure back. Wire the selection lifecycle: full fetch cycle on load/time change, descendant cascade on value change.

* fix(dashboard-v2): read a variable's default value as string, not { value }

defaultValue is a string | string[] on the wire, but the editor and runtime read
it as { value }, so a saved default showed blank in the form (and couldn't be
cleared) and never auto-applied. Read it directly in all three sites.

* feat(dashboard-v2): show hidden variable values on the collapsed +N hover

When the variables bar collapses the overflow behind +N, hovering it now lists
the hidden variables and their current selected values.

* feat(dashboard-v2): batch initial variable auto-selections into one fetch

Auto-selection now flows through a dedicated onAutoSelect path, separate from
user changes. The initial load burst of fills (each selector picks a value as
its options resolve, at different times) is coalesced across one animation frame
into a single store write plus one enqueueDescendantsBatch, so dependent
variables re-fetch once with the settled parent values instead of once per fill.

* refactor(dashboard-v2): use enum for VariableFetchState
2026-07-07 19:01:32 +00:00
Ashwin Bhatkal
2114fbddfe feat(dashboard-v2): enforce name-length limits on dashboard inputs (#12015)
* feat(dashboard-v2): cap dashboard, section, panel and variable name inputs at 128 chars

* feat(dashboard-v2): cap saved-view names at 64 chars
2026-07-07 18:51:20 +00:00
Yunus M
b2214cf5a3 feat: implement resizable quick filters across multiple components with persistent width settings (#12014) 2026-07-07 18:10:39 +00:00
116 changed files with 3583 additions and 975 deletions

View File

@@ -47,7 +47,7 @@ function CheckboxValueRow({
{customRendererForValue ? (
customRendererForValue(value)
) : (
<TooltipSimple title={value} side="top" align="center" arrow>
<TooltipSimple title={String(value)} side="top" align="start">
<Typography.Text className="value-string" truncate={1}>
{String(value)}
</Typography.Text>

View File

@@ -32,6 +32,11 @@ 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',

View File

@@ -163,12 +163,23 @@
}
&.filter-visible {
// Width is owned by ResizableBox (inline style); this section is the
// ResizableBox root, so it stays position: relative for the drag handle.
.api-quick-filter-left-section {
width: 260px;
.resizable-box__content {
display: flex;
flex-direction: column;
}
.quick-filters-container {
flex: 1;
min-height: 0;
}
}
.api-module-right-section {
width: calc(100% - 260px);
flex: 1;
min-width: 0;
}
}
}

View File

@@ -4,21 +4,49 @@ import logEvent from 'api/common/logEvent';
import cx from 'classnames';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import { LOCALSTORAGE } from 'constants/localStorage';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { ResizableBox } from 'periscope/components/ResizableBox';
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
import DomainList from './Domains/DomainList';
import './Explorer.styles.scss';
const QUICK_FILTERS_DEFAULT_WIDTH = 260;
const QUICK_FILTERS_MIN_WIDTH = 240;
const QUICK_FILTERS_MAX_WIDTH = 500;
function Explorer(): JSX.Element {
useEffect(() => {
logEvent('API Monitoring: Landing page visited', {});
}, []);
const {
initialWidth: quickFiltersInitialWidth,
persistWidth: persistQuickFiltersWidth,
} = usePanelWidth({
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_API_MONITORING,
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
minWidth: QUICK_FILTERS_MIN_WIDTH,
maxWidth: QUICK_FILTERS_MAX_WIDTH,
});
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div className={cx('api-monitoring-page', 'filter-visible')}>
<section className="api-quick-filter-left-section">
<ResizableBox
handle="right"
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
initialWidth={quickFiltersInitialWidth}
minWidth={QUICK_FILTERS_MIN_WIDTH}
maxWidth={QUICK_FILTERS_MAX_WIDTH}
onResize={persistQuickFiltersWidth}
resetToDefaultOnDoubleClick
withHandle
className="api-quick-filter-left-section"
handleTestId="quick-filters-resize-handle"
>
<QuickFilters
className="qf-api-monitoring"
source={QuickFiltersSource.API_MONITORING}
@@ -27,7 +55,7 @@ function Explorer(): JSX.Element {
showQueryName={false}
handleFilterVisibilityChange={(): void => {}}
/>
</section>
</ResizableBox>
<DomainList />
</div>
</Sentry.ErrorBoundary>

View File

@@ -6,6 +6,7 @@ import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import { InfraMonitoringEvents } from 'constants/events';
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import K8sBaseDetails from 'container/InfraMonitoringK8s/Base/K8sBaseDetails';
import { K8sBaseList } from 'container/InfraMonitoringK8s/Base/K8sBaseList';
import { K8sBaseFilters } from 'container/InfraMonitoringK8s/Base/types';
@@ -16,6 +17,8 @@ import {
} from 'container/InfraMonitoringK8s/hooks';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { ResizableBox } from 'periscope/components/ResizableBox';
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
import { useAppContext } from 'providers/App/App';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
@@ -40,8 +43,21 @@ import { getHostsQuickFiltersConfig } from './utils';
import styles from './InfraMonitoringHosts.module.scss';
import { ArrowUpToLine, Filter } from '@signozhq/icons';
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
const QUICK_FILTERS_MIN_WIDTH = 240;
const QUICK_FILTERS_MAX_WIDTH = 500;
function Hosts(): JSX.Element {
const [showFilters, setShowFilters] = useState(true);
const {
initialWidth: quickFiltersInitialWidth,
persistWidth: persistQuickFiltersWidth,
} = usePanelWidth({
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_INFRA,
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
minWidth: QUICK_FILTERS_MIN_WIDTH,
maxWidth: QUICK_FILTERS_MAX_WIDTH,
});
const [, setCurrentPage] = useInfraMonitoringPageListing();
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
@@ -139,7 +155,18 @@ function Hosts(): JSX.Element {
<div className={styles.infraMonitoringContainer}>
<div className={styles.infraContentRow}>
{showFilters && (
<div className={styles.quickFiltersContainer}>
<ResizableBox
handle="right"
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
initialWidth={quickFiltersInitialWidth}
minWidth={QUICK_FILTERS_MIN_WIDTH}
maxWidth={QUICK_FILTERS_MAX_WIDTH}
onResize={persistQuickFiltersWidth}
resetToDefaultOnDoubleClick
withHandle
className={styles.quickFiltersContainer}
handleTestId="quick-filters-resize-handle"
>
<div className={styles.quickFiltersContainerHeader}>
<Typography.Text>Filters</Typography.Text>
<Tooltip title="Collapse Filters">
@@ -156,7 +183,7 @@ function Hosts(): JSX.Element {
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleQuickFiltersChange}
/>
</div>
</ResizableBox>
)}
<div
className={`${styles.listContainer}${

View File

@@ -44,26 +44,20 @@
}
.quickFiltersContainer {
width: 280px;
min-width: 280px;
// Width is owned by ResizableBox (inline style). No overflow here: the panel
// must not scroll (QuickFilters scrolls its own list internally) — an
// overflow on the root would also clip the edge-mounted resize grip.
flex-shrink: 0;
border-right: 1px solid var(--l1-border);
overflow-y: auto;
&::-webkit-scrollbar {
width: 0.1rem;
height: 0.1rem;
:global(.resizable-box__content) {
display: flex;
flex-direction: column;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--accent-primary);
}
&::-webkit-scrollbar-thumb:hover {
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
:global(.quick-filters-container) {
flex: 1;
min-height: 0;
}
:global(.ant-collapse-header) {
@@ -142,7 +136,9 @@
}
.listContainerFiltersVisible {
max-width: calc(100% - 280px);
// Width is driven by flex now that the filters panel is resizable; the list
// fills whatever space the (variable-width) panel leaves.
max-width: 100%;
}
.quickFiltersToggleContainer {

View File

@@ -44,26 +44,15 @@
}
.quickFiltersContainer {
width: 280px;
min-width: 280px;
// Width is owned by ResizableBox (inline style). No overflow here: the panel
// must not scroll (QuickFilters scrolls its own list internally) — an
// overflow on the root would also clip the edge-mounted resize grip.
flex-shrink: 0;
border-right: 1px solid var(--l1-border);
overflow-y: auto;
&::-webkit-scrollbar {
width: 0.1rem;
height: 0.1rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--accent-primary);
}
&::-webkit-scrollbar-thumb:hover {
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
:global(.resizable-box__content) {
display: flex;
flex-direction: column;
}
:global(.quick-filters) {
@@ -128,7 +117,9 @@
}
.listContainerFiltersVisible {
max-width: calc(100% - 280px);
// Width is driven by flex now that the filters panel is resizable; the list
// fills whatever space the (variable-width) panel leaves.
max-width: 100%;
}
.categorySelectorSection {
@@ -219,8 +210,16 @@
.quickFiltersSection {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow-y: auto;
:global(.quick-filters-container) {
flex: 1;
min-height: 0;
}
&::-webkit-scrollbar {
width: 0.1rem;
}

View File

@@ -6,6 +6,7 @@ import logEvent from 'api/common/logEvent';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import { InfraMonitoringEvents } from 'constants/events';
import { LOCALSTORAGE } from 'constants/localStorage';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import {
@@ -22,6 +23,8 @@ import {
Workflow,
} from '@signozhq/icons';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { ResizableBox } from 'periscope/components/ResizableBox';
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { FeatureKeys } from '../../constants/features';
@@ -56,9 +59,23 @@ import K8sVolumesList from './Volumes/K8sVolumesList';
import styles from './InfraMonitoringK8s.module.scss';
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
const QUICK_FILTERS_MIN_WIDTH = 240;
const QUICK_FILTERS_MAX_WIDTH = 500;
export default function InfraMonitoringK8s(): JSX.Element {
const [showFilters, setShowFilters] = useState(true);
const {
initialWidth: quickFiltersInitialWidth,
persistWidth: persistQuickFiltersWidth,
} = usePanelWidth({
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_INFRA,
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
minWidth: QUICK_FILTERS_MIN_WIDTH,
maxWidth: QUICK_FILTERS_MAX_WIDTH,
});
const [selectedCategory, setSelectedCategory] = useInfraMonitoringCategory();
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
const [, setGroupBy] = useInfraMonitoringGroupBy();
@@ -212,7 +229,18 @@ export default function InfraMonitoringK8s(): JSX.Element {
<div className={styles.infraMonitoringContainer}>
<div className={styles.infraContentRow}>
{showFilters && (
<div className={styles.quickFiltersContainer}>
<ResizableBox
handle="right"
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
initialWidth={quickFiltersInitialWidth}
minWidth={QUICK_FILTERS_MIN_WIDTH}
maxWidth={QUICK_FILTERS_MAX_WIDTH}
onResize={persistQuickFiltersWidth}
resetToDefaultOnDoubleClick
withHandle
className={styles.quickFiltersContainer}
handleTestId="quick-filters-resize-handle"
>
<div className={styles.categorySelectorSection}>
<div className={styles.sectionHeader} data-type="resource">
<Typography.Text className={styles.sectionLabel}>
@@ -265,7 +293,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
/>
)}
</div>
</div>
</ResizableBox>
)}
<div

View File

@@ -3,12 +3,23 @@
flex-direction: row;
.meter-explorer-quick-filters-section {
width: 280px;
// Width is owned by ResizableBox (inline style).
flex-shrink: 0;
border-right: 1px solid var(--l1-border);
&.hidden {
display: none;
}
.resizable-box__content {
display: flex;
flex-direction: column;
}
.quick-filters-container {
flex: 1;
min-height: 0;
}
}
.meter-explorer-content-section {
@@ -78,7 +89,9 @@
&.quick-filters-open {
.meter-explorer-content-section {
width: calc(100% - 280px);
flex: 1;
width: auto;
min-width: 0;
}
}

View File

@@ -7,6 +7,7 @@ import cx from 'classnames';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import { LOCALSTORAGE } from 'constants/localStorage';
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
@@ -18,6 +19,8 @@ import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { Filter } from '@signozhq/icons';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { ResizableBox } from 'periscope/components/ResizableBox';
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
import { Dashboard } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
@@ -30,6 +33,10 @@ import { splitQueryIntoOneChartPerQuery } from './utils';
import './Explorer.styles.scss';
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
const QUICK_FILTERS_MIN_WIDTH = 240;
const QUICK_FILTERS_MAX_WIDTH = 500;
function Explorer(): JSX.Element {
const {
handleRunQuery,
@@ -55,6 +62,16 @@ function Explorer(): JSX.Element {
const [showQuickFilters, setShowQuickFilters] = useState(true);
const {
initialWidth: quickFiltersInitialWidth,
persistWidth: persistQuickFiltersWidth,
} = usePanelWidth({
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_METER,
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
minWidth: QUICK_FILTERS_MIN_WIDTH,
maxWidth: QUICK_FILTERS_MAX_WIDTH,
});
const defaultQuery = useMemo(
() =>
updateAllQueriesOperators(
@@ -127,10 +144,19 @@ function Explorer(): JSX.Element {
'quick-filters-open': showQuickFilters,
})}
>
<div
<ResizableBox
handle="right"
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
initialWidth={quickFiltersInitialWidth}
minWidth={QUICK_FILTERS_MIN_WIDTH}
maxWidth={QUICK_FILTERS_MAX_WIDTH}
onResize={persistQuickFiltersWidth}
resetToDefaultOnDoubleClick
withHandle
className={cx('meter-explorer-quick-filters-section', {
hidden: !showQuickFilters,
})}
handleTestId="quick-filters-resize-handle"
>
<QuickFilters
className="qf-meter-explorer"
@@ -142,7 +168,7 @@ function Explorer(): JSX.Element {
setShowQuickFilters(!showQuickFilters);
}}
/>
</div>
</ResizableBox>
<div className="meter-explorer-content-section">
<div className="meter-explorer-explore-content">

View File

@@ -30,4 +30,6 @@ 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,
});

View File

@@ -20,12 +20,23 @@
}
&.filter-visible {
// Width is owned by ResizableBox (inline style); this section is the
// ResizableBox root, so it stays position: relative for the drag handle.
.all-errors-quick-filter-section {
width: 260px;
.resizable-box__content {
display: flex;
flex-direction: column;
}
.quick-filters-container {
flex: 1;
min-height: 0;
}
}
.all-errors-right-section {
width: calc(100% - 260px);
flex: 1;
min-width: 0;
}
}
}

View File

@@ -18,12 +18,18 @@ import Toolbar from 'container/Toolbar/Toolbar';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import history from 'lib/history';
import { isNull } from 'lodash-es';
import { ResizableBox } from 'periscope/components/ResizableBox';
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
import { routes } from './config';
import { useAllErrorsQueryState } from './QueryStateContext';
import './AllErrors.styles.scss';
const QUICK_FILTERS_DEFAULT_WIDTH = 260;
const QUICK_FILTERS_MIN_WIDTH = 240;
const QUICK_FILTERS_MAX_WIDTH = 500;
function AllErrors(): JSX.Element {
const { pathname } = useLocation();
const { handleRunQuery } = useQueryBuilder();
@@ -55,17 +61,38 @@ function AllErrors(): JSX.Element {
setShowFilters((prev) => !prev);
};
const {
initialWidth: quickFiltersInitialWidth,
persistWidth: persistQuickFiltersWidth,
} = usePanelWidth({
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_EXCEPTIONS,
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
minWidth: QUICK_FILTERS_MIN_WIDTH,
maxWidth: QUICK_FILTERS_MAX_WIDTH,
});
return (
<div className={cx('all-errors-page', showFilters ? 'filter-visible' : '')}>
{showFilters && (
<section className={cx('all-errors-quick-filter-section')}>
<ResizableBox
handle="right"
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
initialWidth={quickFiltersInitialWidth}
minWidth={QUICK_FILTERS_MIN_WIDTH}
maxWidth={QUICK_FILTERS_MAX_WIDTH}
onResize={persistQuickFiltersWidth}
resetToDefaultOnDoubleClick
withHandle
className="all-errors-quick-filter-section"
handleTestId="quick-filters-resize-handle"
>
<QuickFilters
className="qf-exceptions"
source={QuickFiltersSource.EXCEPTIONS}
signal={SignalType.EXCEPTIONS}
handleFilterVisibilityChange={handleFilterVisibilityChange}
/>
</section>
</ResizableBox>
)}
<section
className={cx(

View File

@@ -5,3 +5,9 @@
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;
}

View File

@@ -1,4 +1,4 @@
import { type ReactNode, useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { FullScreenHandle } from 'react-full-screen';
import { generatePath } from 'react-router-dom';
import {
@@ -17,7 +17,6 @@ import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import { toast } from '@signozhq/ui/sonner';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { cloneDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
@@ -30,7 +29,6 @@ import APIError from 'types/api/error';
import { USER_ROLES } from 'types/roles';
import ConfirmDeleteDialog from '../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
import DisabledMenuItemLabel from '../../components/DisabledMenuItemLabel/DisabledMenuItemLabel';
import DashboardSettings from '../../DashboardSettings';
import { useAddSection } from '../../PanelsAndSectionsLayout/Section/hooks/useAddSection';
import SectionTitleModal from '../../PanelsAndSectionsLayout/Section/SectionTitleModal';
@@ -60,8 +58,9 @@ function DashboardActions({
onLockToggle,
onOpenRename,
}: DashboardActionsProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const editDisabledReason = useDashboardStore((s) => s.editDisabledReason);
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();
@@ -75,6 +74,14 @@ 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,
});
@@ -114,37 +121,23 @@ function DashboardActions({
});
}, [deleteDashboardMutation]);
// Edit-action label: plain text when editable, else a disabled row whose label
// still shows a hover tooltip explaining why (locked / no permission).
const editLabel = useCallback(
(text: string): ReactNode =>
isEditable ? (
text
) : (
<DisabledMenuItemLabel reason={editDisabledReason}>
{text}
</DisabledMenuItemLabel>
),
[isEditable, editDisabledReason],
);
const menuItems = useMemo<MenuItem[]>(() => {
const dashboardGroup: MenuItem[] = [
{
const dashboardGroup: MenuItem[] = [];
if (canEdit) {
dashboardGroup.push({
key: 'rename',
label: editLabel('Rename'),
label: 'Rename',
icon: <PenLine size={14} />,
disabled: !isEditable,
onClick: onOpenRename,
},
{
key: 'clone',
label: 'Clone dashboard',
icon: <Copy size={14} />,
disabled: isCloning,
onClick: (): void => void handleClone(),
},
];
});
}
dashboardGroup.push({
key: 'clone',
label: 'Clone dashboard',
icon: <Copy size={14} />,
disabled: isCloning,
onClick: (): void => void handleClone(),
});
if (isAuthor || user.role === USER_ROLES.ADMIN) {
dashboardGroup.push({
key: 'lock',
@@ -161,40 +154,45 @@ function DashboardActions({
onClick: handle.enter,
});
return [
const layoutGroup: MenuItem[] = [];
if (canEdit) {
layoutGroup.push({
key: 'new-section',
label: 'New section',
icon: <SquareStack size={14} />,
onClick: (): void => setIsNewSectionOpen(true),
});
}
const items: MenuItem[] = [
{
type: 'group',
key: 'group-dashboard',
label: 'Dashboard',
children: dashboardGroup,
},
{
];
if (layoutGroup.length > 0) {
items.push({
type: 'group',
key: 'group-layout',
label: 'Layout',
children: [
{
key: 'new-section',
label: editLabel('New section'),
icon: <SquareStack size={14} />,
disabled: !isEditable,
onClick: (): void => setIsNewSectionOpen(true),
},
],
},
children: layoutGroup,
});
}
items.push(
{ type: 'divider', key: 'divider-danger' },
{
key: 'delete',
label: editLabel('Delete dashboard'),
label: 'Delete dashboard',
icon: <Trash2 size={14} />,
danger: true,
disabled: !isEditable,
onClick: (): void => setIsDeleteOpen(true),
},
];
);
return items;
}, [
editLabel,
isEditable,
canEdit,
isCloning,
isAuthor,
user.role,
@@ -206,16 +204,6 @@ function DashboardActions({
handle.enter,
]);
// A disabled edit control stays visible with a tooltip explaining why.
const withDisabledTooltip = (node: JSX.Element): JSX.Element =>
isEditable ? (
node
) : (
<TooltipSimple title={editDisabledReason} disableHoverableContent>
{node}
</TooltipSimple>
);
return (
<div className={styles.dashboardActionsContainer}>
<DropdownMenuSimple menu={{ items: menuItems }}>
@@ -223,35 +211,43 @@ function DashboardActions({
variant="solid"
color="secondary"
size="md"
className={styles.toolbarButton}
prefix={<Grid3X3 size="md" />}
testId="options"
>
Actions
</Button>
</DropdownMenuSimple>
{withDisabledTooltip(
<Button
variant="solid"
color="secondary"
prefix={<Configure size="md" />}
testId="show-drawer"
disabled={!isEditable}
onClick={(): void => setIsSettingsDrawerOpen(true)}
size="md"
>
Configure
</Button>,
{canEdit && (
<>
<Button
variant="solid"
color="secondary"
className={styles.toolbarButton}
prefix={<Configure size="md" />}
testId="show-drawer"
onClick={(): void => setIsSettingsDrawerOpen(true)}
size="md"
>
Configure
</Button>
<SettingsDrawer
drawerTitle="Dashboard Configuration"
isOpen={isSettingsDrawerOpen}
destroyOnClose
onClose={(): void => {
setIsSettingsDrawerOpen(false);
clearSettingsRequest();
}}
>
<DashboardSettings dashboard={dashboard} />
</SettingsDrawer>
</>
)}
<SettingsDrawer
drawerTitle="Dashboard Configuration"
isOpen={isSettingsDrawerOpen}
onClose={(): void => setIsSettingsDrawerOpen(false)}
>
<DashboardSettings dashboard={dashboard} />
</SettingsDrawer>
<Button
variant="solid"
color="secondary"
className={styles.toolbarButton}
prefix={<Braces size="md" />}
testId="edit-json"
onClick={(): void => setIsJsonEditorOpen(true)}
@@ -259,18 +255,17 @@ function DashboardActions({
>
JSON
</Button>
{withDisabledTooltip(
{!isDashboardLocked && (
<Button
variant="solid"
color="primary"
onClick={onAddPanel}
prefix={<Plus size="md" />}
testId="add-panel-header"
disabled={!isEditable}
size="md"
>
New Panel
</Button>,
</Button>
)}
<JsonEditorDrawer
dashboard={dashboard}

View File

@@ -19,6 +19,7 @@ import { openInNewTab } from 'utils/navigation';
import styles from './DashboardInfo.module.scss';
import { useVisibleTagCount } from './useVisibleTagCount';
import { DASHBOARD_NAME_MAX_LENGTH } from '../../constants';
import { useDashboardStore } from '../../store/useDashboardStore';
interface DashboardInfoProps {
@@ -99,7 +100,7 @@ function DashboardInfo({
autoFocus
value={draft}
testId="dashboard-title-input"
maxLength={120}
maxLength={DASHBOARD_NAME_MAX_LENGTH}
className={styles.dashboardTitleInput}
onChange={(e): void => onDraftChange(e.target.value)}
onKeyDown={onKeyDown}

View File

@@ -14,7 +14,6 @@ import { defineJsonEditorTheme, JSON_EDITOR_THEME } from './editorTheme';
import styles from './JsonEditorDrawer.module.scss';
import JsonEditorToolbar from './JsonEditorToolbar';
import { useJsonEditor } from './useJsonEditor';
import { useDashboardStore } from '../../store/useDashboardStore';
interface JsonEditorDrawerProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
@@ -29,12 +28,6 @@ function JsonEditorDrawer({
}: JsonEditorDrawerProps): JSX.Element {
const [, copyToClipboard] = useCopyToClipboard();
const isEditable = useDashboardStore((s) => s.isEditable);
const readOnlyReason = useDashboardStore((s) => s.editDisabledReason);
// Locked/no-permission dashboards open the JSON for inspection only — Apply,
// Format and Reset are disabled and edits can't be saved.
const readOnly = !isEditable;
const {
draft,
setDraft,
@@ -46,7 +39,7 @@ function JsonEditorDrawer({
format,
reset,
apply,
} = useJsonEditor({ dashboard, isOpen, readOnly, onApplied: onClose });
} = useJsonEditor({ dashboard, isOpen, onApplied: onClose });
const onCopy = useCallback((): void => {
copyToClipboard(draft);
@@ -70,15 +63,13 @@ function JsonEditorDrawer({
event.stopPropagation();
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault();
if (!readOnly) {
void apply();
}
void apply();
}
},
[apply, readOnly],
[apply],
);
const applyDisabled = readOnly || !isDirty || !validity.valid || isSaving;
const applyDisabled = !isDirty || !validity.valid || isSaving;
const validationText = validity.valid
? `Valid JSON · ${validity.lineCount} lines`
: `Line ${validity.errorLine ?? '?'} · ${validity.message ?? 'Invalid JSON'}`;
@@ -159,30 +150,16 @@ function JsonEditorDrawer({
>
Cancel
</Button>
{readOnly ? (
<TooltipSimple title={readOnlyReason} disableHoverableContent>
<Button
variant="solid"
color="primary"
size="md"
testId="json-editor-apply"
disabled
>
Apply changes
</Button>
</TooltipSimple>
) : (
<Button
variant="solid"
color="primary"
size="md"
testId="json-editor-apply"
disabled={applyDisabled}
onClick={(): void => void apply()}
>
Apply changes
</Button>
)}
<Button
variant="solid"
color="primary"
size="md"
testId="json-editor-apply"
disabled={applyDisabled}
onClick={(): void => void apply()}
>
Apply changes
</Button>
</div>
</div>
}
@@ -191,7 +168,6 @@ function JsonEditorDrawer({
<div className={styles.body} onKeyDown={onKeyDown}>
<JsonEditorToolbar
isDirty={isDirty}
readOnly={readOnly}
onFormat={format}
onCopy={onCopy}
onDownload={onDownload}
@@ -204,7 +180,6 @@ function JsonEditorDrawer({
value={draft}
onChange={(value): void => setDraft(value ?? '')}
options={{
readOnly,
scrollbar: { alwaysConsumeMouseWheel: false },
minimap: { enabled: false },
fontSize: 13,

View File

@@ -5,8 +5,6 @@ import styles from './JsonEditorToolbar.module.scss';
interface JsonEditorToolbarProps {
isDirty: boolean;
/** Locked/no-permission — Format and Reset (draft mutators) are disabled. */
readOnly?: boolean;
onFormat: () => void;
onCopy: () => void;
onDownload: () => void;
@@ -15,7 +13,6 @@ interface JsonEditorToolbarProps {
function JsonEditorToolbar({
isDirty,
readOnly = false,
onFormat,
onCopy,
onDownload,
@@ -29,7 +26,6 @@ function JsonEditorToolbar({
size="sm"
prefix={<AlignLeft size={14} />}
testId="json-editor-format"
disabled={readOnly}
onClick={onFormat}
>
Format
@@ -61,7 +57,7 @@ function JsonEditorToolbar({
size="sm"
prefix={<RotateCcw size={14} />}
testId="json-editor-reset"
disabled={readOnly || !isDirty}
disabled={!isDirty}
onClick={onReset}
>
Reset

View File

@@ -7,13 +7,6 @@ import { useJsonEditor } from '../useJsonEditor';
jest.mock('../useJsonEditor', () => ({ useJsonEditor: jest.fn() }));
// Editable by default so the drawer renders in its editable (non-read-only) mode.
jest.mock('../../../store/useDashboardStore', () => ({
useDashboardStore: (
selector: (s: { isEditable: boolean; editDisabledReason: string }) => unknown,
): unknown => selector({ isEditable: true, editDisabledReason: '' }),
}));
jest.mock('@monaco-editor/react', () => ({
__esModule: true,
default: ({

View File

@@ -23,8 +23,6 @@ export interface JsonValidity {
interface Params {
dashboard: DashboardtypesGettableDashboardV2DTO;
isOpen: boolean;
/** Locked/no-permission — `apply` is a no-op so edits can never be saved. */
readOnly?: boolean;
onApplied: () => void;
}
@@ -79,7 +77,6 @@ function errorLineFromMessage(
export function useJsonEditor({
dashboard,
isOpen,
readOnly = false,
onApplied,
}: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
@@ -150,7 +147,7 @@ export function useJsonEditor({
}, [appliedText]);
const apply = useCallback(async (): Promise<void> => {
if (readOnly || !validity.valid || !isDirty) {
if (!validity.valid || !isDirty) {
return;
}
try {
@@ -176,7 +173,6 @@ export function useJsonEditor({
validity.valid,
isDirty,
draft,
readOnly,
refetch,
onApplied,
showErrorModal,

View File

@@ -8,6 +8,8 @@ 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({
@@ -15,6 +17,7 @@ function SettingsDrawer({
drawerTitle,
isOpen,
onClose,
destroyOnClose = false,
}: SettingsDrawerProps): JSX.Element {
return (
<Drawer
@@ -23,6 +26,7 @@ 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. */}

View File

@@ -1,17 +1,14 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQueryClient } from 'react-query';
import { FullScreenHandle } from 'react-full-screen';
import { toast } from '@signozhq/ui/sonner';
import logEvent from 'api/common/logEvent';
import {
getGetDashboardV2QueryKey,
lockDashboardV2,
unlockDashboardV2,
} from 'api/generated/services/dashboard';
import type {
DashboardtypesGettableDashboardV2DTO,
DashboardtypesJSONPatchOperationDTO,
GetDashboardV2200,
} from 'api/generated/services/sigNoz.schemas';
import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
@@ -35,13 +32,13 @@ import styles from './DashboardPageToolbar.module.scss';
interface DashboardPageToolbarProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
handle: FullScreenHandle;
refetch: () => void;
}
function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
const { dashboard, handle } = props;
const { dashboard, handle, refetch } = props;
const id = dashboard.id;
const queryClient = useQueryClient();
// Session-local lock state: the toggle appears once locked and persists for the page.
const [isDashboardLocked, setIsDashboardLocked] = useState(!!dashboard.locked);
@@ -104,21 +101,12 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
await unlockDashboardV2({ id });
toast.success('Dashboard unlocked');
}
// Patch just the `locked` flag in the cache — a full refetch would reload
// every panel's chart data for a metadata-only change.
const key = getGetDashboardV2QueryKey({ id });
const cached = queryClient.getQueryData<GetDashboardV2200>(key);
if (cached) {
queryClient.setQueryData<GetDashboardV2200>(key, {
...cached,
data: { ...cached.data, locked: next },
});
}
refetch();
} catch (error) {
setIsDashboardLocked(!next);
showErrorModal(error as APIError);
}
}, [id, isDashboardLocked, queryClient, showErrorModal]);
}, [id, isDashboardLocked, refetch, showErrorModal]);
const onNameSave = useCallback(
async (next: string): Promise<void> => {

View File

@@ -12,6 +12,7 @@ import { Input as AntdInput } from 'antd';
import TagKeyValueInput from 'components/TagKeyValueInput/TagKeyValueInput';
import { Base64Icons } from '../utils';
import { DASHBOARD_NAME_MAX_LENGTH } from '../../../constants';
import settingsStyles from '../../DashboardSettings.module.scss';
import styles from './DashboardInfoForm.module.scss';
@@ -71,6 +72,7 @@ function DashboardInfoForm({
testId="dashboard-name"
className={styles.dashboardNameInput}
value={title}
maxLength={DASHBOARD_NAME_MAX_LENGTH}
onChange={(e): void => onTitleChange(e.target.value)}
/>
</section>

View File

@@ -40,7 +40,7 @@ function DynamicVariableFields({
attributeError,
}: DynamicVariableFieldsProps): JSX.Element {
const [search, setSearch] = useState('');
const debouncedSearch = useDebounce(search, 300);
const debouncedSearch = useDebounce(search, 500);
const apiSignal = signalForApi(signal);
const {
@@ -82,38 +82,14 @@ function DynamicVariableFields({
return (
<>
<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.sortSelect}
popupMatchSelectWidth={false}
value={signal}
options={DYNAMIC_SIGNALS.map((s) => ({
label: DYNAMIC_SIGNAL_LABEL[s],
value: s,
}))}
onChange={(value): void =>
onChange({ dynamicSignal: value as DynamicSignalOption })
}
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>
{/* 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.searchSelect}
className={styles.dynamicFieldSelect}
showSearch
value={attribute || undefined}
placeholder="Select a telemetry field"
placeholder="Select a field"
loading={isLoading}
options={options}
onSearch={setSearch}
@@ -126,6 +102,25 @@ function DynamicVariableFields({
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} />}
/>
<Select
className={styles.dynamicSourceSelect}
popupMatchSelectWidth={false}
value={signal}
options={DYNAMIC_SIGNALS.map((s) => ({
label: DYNAMIC_SIGNAL_LABEL[s],
value: s,
}))}
onChange={(value): void =>
onChange({ dynamicSignal: value as DynamicSignalOption })
}
data-testid="variable-signal-select"
/>
</div>
{attributeError ? (
<Typography.Text className={styles.errorText}>

View File

@@ -138,7 +138,7 @@
align-items: center;
gap: 6px;
min-height: 24px;
padding: 6px 14px;
padding: 4px 16px !important;
white-space: nowrap;
border-radius: 0;
color: var(--l2-foreground);
@@ -157,6 +157,7 @@
.betaTag {
margin-left: 4px;
vertical-align: middle;
}
/* Query */
@@ -264,6 +265,7 @@
flex-flow: wrap;
gap: 8px;
padding: 4.5px 11px;
max-height: 120px;
overflow-y: auto;
}
@@ -328,16 +330,54 @@
/* 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;
:global(.ant-select-selector) {
border-color: var(--l2-border) !important;
// 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;
}
}
/* 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;

View File

@@ -1,3 +1,4 @@
import { useRef, useState } from 'react';
import { Check, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
@@ -6,6 +7,7 @@ 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';
@@ -30,9 +32,19 @@ 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,
@@ -54,7 +66,12 @@ function VariableForm({
showAllOptionField,
payloadVariables,
handleSave,
} = useVariableForm({ initial, siblings, isNew, onSave });
} = useVariableForm({
initial,
siblings,
isNew,
onSave: (next): void => onSave(next, selectedPanelIdsRef.current),
});
// 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
@@ -101,6 +118,20 @@ 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>

View File

@@ -46,8 +46,12 @@ export interface UseVariableForm {
handleSave: () => void;
}
const readDefaultValue = (model: VariableFormModel): string =>
((model.defaultValue as { value?: string })?.value ?? '') as string;
// `defaultValue` is a string | string[] on the wire; the editor uses a single
// string, so take the first when it's an array.
const readDefaultValue = (model: VariableFormModel): string => {
const dv = model.defaultValue;
return Array.isArray(dv) ? (dv[0] ?? '') : (dv ?? '');
};
/** Form state, derivations and handlers for the variable editor. */
export function useVariableForm({

View File

@@ -3,18 +3,13 @@ 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;
@@ -25,9 +20,11 @@ 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 (drag handle + meta + inline actions). */
/** A single draggable variable row in the two-column (name / description) table. */
function VariableRow({
variable,
index,
@@ -37,6 +34,7 @@ function VariableRow({
onRequestDelete,
onConfirmDelete,
onCancelDelete,
onApplyToAll,
}: VariableRowProps): JSX.Element {
const {
attributes,
@@ -61,7 +59,7 @@ function VariableRow({
className={styles.row}
data-testid={`variable-row-${variable.name}`}
>
<div className={styles.rowMain}>
<div className={styles.varCell}>
{canEdit ? (
<span
ref={setActivatorNodeRef}
@@ -74,65 +72,94 @@ function VariableRow({
</span>
) : null}
<Typography.Text className={styles.varName}>
${variable.name}
{variable.name}
</Typography.Text>
<span className={styles.typeTag}>{TYPE_LABEL[variable.type]}</span>
</div>
<div className={styles.descCell}>
{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>
);
}

View File

@@ -4,7 +4,7 @@
gap: 16px;
}
.header {
.footer {
display: flex;
justify-content: flex-end;
gap: 16px;
@@ -28,69 +28,125 @@
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 {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--l1-border);
border-radius: 4px;
position: relative;
display: grid;
grid-template-columns: $grid-columns;
padding: 0 16px;
background: var(--l1-background);
&:not(:last-child) {
border-bottom: 1px solid var(--l2-border);
}
&:hover {
background: var(--l2-background);
}
}
.rowMain {
.varCell {
display: flex;
align-items: center;
gap: 10px;
align-items: flex-start;
gap: 8px;
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 {
font-weight: 500;
color: var(--l1-foreground);
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;
}
.varDesc {
min-width: 0;
overflow: hidden;
font-size: 12px;
color: var(--l2-foreground);
text-overflow: ellipsis;
white-space: nowrap;
font-family: 'Space Mono', monospace;
font-size: 13px;
color: var(--bg-sienna-400);
white-space: pre-wrap;
word-break: break-word;
}
.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;
.varDescEmpty {
color: var(--l3-foreground);
}
.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 {
@@ -98,3 +154,8 @@
font-size: 12px;
color: var(--l2-foreground);
}
.applyAllButton {
font-size: 12px;
white-space: nowrap;
}

View File

@@ -25,6 +25,7 @@ interface VariablesListProps {
onConfirmDelete: (index: number) => void;
onCancelDelete: () => void;
onMove: (from: number, to: number) => void;
onApplyToAll: (index: number) => void;
}
function VariablesList({
@@ -36,6 +37,7 @@ function VariablesList({
onConfirmDelete,
onCancelDelete,
onMove,
onApplyToAll,
}: VariablesListProps): JSX.Element {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 1 } }),
@@ -53,32 +55,39 @@ function VariablesList({
};
return (
<DndContext
sensors={sensors}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
>
<SortableContext
items={variables.map((v) => v.name)}
strategy={verticalListSortingStrategy}
<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}
>
<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>
<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>
);
}

View File

@@ -0,0 +1,231 @@
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']);
});
});

View File

@@ -0,0 +1,179 @@
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;
});
}

View File

@@ -0,0 +1,16 @@
.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;
}

View File

@@ -0,0 +1,67 @@
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;

View File

@@ -4,6 +4,7 @@ import { Typography } from '@signozhq/ui/typography';
import { Input as AntdInput } from 'antd';
import styles from './VariableInfoForm.module.scss';
import { DASHBOARD_NAME_MAX_LENGTH } from '../../../../constants';
import variableFormStyles from '../../VariableForm/VariableForm.module.scss';
interface VariableInfoFormProps {
@@ -30,6 +31,7 @@ function VariableInfoForm({
testId="variable-name"
className={styles.variableNameInput}
value={title}
maxLength={DASHBOARD_NAME_MAX_LENGTH}
onChange={(e): void => onTitleChange(e.target.value)}
placeholder="Unique name of the variable"
/>

View File

@@ -1,9 +1,16 @@
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 {
@@ -14,6 +21,7 @@ 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';
@@ -23,7 +31,13 @@ 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),
@@ -38,10 +52,13 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboard.updatedAt]);
const [isEditing, setIsEditing] = useState<EditingState>(null);
const [isEditing, setIsEditing] = useState<EditingState>(
openAddOnMount && isEditable ? { type: 'new' } : null,
);
const [confirmDeleteIndex, setConfirmDeleteIndex] = useState<number | null>(
null,
);
const [applyToAllIndex, setApplyToAllIndex] = useState<number | null>(null);
const editingFormModel: VariableFormModel | null = useMemo(() => {
if (!isEditing) {
@@ -57,20 +74,64 @@ 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): void => {
const handleFormSave = (
formModel: VariableFormModel,
selectedPanelIds: string[],
): 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);
persist(next);
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');
}
})();
};
const handleMove = (from: number, to: number): void => {
@@ -88,6 +149,32 @@ 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
@@ -95,6 +182,8 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
siblings={siblings}
isNew={isEditing?.type === 'new'}
isSaving={isSaving}
panelOptions={panelOptions}
appliedPanelIds={appliedPanelIds}
onClose={(): void => setIsEditing(null)}
onSave={handleFormSave}
/>
@@ -108,9 +197,6 @@ 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}
@@ -120,9 +206,20 @@ 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>
);
}

View File

@@ -6,6 +6,11 @@ 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. */
@@ -13,6 +18,10 @@ 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) => void;
onSave: (model: VariableFormModel, selectedPanelIds: string[]) => void;
}

View File

@@ -14,6 +14,7 @@ import type {
import {
DYNAMIC_SIGNAL_ALL,
DYNAMIC_SIGNALS,
type DynamicSignalOption,
emptyVariableFormModel,
signalForApi,
@@ -68,9 +69,12 @@ export function dtoToFormModel(
...listCommon,
type: 'DYNAMIC',
dynamicAttribute: plugin.spec.name ?? '',
// An omitted wire signal means "all telemetry".
dynamicSignal:
(plugin.spec.signal as DynamicSignalOption) ?? DYNAMIC_SIGNAL_ALL,
// 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,
};
}
// Default to Query (also covers a query plugin or a missing/unknown plugin).

View File

@@ -17,6 +17,7 @@ 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 {
@@ -38,6 +39,9 @@ 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;
@@ -69,7 +73,7 @@ function DashboardSettings({ dashboard }: DashboardSettingsProps): JSX.Element {
);
return (
<TabsRoot defaultValue={TabKeys.OVERVIEW}>
<TabsRoot defaultValue={settingsRequest?.tab ?? TabKeys.OVERVIEW}>
<TabsList variant="primary">
{Object.values(TabKeys).map((key) => (
<TabsTrigger value={key} key={key}>

View File

@@ -14,6 +14,7 @@ import ConfigActions from './ConfigActions/ConfigActions';
import SectionSlot from './SectionSlot/SectionSlot';
import styles from './ConfigPane.module.scss';
import { DASHBOARD_NAME_MAX_LENGTH } from '../../constants';
import { PanelKind } from '../../Panels/types/panelKind';
interface ConfigPaneProps {
@@ -86,6 +87,7 @@ function ConfigPane({
data-testid="panel-editor-v2-title"
value={spec.display.name}
placeholder="Panel title"
maxLength={DASHBOARD_NAME_MAX_LENGTH}
onChange={(e): void => setDisplayField('name', e.target.value)}
/>
</div>

View File

@@ -3,7 +3,6 @@ import { SolidAlertTriangle, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Divider } from '@signozhq/ui/divider';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { useConfirmableAction } from 'hooks/useConfirmableAction';
@@ -13,9 +12,6 @@ interface HeaderProps {
isDirty: boolean;
isSaving: boolean;
showSwitchToView?: boolean;
/** Locked/no-permission dashboard — Save is disabled with a reason. */
readOnly?: boolean;
readOnlyReason?: string;
onSave: () => void;
onSwitchToView?: () => void;
onClose: () => void;
@@ -25,8 +21,6 @@ function Header({
isDirty,
isSaving,
showSwitchToView = false,
readOnly = false,
readOnlyReason,
onSave,
onSwitchToView,
onClose,
@@ -69,29 +63,16 @@ function Header({
Switch to View Mode
</Button>
)}
{readOnly ? (
<TooltipSimple title={readOnlyReason} disableHoverableContent>
<Button
variant="solid"
color="primary"
data-testid="panel-editor-v2-save"
disabled
>
Save changes
</Button>
</TooltipSimple>
) : (
<Button
variant="solid"
color="primary"
data-testid="panel-editor-v2-save"
disabled={!isDirty || isSaving}
loading={isSaving}
onClick={onSave}
>
Save changes
</Button>
)}
<Button
variant="solid"
color="primary"
data-testid="panel-editor-v2-save"
disabled={!isDirty || isSaving}
loading={isSaving}
onClick={onSave}
>
Save changes
</Button>
</div>
<DialogWrapper

View File

@@ -159,8 +159,6 @@ function makePanel(
const baseProps = {
dashboardId: 'dash-1',
panelId: 'panel-1',
isEditable: true,
editDisabledReason: '',
onClose: jest.fn(),
onSaved: jest.fn(),
};

View File

@@ -43,10 +43,6 @@ interface PanelEditorContainerProps {
isNew?: boolean;
/** Target section for a new panel; falls back to the last/new section. */
layoutIndex?: number;
/** The dashboard can be edited (unlocked + permission); gates Save. */
isEditable: boolean;
/** Why Save is disabled (locked / no permission); '' when editable. */
editDisabledReason: string;
/** Leave the editor (navigate back to the dashboard) without saving. */
onClose: () => void;
/** Called after a successful save — navigates back to the dashboard. */
@@ -64,8 +60,6 @@ function PanelEditorContainer({
panel,
isNew = false,
layoutIndex,
isEditable,
editDisabledReason,
onClose,
onSaved,
}: PanelEditorContainerProps): JSX.Element {
@@ -204,9 +198,6 @@ function PanelEditorContainer({
});
const onSave = useCallback(async (): Promise<void> => {
if (!isEditable) {
return;
}
try {
// Bake the live query into the spec so unstaged edits are saved too.
await save(buildSaveSpec(draft.spec));
@@ -215,7 +206,7 @@ function PanelEditorContainer({
} catch {
toast.error('Failed to save panel');
}
}, [isEditable, save, buildSaveSpec, draft.spec, onSaved]);
}, [save, buildSaveSpec, draft.spec, onSaved]);
return (
<div className={styles.page} data-testid="panel-editor-v2">
@@ -223,8 +214,6 @@ function PanelEditorContainer({
isDirty={isDirty}
isSaving={isSaving}
showSwitchToView={!isNew}
readOnly={!isEditable}
readOnlyReason={editDisabledReason}
onSave={onSave}
onSwitchToView={onSwitchToView}
onClose={onClose}

View File

@@ -1,37 +1,60 @@
import { Clock, RotateCw } from '@signozhq/icons';
import { CalendarRange, Clock, RotateCw } from '@signozhq/icons';
import PanelMessage from '../PanelMessage/PanelMessage';
import {
selectViewPanelExtendWindow,
useViewPanelStore,
} from '../../../store/useViewPanelStore';
import PanelLoader from '../PanelLoader/PanelLoader';
import PanelMessage, { PanelMessageAction } from '../PanelMessage/PanelMessage';
import { useExtendTimeWindow } from './useExtendTimeWindow';
interface NoDataProps {
/** Title override. Defaults to the time-range empty-state copy. */
title?: string;
/** Description override. Defaults to the "widen the range" hint. */
description?: string;
/** In flight over empty data → show the loader, not the empty state. */
isFetching?: boolean;
/** When provided, renders a Retry button that re-runs the query. */
onRetry?: () => void;
'data-testid'?: string;
}
/**
* Shared empty-state for panel renderers: wraps `PanelMessage` so every panel
* kind surfaces the same "no data" affordance when a query returns nothing.
* Shared empty-state for panel renderers. The query succeeded but returned nothing,
* so we offer to widen the time window — global by default, or the View modal's
* local window when it publishes one to the store — alongside a Retry that re-runs
* the query.
*/
function NoData({
title = 'No data in this time range',
description = 'Nothing in the selected window. Try widening the range.',
isFetching = false,
onRetry,
'data-testid': testId = 'panel-no-data',
}: NoDataProps): JSX.Element {
const viewExtend = useViewPanelStore(selectViewPanelExtendWindow);
const globalExtend = useExtendTimeWindow();
const { canExtend, actionLabel, extend } = viewExtend ?? globalExtend;
if (isFetching) {
return <PanelLoader />;
}
const extendAction: PanelMessageAction | undefined =
canExtend && actionLabel
? { label: actionLabel, onClick: extend, icon: <CalendarRange size={14} /> }
: undefined;
const retryAction: PanelMessageAction | undefined = onRetry
? { label: 'Retry', onClick: onRetry, icon: <RotateCw size={14} /> }
: undefined;
return (
<PanelMessage
icon={<Clock size={18} />}
title={title}
description={description}
action={
onRetry
? { label: 'Retry', onClick: onRetry, icon: <RotateCw size={14} /> }
: undefined
}
action={extendAction ?? retryAction}
secondaryAction={extendAction ? retryAction : undefined}
data-testid={testId}
/>
);

View File

@@ -0,0 +1,122 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { useViewPanelStore } from '../../../../store/useViewPanelStore';
import { ExtendTimeWindow } from '../extendWindow';
import NoData from '../NoData';
const mockUseExtendTimeWindow = jest.fn();
jest.mock('../useExtendTimeWindow', () => ({
useExtendTimeWindow: (): ExtendTimeWindow => mockUseExtendTimeWindow(),
}));
const inert: ExtendTimeWindow = {
canExtend: false,
actionLabel: null,
extend: jest.fn(),
};
function extender(over?: Partial<ExtendTimeWindow>): ExtendTimeWindow {
return {
canExtend: true,
actionLabel: 'Extend time range',
extend: jest.fn(),
...over,
};
}
describe('NoData', () => {
beforeEach(() => {
mockUseExtendTimeWindow.mockReturnValue(inert);
useViewPanelStore.setState({ viewPanelExtendWindow: null });
});
it('renders the empty-state title and hint', () => {
render(<NoData />);
expect(screen.getByTestId('panel-no-data')).toBeInTheDocument();
expect(screen.getByText('No data in this time range')).toBeInTheDocument();
expect(
screen.getByText('Nothing in the selected window. Try widening the range.'),
).toBeInTheDocument();
});
it('offers to extend the window as the primary action', () => {
mockUseExtendTimeWindow.mockReturnValue(extender());
render(<NoData />);
const action = screen.getByTestId('panel-no-data-action');
expect(action).toHaveTextContent('Extend time range');
// No retry handler → no secondary Retry button.
expect(
screen.queryByTestId('panel-no-data-secondary-action'),
).not.toBeInTheDocument();
});
it('renders both Extend (primary) and Retry (secondary) when a retry handler is given', () => {
const onRetry = jest.fn();
mockUseExtendTimeWindow.mockReturnValue(extender());
render(<NoData onRetry={onRetry} />);
expect(screen.getByTestId('panel-no-data-action')).toHaveTextContent(
'Extend time range',
);
const retry = screen.getByTestId('panel-no-data-secondary-action');
expect(retry).toHaveTextContent('Retry');
fireEvent.click(retry);
expect(onRetry).toHaveBeenCalledTimes(1);
});
it('falls back to Retry as the sole action when the window cannot be widened', () => {
const onRetry = jest.fn();
render(<NoData onRetry={onRetry} />);
const action = screen.getByTestId('panel-no-data-action');
expect(action).toHaveTextContent('Retry');
expect(
screen.queryByTestId('panel-no-data-secondary-action'),
).not.toBeInTheDocument();
fireEvent.click(action);
expect(onRetry).toHaveBeenCalledTimes(1);
});
it('prefers the View modal extender (store) over the global one', () => {
const globalExtend = jest.fn();
const storeExtend = jest.fn();
mockUseExtendTimeWindow.mockReturnValue(extender({ extend: globalExtend }));
useViewPanelStore.setState({
viewPanelExtendWindow: extender({ extend: storeExtend }),
});
render(<NoData />);
fireEvent.click(screen.getByTestId('panel-no-data-action'));
expect(storeExtend).toHaveBeenCalledTimes(1);
expect(globalExtend).not.toHaveBeenCalled();
});
it('renders no action when nothing can be widened and no retry handler', () => {
render(<NoData />);
expect(screen.queryByTestId('panel-no-data-action')).not.toBeInTheDocument();
expect(
screen.queryByTestId('panel-no-data-secondary-action'),
).not.toBeInTheDocument();
});
it('shows the panel loader (not the empty state) while refetching', () => {
mockUseExtendTimeWindow.mockReturnValue(extender());
render(<NoData isFetching />);
expect(screen.getByTestId('panel-loading')).toBeInTheDocument();
expect(screen.queryByTestId('panel-no-data')).not.toBeInTheDocument();
expect(screen.queryByTestId('panel-no-data-action')).not.toBeInTheDocument();
});
it('honours the data-testid override for the number panel', () => {
mockUseExtendTimeWindow.mockReturnValue(extender());
render(<NoData data-testid="number-panel-no-data" />);
expect(screen.getByTestId('number-panel-no-data')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,96 @@
import { act, renderHook } from '@testing-library/react';
import { ReactNode } from 'react';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { UPDATE_TIME_INTERVAL } from 'types/actions/globalTime';
import { GlobalReducer } from 'types/reducer/globalTime';
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { useExtendTimeWindow } from '../useExtendTimeWindow';
const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
safeNavigate: mockSafeNavigate,
}),
}));
const NS_PER_MS = 1e6;
const MINUTE_MS = 60 * 1000;
const DAY_MS = 24 * 60 * MINUTE_MS;
// A window anchored in the past so the zoom-out ladder is deterministic (real
// Date.now() is far ahead, so it always center-anchors into a custom range).
const PAST_END_MS = 1_600_003_600_000;
const mockStore = configureStore([thunk]);
function globalTimeState(
partial: Pick<GlobalReducer, 'selectedTime' | 'minTime' | 'maxTime'>,
): GlobalReducer {
return {
loading: false,
isAutoRefreshDisabled: false,
selectedAutoRefreshInterval: '',
...partial,
};
}
function setup(globalTime: GlobalReducer): {
result: { current: ReturnType<typeof useExtendTimeWindow> };
store: ReturnType<typeof mockStore>;
} {
const store = mockStore({ globalTime });
const { result } = renderHook(() => useExtendTimeWindow(), {
wrapper: ({ children }: { children: ReactNode }): JSX.Element => (
<Provider store={store}>
<MemoryRouter>{children}</MemoryRouter>
</Provider>
),
});
return { result, store };
}
describe('useExtendTimeWindow', () => {
beforeEach(() => {
mockSafeNavigate.mockClear();
});
it('reports it can extend and dispatches a wider range', () => {
const { result, store } = setup(
globalTimeState({
selectedTime: '30m',
minTime: (PAST_END_MS - 30 * MINUTE_MS) * NS_PER_MS,
maxTime: PAST_END_MS * NS_PER_MS,
}),
);
expect(result.current.canExtend).toBe(true);
expect(result.current.actionLabel).toBe('Extend time range');
act(() => result.current.extend());
const [action] = store.getActions();
expect(action.type).toBe(UPDATE_TIME_INTERVAL);
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
});
it('cannot extend past the widest window', () => {
const { result, store } = setup(
globalTimeState({
selectedTime: 'custom',
minTime: (PAST_END_MS - 40 * DAY_MS) * NS_PER_MS,
maxTime: PAST_END_MS * NS_PER_MS,
}),
);
expect(result.current.canExtend).toBe(false);
expect(result.current.actionLabel).toBeNull();
act(() => result.current.extend());
expect(store.getActions()).toHaveLength(0);
expect(mockSafeNavigate).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,26 @@
import type { ZoomOutResult } from 'lib/zoomOutUtils';
export interface ExtendTimeWindow {
canExtend: boolean;
/** Button copy; null when the window can't be widened further. */
actionLabel: string | null;
extend: () => void;
}
const EXTEND_ACTION_LABEL = 'Extend time range';
/**
* Adapts a zoom-out ladder result (`getNextZoomOutRange`) plus its apply callback
* into the panel empty-state extender. A `null` result means the window is already
* at the widest ladder step, so there is nothing to extend to.
*/
export function buildExtendWindow(
result: ZoomOutResult | null,
extend: () => void,
): ExtendTimeWindow {
return {
canExtend: result !== null,
actionLabel: result ? EXTEND_ACTION_LABEL : null,
extend,
};
}

View File

@@ -0,0 +1,26 @@
// eslint-disable-next-line no-restricted-imports -- global time lives in redux; V2 time dispatch is not yet migrated
import { useSelector } from 'react-redux';
import { useZoomOut } from 'hooks/useZoomOut';
import { getNextZoomOutRange } from 'lib/zoomOutUtils';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { buildExtendWindow, ExtendTimeWindow } from './extendWindow';
import { NANO_SECOND_MULTIPLIER } from '@/store/globalTime';
/**
* Default empty-state extender: widen the dashboard's global time via the shared
* zoom-out ladder. Dispatch + URL sync live in `useZoomOut`; the View modal
* overrides this with its own local extender via the store.
*/
export function useExtendTimeWindow(): ExtendTimeWindow {
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const extend = useZoomOut();
const result = getNextZoomOutRange(
Math.floor(minTime / NANO_SECOND_MULTIPLIER),
Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
);
return buildExtendWindow(result, extend);
}

View File

@@ -0,0 +1,10 @@
// Centred full-body spinner (first-fetch + refetch-over-empty).
.loader {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
min-height: 0;
min-width: 0;
}

View File

@@ -0,0 +1,21 @@
import { Spin } from 'antd';
import { Loader } from '@signozhq/icons';
import styles from './PanelLoader.module.scss';
interface PanelLoaderProps {
'data-testid'?: string;
}
/** Centred full-panel spinner shown while a query is in flight with nothing to display yet. */
function PanelLoader({
'data-testid': testId = 'panel-loading',
}: PanelLoaderProps): JSX.Element {
return (
<div className={styles.loader} data-testid={testId}>
<Spin indicator={<Loader size={14} className="animate-spin" />} />
</div>
);
}
export default PanelLoader;

View File

@@ -45,6 +45,10 @@
overflow-wrap: anywhere;
}
.action {
.actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
margin-top: 8px;
}

View File

@@ -20,6 +20,8 @@ interface PanelMessageProps {
description?: string;
/** Optional call-to-action (e.g. Retry). Omitted → no button. */
action?: PanelMessageAction;
/** Second call-to-action rendered beside `action` (e.g. Extend + Retry). */
secondaryAction?: PanelMessageAction;
/** `danger` tints the icon for failure states; `neutral` for empty states. */
tone?: 'neutral' | 'danger';
'data-testid'?: string;
@@ -34,9 +36,18 @@ function PanelMessage({
title,
description,
action,
secondaryAction,
tone = 'neutral',
'data-testid': testId,
}: PanelMessageProps): JSX.Element {
const actions: Array<{ suffix: string; action: PanelMessageAction }> = [];
if (action) {
actions.push({ suffix: 'action', action });
}
if (secondaryAction) {
actions.push({ suffix: 'secondary-action', action: secondaryAction });
}
return (
<div className={styles.message} data-testid={testId}>
<div className={cx(styles.icon, { [styles.iconDanger]: tone === 'danger' })}>
@@ -48,18 +59,22 @@ function PanelMessage({
{description}
</Typography.Text>
)}
{action && (
<Button
variant="outlined"
color="secondary"
size="sm"
prefix={action.icon}
onClick={action.onClick}
className={styles.action}
data-testid={testId ? `${testId}-action` : undefined}
>
{action.label}
</Button>
{actions.length > 0 && (
<div className={styles.actions}>
{actions.map(({ suffix, action: item }) => (
<Button
key={suffix}
variant="outlined"
color="secondary"
size="sm"
prefix={item.icon}
onClick={item.onClick}
data-testid={testId ? `${testId}-${suffix}` : undefined}
>
{item.label}
</Button>
))}
</div>
)}
</div>
);

View File

@@ -43,4 +43,28 @@ describe('PanelMessage', () => {
fireEvent.click(button);
expect(onClick).toHaveBeenCalledTimes(1);
});
it('renders both actions side by side and wires each onClick', () => {
const onPrimary = jest.fn();
const onSecondary = jest.fn();
render(
<PanelMessage
icon={null}
title="No data in this time range"
action={{ label: 'Extend time range', onClick: onPrimary }}
secondaryAction={{ label: 'Retry', onClick: onSecondary }}
data-testid="panel-no-data"
/>,
);
const primary = screen.getByTestId('panel-no-data-action');
const secondary = screen.getByTestId('panel-no-data-secondary-action');
expect(primary).toHaveTextContent('Extend time range');
expect(secondary).toHaveTextContent('Retry');
fireEvent.click(primary);
fireEvent.click(secondary);
expect(onPrimary).toHaveBeenCalledTimes(1);
expect(onSecondary).toHaveBeenCalledTimes(1);
});
});

View File

@@ -35,6 +35,7 @@ function BarPanelRenderer({
panelId,
panel,
data,
isFetching,
refetch,
onClick,
onDragSelect,
@@ -186,7 +187,9 @@ function BarPanelRenderer({
data-testid="bar-panel-renderer"
className={PanelStyles.panelContainer}
>
{flatSeries.length === 0 && <NoData onRetry={refetch} />}
{flatSeries.length === 0 && (
<NoData isFetching={isFetching} onRetry={refetch} />
)}
{flatSeries.length > 0 &&
containerDimensions.width > 0 &&
containerDimensions.height > 0 && (

View File

@@ -25,6 +25,7 @@ function HistogramPanelRenderer({
panelId,
panel,
data,
isFetching,
refetch,
panelMode,
}: PanelRendererProps<'signoz/HistogramPanel'>): JSX.Element {
@@ -104,7 +105,9 @@ function HistogramPanelRenderer({
data-testid="histogram-panel-renderer"
className={PanelStyles.panelContainer}
>
{flatSeries.length === 0 && <NoData onRetry={refetch} />}
{flatSeries.length === 0 && (
<NoData isFetching={isFetching} onRetry={refetch} />
)}
{flatSeries.length > 0 &&
containerDimensions.width > 0 &&
containerDimensions.height > 0 && (

View File

@@ -33,6 +33,7 @@ function ListPanelRenderer({
panelId,
panel,
data,
isFetching,
refetch,
searchTerm = '',
pagination,
@@ -142,7 +143,7 @@ function ListPanelRenderer({
className={PanelStyles.panelContainer}
>
{!table || dataSource.length === 0 ? (
<NoData onRetry={refetch} />
<NoData isFetching={isFetching} onRetry={refetch} />
) : (
<>
<div

View File

@@ -24,6 +24,7 @@ import ValueDisplay from './components/ValueDisplay/ValueDisplay';
function NumberPanelRenderer({
panel,
data,
isFetching,
refetch,
onClick,
enableDrillDown,
@@ -125,7 +126,11 @@ function NumberPanelRenderer({
: {})}
>
{value === null ? (
<NoData data-testid="number-panel-no-data" onRetry={refetch} />
<NoData
data-testid="number-panel-no-data"
isFetching={isFetching}
onRetry={refetch}
/>
) : (
<ValueDisplay
value={formattedValue}

View File

@@ -27,6 +27,7 @@ function PiePanelRenderer({
panelId,
panel,
data,
isFetching,
refetch,
onClick,
enableDrillDown,
@@ -94,7 +95,7 @@ function PiePanelRenderer({
return (
<div data-testid="pie-panel-renderer" className={PanelStyles.panelContainer}>
{slices.length === 0 ? (
<NoData onRetry={refetch} />
<NoData isFetching={isFetching} onRetry={refetch} />
) : (
<Pie
data={slices}

View File

@@ -35,6 +35,7 @@ function TablePanelRenderer({
panelId,
panel,
data,
isFetching,
refetch,
searchTerm = '',
onClick,
@@ -159,7 +160,7 @@ function TablePanelRenderer({
className={PanelStyles.panelContainer}
>
{!table || dataSource.length === 0 ? (
<NoData onRetry={refetch} />
<NoData isFetching={isFetching} onRetry={refetch} />
) : (
<div className={styles.container}>
<Table

View File

@@ -35,6 +35,7 @@ function TimeSeriesPanelRenderer({
panelId,
panel,
data,
isFetching,
refetch,
onClick,
onDragSelect,
@@ -187,7 +188,9 @@ function TimeSeriesPanelRenderer({
data-testid="time-series-renderer"
className={PanelStyles.panelContainer}
>
{flatSeries.length === 0 && <NoData onRetry={refetch} />}
{flatSeries.length === 0 && (
<NoData isFetching={isFetching} onRetry={refetch} />
)}
{flatSeries.length > 0 &&
containerDimensions.width > 0 &&
containerDimensions.height > 0 && (

View File

@@ -25,7 +25,6 @@
.welcome {
color: var(--l1-foreground);
font-family: Inter;
font-size: 16px;
font-weight: 500;
line-height: 24px;
@@ -34,52 +33,56 @@
.welcomeInfo {
color: var(--l3-foreground);
font-family: Inter;
font-size: 13px;
font-weight: 400;
line-height: 18px;
}
}
.addPanel {
.steps {
display: flex;
flex-direction: column;
gap: 12px;
}
.step {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px;
border: 1px dashed var(--l1-border);
border: 1px dashed var(--l3-border);
border-radius: 6px;
}
.addPanelText {
.stepText {
display: flex;
align-items: flex-start;
gap: 10px;
.icon {
.stepIcon {
height: 14px;
width: 14px;
margin-top: 2px;
flex: none;
}
}
.addPanelCopy {
.stepCopy {
display: flex;
flex-direction: column;
gap: 2px;
}
.addPanelTitle {
.stepTitle {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.addPanelInfo {
.stepInfo {
color: var(--l3-foreground);
font-family: Inter;
font-size: 13px;
font-weight: 400;
line-height: 18px;

View File

@@ -1,4 +1,4 @@
import { Plus } from '@signozhq/icons';
import { Configure, Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
@@ -6,6 +6,7 @@ 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';
@@ -18,6 +19,8 @@ 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}>
@@ -32,28 +35,55 @@ function DashboardEmptyState({
</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 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>
{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

View File

@@ -4,19 +4,9 @@ import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/qu
import type { ROLES } from 'types/roles';
import type { DashboardSection } from '../../../../utils';
import { DASHBOARD_LOCKED_REASON } from '../../../../store/slices/editContextSlice';
import { useDashboardStore } from '../../../../store/useDashboardStore';
import { usePanelActionItems } from '../usePanelActionItems';
/** Keys of the disabled items, in order. */
function disabledKeys(
result: ReturnType<typeof usePanelActionItems>,
): unknown[] {
return result.items
.filter((item) => 'disabled' in item && item.disabled)
.map((item) => ('key' in item ? item.key : undefined));
}
const mockOpenEditor = jest.fn();
jest.mock(
'pages/DashboardPageV2/DashboardContainer/hooks/useOpenPanelEditor',
@@ -125,7 +115,7 @@ describe('usePanelActionItems', () => {
beforeEach(() => {
jest.clearAllMocks();
mockRole = 'ADMIN';
useDashboardStore.setState({ isEditable: true, editDisabledReason: '' });
useDashboardStore.setState({ isEditable: true });
});
it('ADMIN on an editable dashboard with a known kind gets the full V1-parity set, divider-separated', () => {
@@ -146,14 +136,11 @@ describe('usePanelActionItems', () => {
// it's present for every renderable kind.
});
it('AUTHOR sees edit and clone disabled (edit_widget excludes AUTHOR) but can move and delete', () => {
it('AUTHOR loses edit and clone (edit_widget excludes AUTHOR) but keeps the rest', () => {
mockRole = 'AUTHOR';
const { result } = renderHook(() => usePanelActionItems(baseArgs));
// The full set is now always present — the role gate disables rather than hides.
expect(itemKeys(result.current)).toStrictEqual([
'view-panel',
'edit-panel',
'clone-panel',
'divider',
'download',
'create-alert',
@@ -162,49 +149,31 @@ describe('usePanelActionItems', () => {
'divider',
'delete-panel',
]);
expect(disabledKeys(result.current)).toStrictEqual([
'edit-panel',
'clone-panel',
]);
});
it('VIEWER sees every edit action disabled (no edit permissions)', () => {
it('VIEWER keeps only the role-ungated actions (view, download, create-alert)', () => {
mockRole = 'VIEWER';
const { result } = renderHook(() => usePanelActionItems(baseArgs));
expect(disabledKeys(result.current)).toStrictEqual([
'edit-panel',
'clone-panel',
'move',
'delete-panel',
expect(itemKeys(result.current)).toStrictEqual([
'view-panel',
'divider',
'download',
'create-alert',
]);
});
it('a non-editable (locked / no-permission) dashboard disables every edit action', () => {
useDashboardStore.setState({
isEditable: false,
editDisabledReason: DASHBOARD_LOCKED_REASON,
});
// A read-only dashboard mounts panels without layout context (no panelActions).
it('read-only dashboard keeps the non-mutating actions (View, Download, Create Alerts)', () => {
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).
expect(itemKeys(result.current)).toStrictEqual([
'view-panel',
'edit-panel',
'clone-panel',
'divider',
'download',
'create-alert',
'divider',
'move',
'divider',
'delete-panel',
]);
expect(disabledKeys(result.current)).toStrictEqual([
'edit-panel',
'clone-panel',
'move',
'delete-panel',
]);
});

View File

@@ -1,12 +1,5 @@
import { type ReactNode, useCallback, useMemo } from 'react';
import {
Bell,
Copy,
FolderInput,
Fullscreen,
PenLine,
Trash2,
} from '@signozhq/icons';
import { useCallback, useMemo } from 'react';
import { Bell, Copy, 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';
@@ -30,8 +23,6 @@ import { useMovePanelToSection } from '../hooks/useMovePanelToSection';
import { useViewPanel } from '../hooks/useViewPanel';
import { buildMoveItems } from '../utils/buildMoveItems';
import { PANEL_ACTION_META } from './panelActionMeta';
import DisabledMenuItemLabel from '../../../components/DisabledMenuItemLabel/DisabledMenuItemLabel';
import { DASHBOARD_NO_EDIT_PERMISSION_REASON } from '../../../hooks/useDashboardEditGuard';
// Stable fallback so renders without layout context don't churn the mutation
// hooks' deps (a fresh [] each render would re-create their callbacks).
@@ -77,7 +68,6 @@ export function usePanelActionItems({
user.role,
);
const isEditable = useDashboardStore((s) => s.isEditable);
const editDisabledReason = useDashboardStore((s) => s.editDisabledReason);
const openPanelEditor = useOpenPanelEditor();
const createAlert = useCreateAlertFromPanel();
const { openView } = useViewPanel();
@@ -112,23 +102,6 @@ export function usePanelActionItems({
const { request: requestDelete } = deleteConfirm;
const items = useMemo<MenuItem[]>(() => {
// The reason an edit action is unavailable: dashboard not editable (locked /
// no permission) takes precedence, else the missing widget-level role
// permission. Empty string ⇒ the action is enabled.
const reasonFor = (hasRolePerm: boolean): string => {
if (!isEditable) {
return editDisabledReason;
}
return hasRolePerm ? '' : DASHBOARD_NO_EDIT_PERMISSION_REASON;
};
// Disabled rows keep a hover tooltip via DisabledMenuItemLabel (see component).
const label = (reason: string, text: string): ReactNode =>
reason ? (
<DisabledMenuItemLabel reason={reason}>{text}</DisabledMenuItemLabel>
) : (
text
);
const panelGroup: MenuItem[] = [];
if (panelCapabilities.view) {
panelGroup.push({
@@ -138,33 +111,26 @@ export function usePanelActionItems({
onClick: (): void => openView(panelId),
});
}
if (panelCapabilities.edit) {
const reason = reasonFor(canEditWidget);
if (isEditable && canEditWidget && panelCapabilities.edit) {
panelGroup.push({
key: 'edit-panel',
label: label(reason, 'Edit panel'),
label: 'Edit panel',
icon: <PenLine size={14} />,
disabled: !!reason,
onClick: (): void => openPanelEditor(panelId),
});
}
if (panelCapabilities.clone) {
// Clone needs the section context to place the copy; without it (read-only
// mount) it stays disabled.
const reason = reasonFor(canEditWidget);
// Clone needs the section context to place the copy, so — unlike Edit —
// it requires panelActions.
if (isEditable && canEditWidget && panelActions && panelCapabilities.clone) {
panelGroup.push({
key: 'clone-panel',
label: label(reason, 'Clone'),
label: 'Clone',
icon: <Copy size={14} />,
disabled: !!reason || !panelActions,
onClick: (): void => {
if (panelActions) {
void clonePanel({
panelId,
layoutIndex: panelActions.currentLayoutIndex,
});
}
},
onClick: (): void =>
void clonePanel({
panelId,
layoutIndex: panelActions.currentLayoutIndex,
}),
});
}
@@ -174,7 +140,7 @@ export function usePanelActionItems({
}
// Create Alerts opens a new tab and never mutates the dashboard, so —
// unlike edit/clone — it isn't gated on editability (V1 parity).
// unlike edit/clone — it isn't gated on `isEditable` (V1 parity).
if (panelCapabilities.createAlert) {
dataGroup.push({
key: 'create-alert',
@@ -184,35 +150,28 @@ export function usePanelActionItems({
});
}
const moveReason = reasonFor(canMove);
const moveGroup: MenuItem[] =
!moveReason && panelActions
canMove && panelActions
? buildMoveItems({
sections,
currentLayoutIndex: panelActions.currentLayoutIndex,
panelId,
movePanel,
})
: [
{
key: 'move',
label: label(moveReason, 'Move to section'),
icon: <FolderInput size={14} />,
disabled: true,
},
];
: [];
const deleteReason = reasonFor(canDelete);
const deleteGroup: MenuItem[] = [
{
key: 'delete-panel',
danger: true,
icon: <Trash2 size={14} />,
label: label(deleteReason, 'Delete panel'),
disabled: !!deleteReason || !panelActions,
onClick: (): void => requestDelete(),
},
];
const deleteGroup: MenuItem[] =
canDelete && panelActions
? [
{
key: 'delete-panel',
danger: true,
icon: <Trash2 size={14} />,
label: 'Delete panel',
onClick: (): void => requestDelete(),
},
]
: [];
return [panelGroup, dataGroup, moveGroup, deleteGroup]
.filter((group) => group.length > 0)
@@ -221,7 +180,6 @@ export function usePanelActionItems({
);
}, [
isEditable,
editDisabledReason,
canEditWidget,
canMove,
canDelete,

View File

@@ -1,18 +1,4 @@
// 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 {
.panelContainer {
flex: 1;
display: flex;
min-height: 0;

View File

@@ -1,7 +1,7 @@
import { Spin } from 'antd';
import { Loader, RotateCw, SquarePlus, TriangleAlert } from '@signozhq/icons';
import { RotateCw, SquarePlus, TriangleAlert } from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import PanelLoader from 'pages/DashboardPageV2/DashboardContainer/Panels/components/PanelLoader/PanelLoader';
import PanelMessage from 'pages/DashboardPageV2/DashboardContainer/Panels/components/PanelMessage/PanelMessage';
import type { AnyPanelInteractionProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/interactions';
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
@@ -106,17 +106,13 @@ function PanelBody({
}
// Full-panel loader only on first fetch; a refetch over existing data keeps the renderer
// mounted (e.g. list page change) with the header carrying the in-flight indicator.
// mounted (e.g. list page change). A refetch over empty data loads via NoData.
if (isFetching && !hasData) {
return (
<div className={styles.body} data-testid="panel-loading">
<Spin indicator={<Loader size={14} className="animate-spin" />} />
</div>
);
return <PanelLoader />;
}
return (
<div className={styles.chartContainer}>
<div className={styles.panelContainer}>
<panelDefinition.Renderer
panelId={panelId}
panel={panel}

View File

@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useEffect, useMemo } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
@@ -6,6 +6,7 @@ import ContextMenu from 'periscope/components/ContextMenu';
import PanelEditorQueryBuilder from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/PanelEditorQueryBuilder';
import PreviewPane from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/PreviewPane/PreviewPane';
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
import { useViewPanelStore } from 'pages/DashboardPageV2/DashboardContainer/store/useViewPanelStore';
import { useOpenPanelEditor } from 'pages/DashboardPageV2/DashboardContainer/hooks/useOpenPanelEditor';
import { useDrilldown } from '../hooks/useDrilldown';
@@ -38,6 +39,7 @@ function ViewPanelModalContent({
onTimeChange,
refreshWindow,
onDragSelect,
extendWindow,
} = useViewPanelTimeWindow();
const {
@@ -69,6 +71,15 @@ function ViewPanelModalContent({
);
const openPanelEditor = useOpenPanelEditor();
// Publish the modal's local extender for the nested no-data state; cleared on close.
const setViewPanelExtendWindow = useViewPanelStore(
(s) => s.setViewPanelExtendWindow,
);
useEffect(() => {
setViewPanelExtendWindow(extendWindow);
return (): void => setViewPanelExtendWindow(null);
}, [extendWindow, setViewPanelExtendWindow]);
// The View action only appears for registered kinds, so this is defensive.
if (!panelDefinition) {
return null;

View File

@@ -6,11 +6,15 @@ import type {
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import GetMinMax from 'lib/getMinMax';
import {
buildExtendWindow,
ExtendTimeWindow,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/components/NoData/extendWindow';
import { getNextZoomOutRange } from 'lib/zoomOutUtils';
import type { PanelQueryTimeOverride } from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
const NS_PER_MS = 1e6;
import { NANO_SECOND_MULTIPLIER } from '@/store/globalTime';
export interface ViewPanelTimeWindow {
/** Absolute window (epoch ms) to pass to usePanelQuery as a time override. */
@@ -26,6 +30,8 @@ export interface ViewPanelTimeWindow {
refreshWindow: () => void;
/** Drag-to-zoom on a time chart → set a custom window locally (not the dashboard's). */
onDragSelect: (start: number, end: number) => void;
/** Widens this local window in place — powers the empty-state "extend" action. */
extendWindow: ExtendTimeWindow;
}
/**
@@ -45,8 +51,8 @@ export function useViewPanelTimeWindow(): ViewPanelTimeWindow {
>(selectedTime as Time);
const [timeOverride, setTimeOverride] = useState<PanelQueryTimeOverride>(
() => ({
startMs: Math.floor(minTime / NS_PER_MS),
endMs: Math.floor(maxTime / NS_PER_MS),
startMs: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
endMs: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
}),
);
@@ -64,8 +70,8 @@ export function useViewPanelTimeWindow(): ViewPanelTimeWindow {
// GetMinMax returns nanoseconds — convert to the ms window we work in.
const { minTime: startNs, maxTime: endNs } = GetMinMax(interval);
setTimeOverride({
startMs: Math.floor(startNs / NS_PER_MS),
endMs: Math.floor(endNs / NS_PER_MS),
startMs: Math.floor(startNs / NANO_SECOND_MULTIPLIER),
endMs: Math.floor(endNs / NANO_SECOND_MULTIPLIER),
});
},
[],
@@ -78,8 +84,8 @@ export function useViewPanelTimeWindow(): ViewPanelTimeWindow {
}
const { minTime: startNs, maxTime: endNs } = GetMinMax(selectedInterval);
setTimeOverride({
startMs: Math.floor(startNs / NS_PER_MS),
endMs: Math.floor(endNs / NS_PER_MS),
startMs: Math.floor(startNs / NANO_SECOND_MULTIPLIER),
endMs: Math.floor(endNs / NANO_SECOND_MULTIPLIER),
});
}, [selectedInterval]);
@@ -95,6 +101,22 @@ export function useViewPanelTimeWindow(): ViewPanelTimeWindow {
setTimeOverride({ startMs, endMs });
}, []);
// Empty-state extender that widens this local window via onTimeChange (not global time).
const extendWindow = useMemo<ExtendTimeWindow>(() => {
const result = getNextZoomOutRange(timeOverride.startMs, timeOverride.endMs);
return buildExtendWindow(result, (): void => {
if (!result) {
return;
}
const [startMs, endMs] = result.range;
if (result.preset) {
onTimeChange(result.preset);
} else {
onTimeChange('custom', [startMs, endMs]);
}
});
}, [timeOverride, onTimeChange]);
return useMemo(
() => ({
timeOverride,
@@ -102,7 +124,15 @@ export function useViewPanelTimeWindow(): ViewPanelTimeWindow {
onTimeChange,
refreshWindow,
onDragSelect,
extendWindow,
}),
[timeOverride, selectedInterval, onTimeChange, refreshWindow, onDragSelect],
[
timeOverride,
selectedInterval,
onTimeChange,
refreshWindow,
onDragSelect,
extendWindow,
],
);
}

View File

@@ -103,6 +103,7 @@ jest.mock('../ViewPanelModal/useViewPanelTimeWindow', () => ({
onTimeChange: jest.fn(),
refreshWindow: jest.fn(),
onDragSelect: jest.fn(),
extendWindow: { canExtend: false, actionLabel: null, extend: jest.fn() },
}),
}));

View File

@@ -90,4 +90,35 @@ describe('useViewPanelTimeWindow', () => {
expect(result.current.timeOverride).toStrictEqual(initial);
});
it('widens the local window in place via the zoom-out ladder', () => {
const { result } = renderHook(() => useViewPanelTimeWindow());
act(() => result.current.onDragSelect(1000, 5000));
expect(result.current.extendWindow.canExtend).toBe(true);
expect(result.current.extendWindow.actionLabel).toBe('Extend time range');
act(() => result.current.extendWindow.extend());
// A past 4s window (< 15m) zooms out 3× around its centre (3000): [-3000, 9000].
expect(result.current.selectedInterval).toBe('custom');
expect(result.current.timeOverride).toStrictEqual({
startMs: -3000,
endMs: 9000,
});
});
it('cannot extend a window already at the widest ladder step', () => {
const { result } = renderHook(() => useViewPanelTimeWindow());
// 40 days > the ladder's 1-month max.
act(() => result.current.onDragSelect(0, 40 * 24 * 60 * 60 * 1000));
expect(result.current.extendWindow.canExtend).toBe(false);
expect(result.current.extendWindow.actionLabel).toBeNull();
// extend() is a no-op — the window is unchanged.
const before = result.current.timeOverride;
act(() => result.current.extendWindow.extend());
expect(result.current.timeOverride).toStrictEqual(before);
});
});

View File

@@ -1,7 +1,6 @@
import { useCallback, useRef, useState } from 'react';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
@@ -30,7 +29,6 @@ interface SectionProps {
function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const editDisabledReason = useDashboardStore((s) => s.editDisabledReason);
const {
isPickerOpen,
openPicker,
@@ -106,19 +104,22 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
onToggle={toggle}
repeatVariable={section.repeatVariable}
dragHandle={dragHandle}
disabledReason={isEditable ? '' : editDisabledReason}
actions={{
onRename: (): void => setIsRenaming(true),
onAddPanel: (): void => openPicker(section.layoutIndex),
onDeleteSection: (): void => setIsDeleteOpen(true),
}}
actions={
isEditable
? {
onRename: (): void => setIsRenaming(true),
onAddPanel: (): void => openPicker(section.layoutIndex),
onDeleteSection: (): void => setIsDeleteOpen(true),
}
: undefined
}
/>
{open &&
(section.items.length > 0 ? (
grid
) : (
<div className={styles.emptySection}>
{isEditable ? (
{isEditable && (
<Button
type="button"
variant="dashed"
@@ -129,19 +130,6 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
>
New Panel
</Button>
) : (
<TooltipSimple title={editDisabledReason} disableHoverableContent>
<Button
type="button"
variant="dashed"
color="secondary"
prefix={<Plus size="md" />}
disabled
testId={`section-add-panel-${section.id}`}
>
New Panel
</Button>
</TooltipSimple>
)}
</div>
))}

View File

@@ -1,16 +1,13 @@
import { type ReactNode, useMemo } from 'react';
import { useMemo } from 'react';
import { EllipsisVertical, PenLine, Plus, Trash2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import DisabledMenuItemLabel from '../../../components/DisabledMenuItemLabel/DisabledMenuItemLabel';
import styles from './SectionActionsMenu.module.scss';
interface SectionActionsMenuProps {
sectionId: string;
/** Non-empty when edits are unavailable — items render disabled with this reason. */
disabledReason?: string;
onAddPanel?: () => void;
onRename?: () => void;
onDeleteSection?: () => void;
@@ -18,28 +15,17 @@ interface SectionActionsMenuProps {
function SectionActionsMenu({
sectionId,
disabledReason = '',
onAddPanel,
onRename,
onDeleteSection,
}: SectionActionsMenuProps): JSX.Element {
const items = useMemo<MenuItem[]>(() => {
const disabled = !!disabledReason;
const label = (text: string): ReactNode =>
disabled ? (
<DisabledMenuItemLabel reason={disabledReason}>
{text}
</DisabledMenuItemLabel>
) : (
text
);
const result: MenuItem[] = [];
if (onAddPanel) {
result.push({
key: 'add-panel',
icon: <Plus size={14} />,
label: label('Add panel'),
disabled,
label: 'Add panel',
onClick: onAddPanel,
});
}
@@ -47,8 +33,7 @@ function SectionActionsMenu({
result.push({
key: 'rename',
icon: <PenLine size={14} />,
label: label('Rename section'),
disabled,
label: 'Rename section',
onClick: onRename,
});
}
@@ -59,14 +44,13 @@ function SectionActionsMenu({
key: 'delete-section',
danger: true,
icon: <Trash2 size={14} />,
label: label('Delete section'),
disabled,
label: 'Delete section',
onClick: onDeleteSection,
},
);
}
return result;
}, [disabledReason, onAddPanel, onRename, onDeleteSection]);
}, [onAddPanel, onRename, onDeleteSection]);
return (
<DropdownMenuSimple menu={{ items }}>

View File

@@ -29,10 +29,8 @@ interface SectionHeaderProps {
repeatVariable?: string;
/** Provided by SortableSection in sectioned mode; absent for untitled/free-flow. */
dragHandle?: SectionDragHandle;
/** The section action handlers (always provided; disabled state gates them). */
/** Present only in editable mode; absent (read-only) when locked/no-permission. */
actions?: SectionHeaderActions;
/** Non-empty when edits are unavailable — actions render disabled with this reason. */
disabledReason?: string;
}
function SectionHeader({
@@ -43,7 +41,6 @@ function SectionHeader({
repeatVariable,
dragHandle,
actions,
disabledReason = '',
}: SectionHeaderProps): JSX.Element {
return (
<div className={cx(styles.header, { [styles.headerOpen]: open })}>
@@ -82,7 +79,6 @@ function SectionHeader({
{actions ? (
<SectionActionsMenu
sectionId={sectionId}
disabledReason={disabledReason}
onAddPanel={actions.onAddPanel}
onRename={actions.onRename}
onDeleteSection={actions.onDeleteSection}

View File

@@ -2,6 +2,8 @@ import { useEffect, useState } from 'react';
import { Modal } from 'antd';
import { Input } from '@signozhq/ui/input';
import { DASHBOARD_NAME_MAX_LENGTH } from '../../constants';
interface SectionTitleModalProps {
open: boolean;
/** Modal heading, e.g. "Rename section" / "New section". */
@@ -56,7 +58,7 @@ function SectionTitleModal({
testId="section-title-input"
autoFocus
value={value}
maxLength={120}
maxLength={DASHBOARD_NAME_MAX_LENGTH}
placeholder={placeholder}
onChange={(e): void => setValue(e.target.value)}
onKeyDown={(e): void => {

View File

@@ -0,0 +1,31 @@
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { useDashboardStore } from '../store/useDashboardStore';
import styles from './VariablesBar.module.scss';
/**
* Full-width labelled "Add variable" button shown in the empty state, before any
* variables exist. Opens the Variables settings tab with the add form primed.
*/
function AddVariableFull(): JSX.Element {
const requestSettings = useDashboardStore((s) => s.requestSettings);
return (
<Button
variant="outlined"
color="secondary"
size="md"
className={styles.addVariable}
prefix={<Plus size={14} />}
testId="dashboard-variables-add"
onClick={(): void =>
requestSettings({ tab: 'Variables', addVariable: true })
}
>
Add variable
</Button>
);
}
export default AddVariableFull;

View File

@@ -0,0 +1,35 @@
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { useDashboardStore } from '../store/useDashboardStore';
import styles from './VariablesBar.module.scss';
/**
* Compact "+" trigger (label on hover) shown after the variable pills once at
* least one variable exists. Opens the Variables settings tab with the add form
* primed.
*/
function AddVariableIcon(): JSX.Element {
const requestSettings = useDashboardStore((s) => s.requestSettings);
return (
<TooltipSimple side="top" title="Add variable">
<Button
variant="outlined"
color="secondary"
size="icon"
className={styles.addVariable}
aria-label="Add variable"
testId="dashboard-variables-add"
onClick={(): void =>
requestSettings({ tab: 'Variables', addVariable: true })
}
>
<Plus size={14} />
</Button>
</TooltipSimple>
);
}
export default AddVariableIcon;

View File

@@ -1,51 +1,37 @@
import { useMemo } from 'react';
import { SolidInfoCircle } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
// eslint-disable-next-line signoz/no-antd-components -- lightweight description tooltip, matches V1
import { Tooltip } from 'antd';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import { sortValuesByOrder } from '../DashboardSettings/Variables/variableFormModel';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
import type { VariableSelection, VariableSelectionMap } from './selectionTypes';
import CustomSelector from './selectors/CustomSelector';
import DynamicSelector from './selectors/DynamicSelector';
import QuerySelector from './selectors/QuerySelector';
import TextSelector from './selectors/TextSelector';
import ValueSelector from './selectors/ValueSelector';
import styles from './VariablesBar.module.scss';
interface VariableSelectorProps {
variable: VariableFormModel;
/** All variables (Dynamic uses them to scope options by sibling selections). */
variables: VariableFormModel[];
/** Names this variable depends on (for Query gating). */
parents: string[];
/** All current selections (Query passes them as the request payload). */
selections: VariableSelectionMap;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
/** Batched fill applied when options resolve (Query/Dynamic/Custom auto-selection). */
onAutoSelect: (selection: VariableSelection) => void;
}
/** One labelled variable control; dispatches on the variable type. */
function VariableSelector({
variable,
variables,
parents,
selections,
selection,
onChange,
onAutoSelect,
}: VariableSelectorProps): JSX.Element {
const customOptions = useMemo(
() =>
variable.type === 'CUSTOM'
? sortValuesByOrder(
commaValuesParser(variable.customValue),
variable.sort,
).map(String)
: [],
[variable],
);
const renderControl = (): JSX.Element => {
switch (variable.type) {
case 'TEXT':
@@ -61,10 +47,10 @@ function VariableSelector({
return (
<QuerySelector
variable={variable}
parents={parents}
selections={selections}
selection={selection}
onChange={onChange}
onAutoSelect={onAutoSelect}
/>
);
case 'DYNAMIC':
@@ -75,18 +61,17 @@ function VariableSelector({
selections={selections}
selection={selection}
onChange={onChange}
onAutoSelect={onAutoSelect}
/>
);
case 'CUSTOM':
default:
return (
<ValueSelector
options={customOptions}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
<CustomSelector
variable={variable}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}
onAutoSelect={onAutoSelect}
/>
);
}

View File

@@ -1,9 +1,21 @@
// Block (not flex) so the strip wraps around the floated time selector: one line
// beside it when collapsed, and — via `.stripExpanded`'s `clear: both` — dropping
// full-width below it when expanded. A flex `.bar` would be a BFC that sits beside
// the float and makes the child strip's `clear` inert.
.bar {
min-width: 0;
}
.addVariable {
flex: none;
border-style: dashed !important;
border-color: var(--l3-border) !important;
}
.strip {
display: flow-root;
// Collapsed: one line only, so the trailing add "+" never wraps below.
white-space: nowrap;
}
.stripExpanded {
@@ -12,10 +24,12 @@
gap: 8px;
padding-top: 12px;
overflow: visible;
white-space: normal;
clear: both;
.variableSlot,
.moreButton {
.moreButton,
.addSlot {
margin: 0;
}
@@ -48,6 +62,12 @@
}
.moreButton {
display: inline-flex;
margin-right: 8px;
vertical-align: top;
}
.addSlot {
display: inline-flex;
vertical-align: top;
}
@@ -112,3 +132,13 @@
box-shadow: none !important;
}
}
.overflowTooltip {
display: flex;
flex-direction: column;
gap: 2px;
}
.overflowName {
font-weight: var(--font-weight-medium);
}

View File

@@ -1,14 +1,36 @@
import { useState } from 'react';
import { ChevronLeft } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import cx from 'classnames';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { useInlineOverflowCount } from 'hooks/useInlineOverflowCount';
import { useDashboardStore } from '../store/useDashboardStore';
import AddVariableFull from './AddVariableFull';
import AddVariableIcon from './AddVariableIcon';
import type { VariableSelection } from './selectionTypes';
import { useVariableSelection } from './useVariableSelection';
import VariableSelector from './VariableSelector';
import styles from './VariablesBar.module.scss';
// Short display of a variable's current selection, for the collapsed +N tooltip.
function formatSelection(selection: VariableSelection | undefined): string {
if (!selection) {
return '—';
}
if (selection.allSelected) {
return 'ALL';
}
const { value } = selection;
if (Array.isArray(value)) {
return value.length > 0 ? value.join(', ') : '—';
}
return value === '' || value === null || value === undefined
? '—'
: String(value);
}
interface VariablesBarProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
}
@@ -23,21 +45,49 @@ interface VariablesBarProps {
* either way so auto-selection and option fetching keep driving the panels.
*/
function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
const { variables, dependencyData, selection, setSelection } =
const { variables, selection, setSelection, autoSelect } =
useVariableSelection(dashboard);
const isEditable = useDashboardStore((s) => s.isEditable);
const [expanded, setExpanded] = useState(false);
const { containerRef, visibleCount, overflowCount } = useInlineOverflowCount({
itemCount: variables.length,
gap: 8,
reserveWidth: 48,
// Reserve room for the "+N" trigger and the add "+" so both stay on one line.
reserveWidth: isEditable ? 112 : 48,
enabled: !expanded,
});
if (variables.length === 0) {
// Editors can add a variable even before any exist; viewers with no variables
// have nothing to show.
if (variables.length === 0 && !isEditable) {
return null;
}
const hasOverflow = overflowCount > 0;
const hiddenVariables =
!expanded && hasOverflow ? variables.slice(visibleCount) : [];
const moreButton = (
<Button
variant="outlined"
color="secondary"
size="md"
prefix={expanded ? <ChevronLeft size={14} /> : undefined}
aria-expanded={expanded}
testId="dashboard-variables-more"
onClick={(): void => setExpanded((prev) => !prev)}
>
{expanded ? 'Less' : `+${overflowCount}`}
</Button>
);
if (variables.length === 0) {
return (
<div className={styles.bar} data-testid="dashboard-variables-bar">
<AddVariableFull />
</div>
);
}
return (
<div className={styles.bar} data-testid="dashboard-variables-bar">
@@ -57,7 +107,6 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
<VariableSelector
variable={variable}
variables={variables}
parents={dependencyData.parentGraph[variable.name] ?? []}
selections={selection}
selection={
selection[variable.name] ?? {
@@ -66,23 +115,41 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
}
}
onChange={(next): void => setSelection(variable.name, next)}
onAutoSelect={(next): void => autoSelect(variable.name, next)}
/>
</div>
))}
{hasOverflow && (
<span className={styles.moreButton}>
<Button
variant="outlined"
color="secondary"
size="md"
prefix={expanded ? <ChevronLeft size={14} /> : undefined}
aria-expanded={expanded}
testId="dashboard-variables-more"
onClick={(): void => setExpanded((prev) => !prev)}
>
{expanded ? 'Less' : `+${overflowCount}`}
</Button>
{expanded ? (
moreButton
) : (
<TooltipSimple
side="top"
title={
<div className={styles.overflowTooltip}>
{hiddenVariables.map((variable) => (
<div key={variable.name}>
<span className={styles.overflowName}>{variable.name}</span>:{' '}
{formatSelection(selection[variable.name])}
</div>
))}
</div>
}
>
{moreButton}
</TooltipSimple>
)}
</span>
)}
{/* After the more/less trigger, in every state. Kept inline (not block)
so the row still flows under the floated time selector, and always
mounted so measuring never toggles it. */}
{isEditable && (
<span className={styles.addSlot}>
<AddVariableIcon />
</span>
)}
</div>

View File

@@ -0,0 +1,113 @@
import { renderHook } from '@testing-library/react';
import {
emptyVariableFormModel,
type VariableFormModel,
} from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableSelection } from '../selectionTypes';
import { useAutoSelect } from '../useAutoSelect';
function model(overrides: Partial<VariableFormModel>): VariableFormModel {
return { ...emptyVariableFormModel(), ...overrides };
}
function run(
variable: VariableFormModel,
options: string[],
selection: VariableSelection,
): VariableSelection | undefined {
const onAutoSelect = jest.fn();
renderHook(() => useAutoSelect(variable, options, selection, onAutoSelect));
return onAutoSelect.mock.calls[0]?.[0];
}
describe('useAutoSelect', () => {
it('materializes a query ALL selection to the full option array', () => {
const next = run(
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
['a', 'b', 'c'],
{ value: null, allSelected: true },
);
expect(next).toStrictEqual({ value: ['a', 'b', 'c'], allSelected: true });
});
it('re-materializes ALL when the options grow', () => {
const next = run(
model({ type: 'CUSTOM', multiSelect: true, showAllOption: true }),
['a', 'b', 'c', 'd'],
{ value: ['a', 'b', 'c'], allSelected: true },
);
expect(next).toStrictEqual({
value: ['a', 'b', 'c', 'd'],
allSelected: true,
});
});
it('leaves a query ALL selection untouched when already the full set', () => {
const next = run(
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
['a', 'b'],
{ value: ['a', 'b'], allSelected: true },
);
expect(next).toBeUndefined();
});
it('does NOT materialize a dynamic ALL selection (it sends __all__)', () => {
const next = run(
model({ type: 'DYNAMIC', multiSelect: true, showAllOption: true }),
['a', 'b'],
{ value: null, allSelected: true },
);
expect(next).toBeUndefined();
});
it('keeps the still-valid subset of a multi-select when options re-scope', () => {
const next = run(
model({ type: 'QUERY', multiSelect: true }),
['a', 'b', 'd'],
{ value: ['a', 'b', 'c'], allSelected: false },
);
expect(next).toStrictEqual({ value: ['a', 'b'], allSelected: false });
});
it('re-defaults a multi-select when none of the selected values remain', () => {
const next = run(model({ type: 'QUERY', multiSelect: true }), ['x', 'y'], {
value: ['a', 'b'],
allSelected: false,
});
expect(next).toStrictEqual({ value: ['x'], allSelected: false });
});
it('auto-selects the default (if present) for a single-select', () => {
const next = run(
model({ type: 'QUERY', defaultValue: 'b' }),
['a', 'b', 'c'],
{ value: '', allSelected: false },
);
expect(next).toStrictEqual({ value: 'b', allSelected: false });
});
it('auto-selects the first option when the default is not available', () => {
const next = run(model({ type: 'QUERY', defaultValue: 'z' }), ['a', 'b'], {
value: '',
allSelected: false,
});
expect(next).toStrictEqual({ value: 'a', allSelected: false });
});
it('leaves a valid single selection untouched', () => {
const next = run(model({ type: 'QUERY' }), ['a', 'b'], {
value: 'b',
allSelected: false,
});
expect(next).toBeUndefined();
});
it('does nothing while options are empty', () => {
const next = run(model({ type: 'QUERY' }), [], {
value: '',
allSelected: false,
});
expect(next).toBeUndefined();
});
});

View File

@@ -0,0 +1,50 @@
import { useMemo } from 'react';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import { sortValuesByOrder } from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableSelection } from '../selectionTypes';
import { useAutoSelect } from '../useAutoSelect';
import ValueSelector from './ValueSelector';
interface CustomSelectorProps {
variable: VariableFormModel;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
onAutoSelect: (selection: VariableSelection) => void;
}
/**
* Custom-variable options come from the comma-separated `customValue` (no fetch),
* but still auto-select a default/first option so the variable is never left blank.
*/
function CustomSelector({
variable,
selection,
onChange,
onAutoSelect,
}: CustomSelectorProps): JSX.Element {
const options = useMemo(
() =>
sortValuesByOrder(
commaValuesParser(variable.customValue),
variable.sort,
).map(String),
[variable.customValue, variable.sort],
);
useAutoSelect(variable, options, selection, onAutoSelect);
return (
<ValueSelector
options={options}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}
/>
);
}
export default CustomSelector;

View File

@@ -1,7 +1,9 @@
import { useMemo } from 'react';
import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
import { DASHBOARD_CACHE_TIME } from 'constants/queryCacheTime';
import type { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
@@ -10,12 +12,14 @@ import {
sortValuesByOrder,
} from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
import { useDashboardStore } from '../../store/useDashboardStore';
import { buildExistingDynamicVariableQuery } from '../dynamicFilter';
import type {
VariableSelection,
VariableSelectionMap,
} from '../selectionTypes';
import { useAutoSelect } from '../useAutoSelect';
import { useVariableFetchState } from '../useVariableFetchState';
import ValueSelector from './ValueSelector';
interface DynamicSelectorProps {
@@ -25,12 +29,16 @@ interface DynamicSelectorProps {
selections: VariableSelectionMap;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
/** Batched auto-selection fill applied when options resolve. */
onAutoSelect: (selection: VariableSelection) => void;
}
/**
* Dynamic-variable options sourced from live telemetry field values for the
* chosen signal + attribute, scoped by the other dynamic variables' selections
* (so e.g. `pod` narrows to the chosen `namespace`).
* (so e.g. `pod` narrows to the chosen `namespace`). WHEN to fetch is owned by
* the runtime fetch engine: dynamics fetch together once the query variables have
* values, and refetch (via a `cycleId` bump) whenever any variable value changes.
*/
function DynamicSelector({
variable,
@@ -38,6 +46,7 @@ function DynamicSelector({
selections,
selection,
onChange,
onAutoSelect,
}: DynamicSelectorProps): JSX.Element {
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
@@ -48,14 +57,53 @@ function DynamicSelector({
[variables, selections, variable.name],
);
const { data, isFetching } = useGetFieldValues({
signal: signalForApi(variable.dynamicSignal),
name: variable.dynamicAttribute,
startUnixMilli: minTime,
endUnixMilli: maxTime,
existingQuery: existingQuery || undefined,
enabled: !!variable.dynamicAttribute,
});
const {
variableFetchCycleId,
isVariableFetching,
isVariableSettled,
isVariableWaiting,
hasVariableFetchedOnce,
} = useVariableFetchState(variable.name);
const onVariableFetchComplete = useDashboardStore(
(s) => s.onVariableFetchComplete,
);
const onVariableFetchFailure = useDashboardStore(
(s) => s.onVariableFetchFailure,
);
const { data, isFetching, error, refetch } = useQuery(
[
'dashboard-variable-dynamic',
variable.name,
variable.dynamicSignal,
variable.dynamicAttribute,
existingQuery,
minTime,
maxTime,
variableFetchCycleId,
],
() =>
getFieldValues(
signalForApi(variable.dynamicSignal),
variable.dynamicAttribute,
undefined,
minTime,
maxTime,
existingQuery || undefined,
),
{
enabled:
!!variable.dynamicAttribute &&
(isVariableFetching || (isVariableSettled && hasVariableFetchedOnce)),
refetchOnWindowFocus: false,
// Each cycle mints a fresh key; a small cacheTime bounds cache churn.
cacheTime: DASHBOARD_CACHE_TIME,
onSettled: (_, error) =>
error
? onVariableFetchFailure(variable.name)
: onVariableFetchComplete(variable.name),
},
);
const options = useMemo(() => {
const payload = data?.data;
@@ -64,14 +112,18 @@ function DynamicSelector({
return sortValuesByOrder(values, variable.sort).map(String);
}, [data, variable.sort]);
useAutoSelect(variable, options, selection, onChange);
useAutoSelect(variable, options, selection, onAutoSelect);
return (
<ValueSelector
options={options}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
loading={isFetching}
loading={isFetching || isVariableWaiting}
errorMessage={error ? (error as Error).message || null : null}
onRetry={(): void => {
void refetch();
}}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}

View File

@@ -3,63 +3,90 @@ import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import { DASHBOARD_CACHE_TIME } from 'constants/queryCacheTime';
import type { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
import { sortValuesByOrder } from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
import { useDashboardStore } from '../../store/useDashboardStore';
import type {
VariableSelection,
VariableSelectionMap,
} from '../selectionTypes';
import { isResolved, selectionToPayload } from '../selectionUtils';
import { selectionToPayload } from '../selectionUtils';
import { useAutoSelect } from '../useAutoSelect';
import { useVariableFetchState } from '../useVariableFetchState';
import ValueSelector from './ValueSelector';
interface QuerySelectorProps {
variable: VariableFormModel;
/** Names this variable's query references; it waits until they're resolved. */
parents: string[];
/** All current selections, fed to the query as `{ name: value }`. */
selections: VariableSelectionMap;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
/** Batched auto-selection fill applied when options resolve. */
onAutoSelect: (selection: VariableSelection) => void;
}
/**
* Query-driven options. Dependency orchestration is declarative: the query is
* `enabled` only once every parent is resolved, and the parent values are in the
* query key — so it refetches automatically when a parent changes (and a cyclic
* dependency is simply never enabled).
* Query-driven options. WHEN to fetch is owned by the runtime fetch engine
* (`variableFetchSlice`): the query is `enabled` while this variable is fetching
* (or settled-after-a-first-fetch, so a cycle bump re-runs it), and the engine's
* per-variable `cycleId` keys the request — so a parent's value change refetches
* only the dependent variables, in dependency order. The current selections feed
* the request payload but are deliberately NOT in the key (V1 parity).
*/
function QuerySelector({
variable,
parents,
selections,
selection,
onChange,
onAutoSelect,
}: QuerySelectorProps): JSX.Element {
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const payload = useMemo(() => selectionToPayload(selections), [selections]);
const enabled = parents.every((parent) => isResolved(selections[parent]));
const { data, isFetching } = useQuery(
const {
variableFetchCycleId,
isVariableFetching,
isVariableSettled,
isVariableWaiting,
hasVariableFetchedOnce,
} = useVariableFetchState(variable.name);
const onVariableFetchComplete = useDashboardStore(
(s) => s.onVariableFetchComplete,
);
const onVariableFetchFailure = useDashboardStore(
(s) => s.onVariableFetchFailure,
);
const { data, isFetching, error, refetch } = useQuery(
[
'dashboard-variable',
variable.name,
variable.queryValue,
payload,
minTime,
maxTime,
variableFetchCycleId,
],
() =>
dashboardVariablesQuery({
query: variable.queryValue,
variables: payload,
}),
{ enabled, refetchOnWindowFocus: false },
{
enabled: isVariableFetching || (isVariableSettled && hasVariableFetchedOnce),
refetchOnWindowFocus: false,
// Each cycle mints a fresh key; a small cacheTime bounds cache churn.
cacheTime: DASHBOARD_CACHE_TIME,
onSettled: (_, error) =>
error
? onVariableFetchFailure(variable.name)
: onVariableFetchComplete(variable.name),
},
);
const options = useMemo(() => {
@@ -72,14 +99,18 @@ function QuerySelector({
).map(String);
}, [data, variable.sort]);
useAutoSelect(variable, options, selection, onChange);
useAutoSelect(variable, options, selection, onAutoSelect);
return (
<ValueSelector
options={options}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
loading={isFetching}
loading={isFetching || isVariableWaiting}
errorMessage={error ? (error as Error).message || null : null}
onRetry={(): void => {
void refetch();
}}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}

View File

@@ -1,7 +1,6 @@
import { useMemo } from 'react';
import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
import type { OptionData } from 'components/NewSelect/types';
import { ALL_SELECT_VALUE } from 'container/DashboardContainer/utils';
import type { VariableSelection } from '../selectionTypes';
import styles from '../VariablesBar.module.scss';
@@ -14,6 +13,9 @@ interface ValueSelectorProps {
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
testId?: string;
/** Option-fetch error surfaced in the dropdown, with a retry action. */
errorMessage?: string | null;
onRetry?: () => void;
}
/**
@@ -29,6 +31,8 @@ function ValueSelector({
selection,
onChange,
testId,
errorMessage,
onRetry,
}: ValueSelectorProps): JSX.Element {
const optionData = useMemo<OptionData[]>(
() => options.map((option) => ({ label: option, value: option })),
@@ -36,8 +40,11 @@ function ValueSelector({
);
if (multiSelect) {
// All-selected → hand CustomMultiSelect the full option set so it engages its
// "all" path (overlay when closed, every option checked when open). Passing the
// scalar sentinel instead makes it render a literal `__ALL__` row.
const value = selection.allSelected
? ALL_SELECT_VALUE
? options
: (Array.isArray(selection.value) ? selection.value : []).map(String);
return (
<CustomMultiSelect
@@ -46,6 +53,8 @@ function ValueSelector({
options={optionData}
value={value}
loading={loading}
errorMessage={errorMessage}
onRetry={onRetry}
showSearch
placeholder="Select value"
enableAllSelection={showAllOption}
@@ -82,6 +91,8 @@ function ValueSelector({
: String(selection.value)
}
loading={loading}
errorMessage={errorMessage}
onRetry={onRetry}
showSearch
placeholder="Select value"
onChange={(next): void =>

View File

@@ -1,41 +1,99 @@
import { useEffect } from 'react';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
import type { VariableSelection } from './selectionTypes';
import type {
SelectedVariableValue,
VariableSelection,
} from './selectionTypes';
/** The variable's default (or first option) as a fresh selection. */
function fillDefault(
variable: VariableFormModel,
options: string[],
): VariableSelection {
const dv = variable.defaultValue;
const fallback = Array.isArray(dv) ? dv[0] : dv;
const initial = fallback && options.includes(fallback) ? fallback : options[0];
return {
value: variable.multiSelect ? [initial] : initial,
allSelected: false,
};
}
/** For an all-selected variable, the value to materialize (or null if unchanged). */
function reconcileAllSelected(
variable: VariableFormModel,
options: string[],
current: SelectedVariableValue,
): VariableSelection | null {
// Dynamic ALL travels as the `__all__` wire sentinel and shows ALL from the
// flag, so it needs no materialized value. Query/custom ALL must carry the full
// option array (the payload builder can't expand it) — keep it in sync.
if (!variable.multiSelect || variable.type === 'DYNAMIC') {
return null;
}
const alreadyFull =
Array.isArray(current) &&
current.length === options.length &&
current.every((c) => options.includes(String(c)));
return alreadyFull ? null : { value: options, allSelected: true };
}
function isValidSingle(
current: SelectedVariableValue,
options: string[],
): boolean {
return (
!Array.isArray(current) &&
current !== '' &&
current !== null &&
current !== undefined &&
options.includes(String(current))
);
}
/**
* When fetched options arrive and the current selection isn't one of them,
* auto-pick the variable's default (if present in the options) or the first
* option so dependent children always have a usable parent value.
* Reconciles a variable's selection with its freshly-fetched options: materialize
* ALL to the full set, keep a still-valid multi-select subset, else auto-pick the
* default (or first option) so dependent children always have a usable value.
*/
export function useAutoSelect(
variable: VariableFormModel,
options: string[],
selection: VariableSelection,
onChange: (selection: VariableSelection) => void,
onAutoSelect: (selection: VariableSelection) => void,
): void {
useEffect(() => {
if (options.length === 0 || selection.allSelected) {
if (options.length === 0) {
return;
}
const current = selection.value;
const isValid = Array.isArray(current)
? current.length > 0 && current.every((c) => options.includes(String(c)))
: current !== '' &&
current !== null &&
current !== undefined &&
options.includes(String(current));
if (isValid) {
if (selection.allSelected) {
const next = reconcileAllSelected(variable, options, current);
if (next) {
onAutoSelect(next);
}
return;
}
const fallback = (variable.defaultValue as { value?: string } | undefined)
?.value;
const initial =
fallback && options.includes(fallback) ? fallback : options[0];
onChange({
value: variable.multiSelect ? [initial] : initial,
allSelected: false,
});
if (variable.multiSelect && Array.isArray(current) && current.length > 0) {
const valid = current.map(String).filter((c) => options.includes(c));
if (valid.length === current.length) {
return;
}
onAutoSelect(
valid.length > 0
? { value: valid, allSelected: false }
: fillDefault(variable, options),
);
return;
}
if (!variable.multiSelect && isValidSingle(current, options)) {
return;
}
onAutoSelect(fillDefault(variable, options));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [options]);
}

View File

@@ -0,0 +1,45 @@
import {
selectVariableCycleId,
selectVariableFetchedOnce,
selectVariableFetchState,
VariableFetchState,
} from '../store/slices/variableFetchSlice';
import { useDashboardStore } from '../store/useDashboardStore';
export interface VariableFetchStateResult {
variableFetchState: VariableFetchState;
/** Include in the selector's react-query key to auto-cancel stale requests. */
variableFetchCycleId: number;
/** Actively fetching (first load or revalidating). */
isVariableFetching: boolean;
/** Stable — the fetch completed (or errored). */
isVariableSettled: boolean;
/** Blocked on parent dependencies (query order) or query variables (dynamics). */
isVariableWaiting: boolean;
/** Completed at least one fetch — keeps the query subscribed so a cycle bump refetches. */
hasVariableFetchedOnce: boolean;
}
/**
* Per-variable view of the runtime fetch engine (`variableFetchSlice`), consumed
* by the Query/Dynamic selectors to gate their fetch and key it by cycle id.
* V2-native equivalent of V1's `useVariableFetchState`.
*/
export function useVariableFetchState(name: string): VariableFetchStateResult {
const variableFetchState = useDashboardStore(selectVariableFetchState(name));
const variableFetchCycleId = useDashboardStore(selectVariableCycleId(name));
const hasVariableFetchedOnce = useDashboardStore(
selectVariableFetchedOnce(name),
);
return {
variableFetchState,
variableFetchCycleId,
isVariableFetching:
variableFetchState === VariableFetchState.Loading ||
variableFetchState === VariableFetchState.Revalidating,
isVariableSettled: variableFetchState === VariableFetchState.Idle,
isVariableWaiting: variableFetchState === VariableFetchState.Waiting,
hasVariableFetchedOnce,
};
}

View File

@@ -1,6 +1,10 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { parseAsJson, useQueryState } from 'nuqs';
// eslint-disable-next-line no-restricted-imports -- global time selector still on redux
import { useSelector } from 'react-redux';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import type { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
import { dtoToFormModel } from '../DashboardSettings/Variables/variableAdapters';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
@@ -12,8 +16,8 @@ import type {
VariableSelectionMap,
} from './selectionTypes';
import {
computeVariableDependencies,
type VariableDependencyData,
deriveFetchContext,
doAllQueryVariablesHaveValues,
} from './variableDependencies';
/** URL sentinel for an "ALL values selected" state (matches V1). */
@@ -29,26 +33,48 @@ export const variablesUrlParser = parseAsJson<
);
function defaultSelection(model: VariableFormModel): VariableSelection {
const def = (
model.defaultValue as { value?: SelectedVariableValue } | undefined
)?.value;
if (def !== undefined && def !== null && def !== '') {
const def = model.defaultValue;
// Explicit ALL sentinel, or a multi-select allowing ALL with no default → ALL.
if (
def === ALL_SELECTED ||
(Array.isArray(def) && def.length === 1 && def[0] === ALL_SELECTED)
) {
return { value: null, allSelected: true };
}
if (Array.isArray(def) && def.length > 0) {
return { value: def, allSelected: false };
}
if (typeof def === 'string' && def !== '') {
return { value: model.multiSelect ? [def] : def, allSelected: false };
}
if (model.multiSelect && model.showAllOption) {
return { value: null, allSelected: true };
}
return { value: model.multiSelect ? [] : '', allSelected: false };
}
function fromUrlValue(raw: SelectedVariableValue): VariableSelection {
return raw === ALL_SELECTED
? { value: null, allSelected: true }
: { value: raw, allSelected: false };
function fromUrlValue(
raw: SelectedVariableValue,
model: VariableFormModel,
): VariableSelection {
// Only read the `__ALL__` sentinel as "ALL" for variables that support it —
// otherwise a legitimate value of "__ALL__" (e.g. a text var) is taken literally.
if (raw === ALL_SELECTED && model.multiSelect && model.showAllOption) {
return { value: null, allSelected: true };
}
return { value: raw, allSelected: false };
}
interface UseVariableSelection {
variables: VariableFormModel[];
dependencyData: VariableDependencyData;
selection: VariableSelectionMap;
setSelection: (name: string, selection: VariableSelection) => void;
/**
* Auto-selection fill (default/first-option) applied when options arrive. Unlike
* {@link UseVariableSelection.setSelection}, fills from the initial load burst are
* coalesced into one store write + one downstream refresh.
*/
autoSelect: (name: string, selection: VariableSelection) => void;
}
/**
@@ -65,33 +91,43 @@ export function useVariableSelection(
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
[dashboard.spec?.variables],
);
const dependencyData = useMemo(
() => computeVariableDependencies(variables),
[variables],
);
const fetchContext = useMemo(() => deriveFetchContext(variables), [variables]);
const selection = useDashboardStore(selectVariableValues(dashboardId));
const setVariableValue = useDashboardStore((s) => s.setVariableValue);
const setVariableValues = useDashboardStore((s) => s.setVariableValues);
const initVariableFetch = useDashboardStore((s) => s.initVariableFetch);
const enqueueFetchAll = useDashboardStore((s) => s.enqueueFetchAll);
const enqueueDescendants = useDashboardStore((s) => s.enqueueDescendants);
const enqueueDescendantsBatch = useDashboardStore(
(s) => s.enqueueDescendantsBatch,
);
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
// Latest selection, read by the fetch-cycle effect without subscribing to it
// (so a value change doesn't re-trigger a full fetch cycle).
const selectionRef = useRef(selection);
selectionRef.current = selection;
const [urlValues, setUrlValues] = useQueryState(
'variables',
variablesUrlParser.withOptions({ history: 'replace' }),
);
// Seed selections for this dashboard: URL wins, then persisted store, then default.
// Seed selections: URL wins, then persisted store, then default.
useEffect(() => {
if (!dashboardId || variables.length === 0) {
return;
}
// `selection` here is the persisted (localStorage) map on mount — the
// effect deliberately doesn't depend on it, so seeding runs once per set.
const stored = selection;
const seeded: VariableSelectionMap = {};
variables.forEach((variable) => {
const urlValue = urlValues?.[variable.name];
if (urlValue !== undefined) {
seeded[variable.name] = fromUrlValue(urlValue);
seeded[variable.name] = fromUrlValue(urlValue, variable);
} else if (stored[variable.name]) {
seeded[variable.name] = stored[variable.name];
} else {
@@ -99,19 +135,101 @@ export function useVariableSelection(
}
});
setVariableValues(dashboardId, seeded);
// Drop URL selections for variables that no longer exist (renamed/removed),
// so a shared link doesn't carry stale entries a later variable could inherit.
if (urlValues) {
const validNames = new Set(variables.map((v) => v.name));
const orphaned = Object.keys(urlValues).some((n) => !validNames.has(n));
if (orphaned) {
const pruned: Record<string, SelectedVariableValue> = {};
Object.entries(urlValues).forEach(([name, value]) => {
if (validNames.has(name)) {
pruned[name] = value;
}
});
void setUrlValues(pruned);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboardId, variables]);
// Start a full fetch cycle on load / dependency-order / time change. A value
// change instead goes through `enqueueDescendants`, not this effect.
const orderKey = `${fetchContext.queryVariableOrder.join(
',',
)}|${fetchContext.dynamicVariableOrder.join(',')}`;
useEffect(() => {
if (!dashboardId || variables.length === 0) {
return;
}
const names = variables
.map((v) => v.name)
.filter((name): name is string => !!name);
initVariableFetch(names, fetchContext);
enqueueFetchAll(
doAllQueryVariablesHaveValues(variables, selectionRef.current),
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboardId, orderKey, minTime, maxTime]);
const setSelection = useCallback(
(name: string, next: VariableSelection): void => {
setVariableValue(dashboardId, name, next);
enqueueDescendants(name);
void setUrlValues((prev) => ({
...(prev ?? {}),
[name]: next.allSelected ? ALL_SELECTED : next.value,
}));
},
[dashboardId, setVariableValue, setUrlValues],
[dashboardId, setVariableValue, enqueueDescendants, setUrlValues],
);
return { variables, dependencyData, selection, setSelection };
// Coalesce the initial load burst of auto-selections: each selector fills its
// value as its options resolve (at different times). Collecting them into one
// store write + one `enqueueDescendantsBatch` means dependents re-fetch once
// with the settled parent values, instead of once per fill.
const pendingAutoFillRef = useRef<VariableSelectionMap>({});
const autoFillFrameRef = useRef<number | null>(null);
const flushAutoFills = useCallback((): void => {
autoFillFrameRef.current = null;
const fills = pendingAutoFillRef.current;
pendingAutoFillRef.current = {};
const names = Object.keys(fills);
if (names.length === 0 || !dashboardId) {
return;
}
setVariableValues(dashboardId, { ...selectionRef.current, ...fills });
void setUrlValues((prev) => {
const next = { ...(prev ?? {}) };
names.forEach((name) => {
const sel = fills[name];
next[name] = sel.allSelected ? ALL_SELECTED : sel.value;
});
return next;
});
enqueueDescendantsBatch(names);
}, [dashboardId, setVariableValues, setUrlValues, enqueueDescendantsBatch]);
const autoSelect = useCallback(
(name: string, next: VariableSelection): void => {
pendingAutoFillRef.current[name] = next;
if (autoFillFrameRef.current == null) {
autoFillFrameRef.current = requestAnimationFrame(flushAutoFills);
}
},
[flushAutoFills],
);
useEffect(
() => (): void => {
if (autoFillFrameRef.current != null) {
cancelAnimationFrame(autoFillFrameRef.current);
}
},
[],
);
return { variables, selection, setSelection, autoSelect };
}

View File

@@ -1,6 +1,11 @@
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
import type {
VariableFormModel,
VariableType,
} from '../DashboardSettings/Variables/variableFormModel';
import type { VariableSelectionMap } from './selectionTypes';
import { isResolved } from './selectionUtils';
/**
* Inter-variable dependency graph for runtime selection. A QUERY variable
@@ -197,3 +202,57 @@ export function computeVariableDependencies(
): VariableDependencyData {
return buildDependencyData(buildDependencies(variables));
}
/**
* Static context the runtime fetch engine (`variableFetchSlice`) needs to order
* fetches: the dependency graph plus the per-name type index and the QUERY /
* DYNAMIC fetch orders. Derived from the variable definitions; stable until the
* spec's variables change. Mirrors V1's `getVariableDependencyContext`.
*/
export interface VariableFetchContext {
dependencyData: VariableDependencyData;
/** variable name → its type. */
variableTypes: Record<string, VariableType>;
/** QUERY variables in topological (parent-before-child) order. */
queryVariableOrder: string[];
/** DYNAMIC variable names (they implicitly depend on all QUERY values). */
dynamicVariableOrder: string[];
}
export function deriveFetchContext(
variables: VariableFormModel[],
): VariableFetchContext {
const dependencyData = computeVariableDependencies(variables);
const variableTypes: Record<string, VariableType> = {};
variables.forEach((v) => {
if (v.name) {
variableTypes[v.name] = v.type;
}
});
const queryVariableOrder = dependencyData.order.filter(
(name) => variableTypes[name] === 'QUERY',
);
const dynamicVariableOrder = variables
.filter((v) => v.type === 'DYNAMIC' && !!v.name)
.map((v) => v.name);
return {
dependencyData,
variableTypes,
queryVariableOrder,
dynamicVariableOrder,
};
}
/**
* Whether every QUERY variable already has a usable selection — decides at load
* time whether dynamic variables may fetch immediately or must wait for the
* query variables to settle first (V1 parity).
*/
export function doAllQueryVariablesHaveValues(
variables: VariableFormModel[],
selection: VariableSelectionMap,
): boolean {
return variables
.filter((v) => v.type === 'QUERY')
.every((v) => isResolved(selection[v.name]));
}

View File

@@ -1,5 +0,0 @@
.label {
// Re-enable pointer events so the tooltip fires while the disabled row
// (pointer-events: none) suppresses selection.
pointer-events: auto;
}

View File

@@ -1,27 +0,0 @@
import type { ReactNode } from 'react';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import styles from './DisabledMenuItemLabel.module.scss';
interface DisabledMenuItemLabelProps {
reason: string;
children: ReactNode;
}
/**
* Menu-item label that shows a hover tooltip even though the row is disabled.
* A disabled dropdown item has `pointer-events: none`, so the label re-enables
* them (`styles.label`) to become a valid hover target for the tooltip.
*/
function DisabledMenuItemLabel({
reason,
children,
}: DisabledMenuItemLabelProps): JSX.Element {
return (
<TooltipSimple title={reason} disableHoverableContent>
<span className={styles.label}>{children}</span>
</TooltipSimple>
);
}
export default DisabledMenuItemLabel;

View File

@@ -0,0 +1,2 @@
/** Max length for dashboard / section / panel / variable name inputs. */
export const DASHBOARD_NAME_MAX_LENGTH = 128;

View File

@@ -21,8 +21,8 @@ jest.mock('api/generated/services/dashboard', () => ({
jest.mock('../../store/useDashboardStore', () => ({
useDashboardStore: jest.fn(
(selector: (s: { dashboardId: string; isEditable: boolean }) => unknown) =>
selector({ dashboardId: 'dash-1', isEditable: true }),
(selector: (s: { dashboardId: string }) => unknown) =>
selector({ dashboardId: 'dash-1' }),
),
}));

View File

@@ -1,54 +0,0 @@
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import useComponentPermission from 'hooks/useComponentPermission';
import { useAppContext } from 'providers/App/App';
import {
DASHBOARD_LOCKED_REASON,
DASHBOARD_NO_EDIT_PERMISSION_REASON,
} from '../store/slices/editContextSlice';
// Re-exported from the (dependency-light) store slice so importing just the reason
// strings doesn't pull this hook's provider chain into leaf modules / unit tests.
export {
DASHBOARD_LOCKED_REASON,
DASHBOARD_NO_EDIT_PERMISSION_REASON,
} from '../store/slices/editContextSlice';
export interface DashboardEditGuard {
/** `canEditDashboard && !isLocked` — the effective gate for performing edits. */
isEditable: boolean;
isLocked: boolean;
/** The user's role grants edit permission, regardless of the lock. */
canEditDashboard: boolean;
/** Why edits are disabled (locked vs no-permission), for tooltips; '' when editable. */
editDisabledReason: string;
}
/**
* Single source of truth for whether a V2 dashboard can be edited, plus the reason
* it can't (locked vs no permission) so controls can render disabled with a tooltip.
* Used where the store isn't seeded (the panel-editor route reached by direct URL).
*/
export function useDashboardEditGuard(
dashboard: DashboardtypesGettableDashboardV2DTO | undefined,
): DashboardEditGuard {
const { user } = useAppContext();
const [editDashboardPermission] = useComponentPermission(
['edit_dashboard'],
user.role,
);
const canEditDashboard = !!editDashboardPermission;
const isLocked = !!dashboard?.locked;
let editDisabledReason = '';
if (isLocked) {
editDisabledReason = DASHBOARD_LOCKED_REASON;
} else if (!canEditDashboard) {
editDisabledReason = DASHBOARD_NO_EDIT_PERMISSION_REASON;
}
return {
isEditable: canEditDashboard && !isLocked,
isLocked,
canEditDashboard,
editDisabledReason,
};
}

View File

@@ -0,0 +1,23 @@
import { isResolved } from '../VariablesBar/selectionUtils';
import { selectVariableValues } from '../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../store/useDashboardStore';
/**
* True while a panel should stay in its loading state because a variable it
* references is still loading/waiting and has no usable value yet — i.e. the
* first load. Once the variable has a value, a later change no longer blocks the
* panel (it refetches over stale data instead). V1 parity with
* `useIsPanelWaitingOnVariable`.
*/
export function useIsPanelWaitingOnVariable(names: string[]): boolean {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const states = useDashboardStore((s) => s.variableFetchStates);
const selection = useDashboardStore(selectVariableValues(dashboardId));
return names.some((name) => {
const state = states[name];
const inFlight =
state === 'loading' || state === 'revalidating' || state === 'waiting';
return isResolved(selection[name]) ? false : inFlight;
});
}

View File

@@ -1,4 +1,3 @@
import { useCallback } from 'react';
import { useMutation, useQueryClient } from 'react-query';
import {
getGetDashboardV2QueryKey,
@@ -12,7 +11,6 @@ import type {
import APIError from 'types/api/error';
import { applyJsonPatch } from '../optimistic/applyJsonPatch';
import { DASHBOARD_LOCKED_REASON } from '../store/slices/editContextSlice';
import { useDashboardStore } from '../store/useDashboardStore';
/** Cached dashboard snapshot, kept for rollback on error. */
@@ -37,7 +35,6 @@ export function useOptimisticPatch(
dashboardIdOverride?: string,
): UseOptimisticPatch {
const storeDashboardId = useDashboardStore((s) => s.dashboardId);
const storeIsEditable = useDashboardStore((s) => s.isEditable);
const dashboardId = dashboardIdOverride ?? storeDashboardId;
const queryClient = useQueryClient();
const queryKey = getGetDashboardV2QueryKey({ id: dashboardId });
@@ -72,24 +69,8 @@ export function useOptimisticPatch(
},
});
// Defense-in-depth: block edits when the store is warm for this dashboard and
// it isn't editable (locked/no-permission). Callers already hide/disable their
// controls; this guards any un-gated or future caller. When the store isn't
// seeded for this id (e.g. the panel editor reached by direct URL), fall through
// — that surface derives editability itself and gates its own save.
const { mutateAsync } = mutation;
const patchAsync = useCallback(
(ops: DashboardtypesJSONPatchOperationDTO[]): Promise<unknown> => {
if (storeDashboardId === dashboardId && !storeIsEditable) {
return Promise.reject(new Error(DASHBOARD_LOCKED_REASON));
}
return mutateAsync(ops);
},
[storeDashboardId, dashboardId, storeIsEditable, mutateAsync],
);
return {
patchAsync,
patchAsync: mutation.mutateAsync,
isPatching: mutation.isLoading,
error: mutation.error ?? null,
};

View File

@@ -15,12 +15,14 @@ import {
} from '../queryV5/buildQueryRangeRequest';
import type { PanelPagination, PanelQueryData } from '../queryV5/types';
import { getRawResults } from '../queryV5/v5ResponseData';
import { getReferencedVariables } from '../queryV5/getReferencedVariables';
import { getBuilderQueries } from '../Panels/utils/getBuilderQueries';
import { PANEL_KIND_TO_PANEL_TYPE } from '../Panels/types/panelKind';
import { selectResolvedVariables } from '../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../store/useDashboardStore';
import { resolvePanelTimeWindow } from './resolvePanelTimeWindow';
import { useGetQueryRangeV5 } from './useGetQueryRangeV5';
import { useIsPanelWaitingOnVariable } from './useIsPanelWaitingOnVariable';
// V1 parity: PER_PAGE_OPTIONS + default page size from V1's list views.
const LIST_PAGE_SIZE_OPTIONS = [10, 25, 50, 100, 200];
@@ -111,9 +113,34 @@ export function usePanelQuery({
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
// Resolved variable values for this dashboard, published by useResolvedVariables.
// Substituted into the request and keyed into the cache so a selection change refetches.
// The full set is substituted into every request, but only the values this panel
// *references* key the cache — so a variable change refetches only the panels that
// use it (V1 parity). Names come from the fetch context (all variables, even
// unresolved ones); null before the variable bar initializes it.
const dashboardId = useDashboardStore((s) => s.dashboardId);
const variables = useDashboardStore(selectResolvedVariables(dashboardId));
const fetchContext = useDashboardStore((s) => s.variableFetchContext);
const referencedVariableNames = useMemo(() => {
const allNames = fetchContext ? Object.keys(fetchContext.variableTypes) : [];
return getReferencedVariables(queries, allNames);
}, [queries, fetchContext]);
const scopedVariables = useMemo(() => {
const scoped: typeof variables = {};
referencedVariableNames.forEach((name) => {
if (variables[name] !== undefined) {
scoped[name] = variables[name];
}
});
return scoped;
}, [variables, referencedVariableNames]);
// First-load gate: hold the panel in its loading state until every referenced
// variable has resolved a value.
const isWaitingOnVariable = useIsPanelWaitingOnVariable(
referencedVariableNames,
);
// `visualization` exists only on variants that declare it — read via `in` narrowing over the
// generated union (no cast). `fillSpans` (TimeSeries/Bar only) → formatOptions.fillGaps.
@@ -188,8 +215,9 @@ export function usePanelQuery({
// Each page is its own cache entry (0/default for non-paged kinds).
offset,
pageSize,
// Variable selection changes the request, so it must re-key the cache (refetch).
variables,
// Only the variables this panel references re-key the cache, so an unrelated
// variable change doesn't refetch it.
scopedVariables,
],
[
panelId,
@@ -205,14 +233,14 @@ export function usePanelQuery({
queries,
offset,
pageSize,
variables,
scopedVariables,
],
);
const response = useGetQueryRangeV5({
requestPayload,
queryKey,
enabled: enabled && runnable,
enabled: enabled && runnable && !isWaitingOnVariable,
// Hold the current page while the next loads (offset re-keys) so the pager doesn't flash.
keepPreviousData: isPaginated,
});

View File

@@ -0,0 +1,75 @@
import { useEffect, useMemo } from 'react';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { setDashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
import type {
IDashboardVariable,
TVariableQueryType,
} from 'types/api/dashboard/getAll';
import { dtoToFormModel } from '../DashboardSettings/Variables/variableAdapters';
import {
DYNAMIC_SIGNAL_ALL,
type VariableFormModel,
type VariableType,
} from '../DashboardSettings/Variables/variableFormModel';
const TYPE_TO_V1: Record<VariableType, TVariableQueryType> = {
QUERY: 'QUERY',
CUSTOM: 'CUSTOM',
TEXT: 'TEXTBOX',
DYNAMIC: 'DYNAMIC',
};
/** Minimal V1-shaped variable — only the fields the shared query builder reads. */
function toV1Variable(model: VariableFormModel): IDashboardVariable {
return {
id: model.name,
name: model.name,
description: model.description,
type: TYPE_TO_V1[model.type],
queryValue: model.queryValue,
customValue: model.customValue,
textboxValue: model.textValue,
sort: 'DISABLED',
multiSelect: model.multiSelect,
showALLOption: model.showAllOption,
dynamicVariablesAttribute: model.dynamicAttribute,
dynamicVariablesSource:
model.dynamicSignal === DYNAMIC_SIGNAL_ALL
? 'all sources'
: model.dynamicSignal,
};
}
/**
* Publishes the V2 dashboard's variables into the shared `dashboardVariablesStore`
* that the query builder's autocomplete (`QuerySearch`) reads, so `$variable`
* suggestions show up in the panel editor and the dashboards-page query builder.
* Suggestion-only — the runtime engine lives in the V2 store. Clears on unmount so
* the shared store doesn't leak into other pages.
*/
export function useSyncVariablesForSuggestions(
dashboard: DashboardtypesGettableDashboardV2DTO | undefined,
): void {
const dashboardId = dashboard?.id ?? '';
const specVariables = dashboard?.spec?.variables;
const variables = useMemo(
() => (specVariables ?? []).map(dtoToFormModel),
[specVariables],
);
useEffect(() => {
if (!dashboardId) {
return undefined;
}
const record: Record<string, IDashboardVariable> = {};
variables.forEach((model) => {
if (model.name) {
record[model.name] = toV1Variable(model);
}
});
setDashboardVariablesStore({ dashboardId, variables: record });
return (): void =>
setDashboardVariablesStore({ dashboardId: '', variables: {} });
}, [dashboardId, variables]);
}

View File

@@ -2,11 +2,13 @@ import { useEffect } from 'react';
import { FullScreen, useFullScreenHandle } from 'react-full-screen';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import useComponentPermission from 'hooks/useComponentPermission';
import { useAppContext } from 'providers/App/App';
import DashboardPageToolbar from './DashboardPageToolbar';
import PanelsAndSectionsLayout from './PanelsAndSectionsLayout';
import { useDashboardEditGuard } from './hooks/useDashboardEditGuard';
import { useResolvedVariables } from './hooks/useResolvedVariables';
import { useSyncVariablesForSuggestions } from './hooks/useSyncVariablesForSuggestions';
import { useDashboardStore } from './store/useDashboardStore';
import styles from './DashboardContainer.module.scss';
import DashboardPageHeader from './components/DashboardPageHeader/DashboardPageHeader';
@@ -31,15 +33,18 @@ function DashboardContainer({
const fullScreenHandle = useFullScreenHandle();
const { isLocked, canEditDashboard } = useDashboardEditGuard(dashboard);
const { user } = useAppContext();
const [editDashboardPermission] = useComponentPermission(
['edit_dashboard'],
user.role,
);
// Seed during render (not an effect) so the first Panel render already sees the id —
// useDashboardFetchRequired throws on a missing id. setEditContext self-guards.
const setEditContext = useDashboardStore((s) => s.setEditContext);
setEditContext({
dashboardId: dashboard.id,
isLocked,
canEditDashboard,
isEditable: !dashboard.locked && editDashboardPermission,
refetch,
});
@@ -47,11 +52,23 @@ function DashboardContainer({
// the store, so each panel's query substitutes the bar's selected values.
useResolvedVariables(dashboard);
// Publish variables to the shared store so the query builder autocomplete
// suggests them ($variable) in the panel editor and dashboards-page builder.
useSyncVariablesForSuggestions(dashboard);
return (
<FullScreen handle={fullScreenHandle}>
<div className={styles.container}>
<DashboardPageHeader title={name} image={image} />
<DashboardPageToolbar dashboard={dashboard} handle={fullScreenHandle} />
{!fullScreenHandle.active && (
<>
<DashboardPageHeader title={name} image={image} />
<DashboardPageToolbar
dashboard={dashboard}
handle={fullScreenHandle}
refetch={refetch}
/>
</>
)}
<PanelsAndSectionsLayout layouts={spec.layouts} panels={spec.panels} />
</div>
</FullScreen>

View File

@@ -77,17 +77,24 @@ describe('buildVariablesPayload', () => {
});
});
it('falls back to a list variable configured default when unselected', () => {
it('falls back to a string configured default when unselected', () => {
const definitions = [
variable('region', 'QUERY', {
defaultValue: { value: 'us-east' },
} as unknown as Partial<VariableFormModel>),
variable('region', 'QUERY', { defaultValue: 'us-east' }),
];
expect(buildVariablesPayload(definitions, {})).toStrictEqual({
region: { type: 'query', value: 'us-east' },
});
});
it('falls back to an array configured default when unselected', () => {
const definitions = [
variable('region', 'QUERY', { defaultValue: ['us-east', 'us-west'] }),
];
expect(buildVariablesPayload(definitions, {})).toStrictEqual({
region: { type: 'query', value: ['us-east', 'us-west'] },
});
});
it('omits a variable with no selection and no default', () => {
const definitions = [variable('q', 'QUERY')];
expect(buildVariablesPayload(definitions, {})).toStrictEqual({});

View File

@@ -40,9 +40,12 @@ function configuredDefault(
if (definition.type === 'TEXT') {
return definition.textValue || undefined;
}
return (
definition.defaultValue as { value?: SelectedVariableValue } | undefined
)?.value;
// `defaultValue` is `string | string[]` on the wire — use it directly.
const def = definition.defaultValue;
if (Array.isArray(def)) {
return def.length > 0 ? def : undefined;
}
return def || undefined;
}
/**

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