Compare commits

...

16 Commits

Author SHA1 Message Date
Ashwin Bhatkal
b1b13af8c0 fix(dashboard-v2): read variable selection from the persisted store, not the editor URL
The dashboard ↔ panel-editor navigation no longer round-trips the selection through the URL (a lagging snapshot could clobber a just-changed value); the editor reads it from the persisted store (localStorage). Drops the now-unused withVariablesSearch.
2026-07-15 18:52:10 +05:30
Ashwin Bhatkal
78b88b4d2b feat(dashboard-v2): rename/delete variable impact dialog
Renaming or deleting a referenced variable is blocked behind a dialog listing every usage (QB / PromQL / ClickHouse panels and other variable defs), with a current → resulting preview, per-usage selection, and inline edits; applied as one JSON-Patch.
2026-07-15 18:52:09 +05:30
Ashwin Bhatkal
7ba0b72359 feat(dashboard-v2): hold panels in loading until referenced variables resolve
A panel waits until every QUERY/DYNAMIC variable it references is ready — a concrete pick and a DYNAMIC ALL are ready immediately, an unselected value or a QUERY ALL waits until it resolves. The wait folds into the panel's loading flags (a disabled query reports neither), so it never flashes "no data". Only referenced variables re-key the cache.
2026-07-15 18:52:09 +05:30
Ashwin Bhatkal
4ca52c3e1d refactor(dashboard-v2): consolidate variable selectors; add bar tooltip + loading bar
Fold the per-type Query/Dynamic/Custom selectors into one VariableValueControl backed by useVariableOptions. Add a hover tooltip showing a variable's dependencies (Depends on / Used by) and a loading bar flush along the control's bottom edge; refine name/dependency colors to design-system tokens.
2026-07-15 18:51:50 +05:30
Ashwin Bhatkal
5375028bcc feat(dashboard-v2): dependency-ordered, value-gated variable fetch engine
Query roots + dynamics fetch immediately; query children unblock only once their parents hold a committed value, so a chain fetches each variable once. The cycle is idempotent per (dashboard·order·time) and resets on page unmount, so a re-mount or editor return can't double it or inherit stale state.
2026-07-15 18:51:50 +05:30
Ashwin Bhatkal
b9bf7be32a feat(dashboard-v2): single default resolver for variable selection
One source of truth for a variable's value (seed → reconcile → payload), shared by the bar, the fetch gate and the query payload so they can't disagree. Precedence stays URL → store → default; ALL materializes once, without an extra round-trip.
2026-07-15 18:51:50 +05:30
Naman Verma
707f01bd9c chore: support non-dsl search in dashboards v2 list apis (#12051)
* chore: support non-dsl search in dashboards v2 list apis

* chore: move free text compilation to visitor

* chore: shorten comments

* fix: support both plain text and dsl together

* fix: ensure empty description dashboards are not excluded in NOT checks
2026-07-14 13:02:58 +00:00
Vinicius Lourenço
3cd0c21703 feat(daemonsets): add ready/misscheduled columns (#12102) 2026-07-14 12:33:21 +00:00
Abhi kumar
181feb3eb9 fix(dashboard-v2): only refetch on-screen panels on time change/auto-refresh (#12113)
Panel visibility was latched (isObserverOnce), so once every panel had
scrolled into view they all stayed query-enabled. A time change or
auto-refresh re-keys every panel's query, refetching all of them at once.

Track the live viewport instead so off-screen panels stay query-disabled
and skip the refetch; they fetch lazily when scrolled back in. Add
staleTime: Infinity so re-entering an unchanged window serves cache rather
than thrash-refetching (key changes and manual refetch still run).
2026-07-14 12:23:04 +00:00
Naman Verma
bf35d12398 chore: add functional unique index for tags (#12029)
* feat: add functional unique index

* chore: add tag migration

* chore: add failing scratch test (to discuss)

* chore: fetch expressions as well in getindices call

* fix: remove tag unique index (will be added in a separate PR)

* fix: remove tag unique index (will be added in a separate PR)

* chore: remove temporary tests

* chore: add functional unique index for tags

* fix: go lint fix

* fix: go lint fix

* chore: better comment for unique index

* test: add test for case insensitive expression equality

* test: add equality test for columns with quotes and capital letters

* test: add test for quoted columns

* chore: add separate type for unique indices with expressions

* fix: update migration file to use correct index

* test: add test for postgres provider

* test: add test for postgres provider

* fix: remove round trip tests
2026-07-14 11:08:59 +00:00
Abhi kumar
1a1f7b2d78 fix(dashboard-v2): bug bash fixes IV (#12056)
* fix(context-menu): keep overlay clickable when opened inside a modal

Portal the popover to body and set pointer-events:auto so a modal's dialog/backdrop can't trap it.

* fix(dashboard-v2): center scrolled-to panels and sections

Default the scroll block to 'center' so a targeted panel lands mid-viewport.

* fix(dashboard-v2): make the view modal refresh button secondary

Use outlined/secondary so refresh doesn't compete with the primary action.

* fix(dashboard-v2): fetch only on-screen panels on dashboard load

RGL unfolds panels from the top-left origin on mount, briefly piling them into the viewport.

The latched observer then marked all visible; suppress the item transition for the first frame.

* chore: readded entry animation
2026-07-14 10:43:14 +00:00
dependabot[bot]
a2778aa45f chore(deps): bump golang.org/x/net from 0.54.0 to 0.55.0 (#11965)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.54.0 to 0.55.0.
- [Commits](https://github.com/golang/net/compare/v0.54.0...v0.55.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.55.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-14 10:01:13 +00:00
Srikanth Chekuri
16c1fce326 fix(ruletypes): address rule read round-trip gaps (#12110)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-07-14 08:36:30 +00:00
Gaurav Tewari
06b762b7ef fix(llm o11y): update unpriced models tab test case (#12108)
* fix: update unpriced models tab test case

* chore: update ci timming

* chore: update comment

* chore: remove comment

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-07-14 08:22:03 +00:00
Aditya Singh
466edf1f1c feat: timeseries export in logs, traces and metrics explorers (2) (#12055)
* feat(data-export): surface raw V5 response and legend map from GetMetricQueryRange

Keeps the pre-conversion V5 queryRange response and the per-query legend map on the returned MetricQueryRangeSuccessResponse so explorer views can feed client-side export without a refetch. Additive optional fields; the legacy V3 render path is untouched. One chokepoint covers Logs, Traces and Metrics.

* fix(data-export): omit display-only format ids from export headers

'short'/'none' are Grafana-style display formats, not physical units — meaningful on a chart axis but misleading as 'value (short)' in a CSV header. Real units (ms, bytes, ...) are unchanged.

* feat(data-export): timeseries export menu and opt-in TimeSeriesView header

New TimeseriesExportMenu (csv/jsonl, LONG shape default; same look as DownloadOptionsMenu, which stays raw/list-only) rendered from a new opt-in TimeSeriesView header row: unit selector left (onYAxisUnitChange — rendering only, unit state stays with each view), download right (allowExport, needs the surfaced raw V5). Both default off, so views opt in explicitly. Includes gating + menu tests.

* feat(explorer): enable timeseries export in Logs, Traces and Metrics

Logs and Traces pass allowExport + onYAxisUnitChange and drop their external unit-filter headers (one header row: unit left, download right). Metrics passes allowExport only, per chart — its YAxisUnitSelector stays screen-level (persists to the metric, spans N charts). convertDataValueToMs now converts the raw V5 tree too, so Traces duration exports carry the ms values the chart shows instead of raw nanoseconds.

* refactor(data-export): use @signozhq/ui components in the export popover

Review feedback: swap antd Button/Popover/Tooltip for the design-system equivalents (radix-style Popover with controlled open, TooltipSimple, Button with native loading). Styles restructured for the DS DOM with periscope font tokens; test renders via tests/test-utils for the TooltipProvider.
2026-07-14 06:54:51 +00:00
Nikhil Mantri
849353b5c0 feat(infra-monitoring): counts query for counting related entities (#12000)
* chore: added types and open api spec changes

* chore: added method to calculate reason

* chore: per group pod status counts with req metric checks method added

* chore: wired up pod status counts

* chore: pod restarts type added

* chore: added restart counts for the group

* chore: bug in query fix

* chore: onboarding API changes

* chore: integration tests added

* chore: added podcountsbyphase in other entities

* chore: added pod status counts for other entities

* chore: added integration tests for other entities

* chore: added checks api changes for other entities

* chore: rearrangement

* chore: removed succeeded status and mark it as completed

* chore: query beautified

* chore: corrected metrics list for metadata lookup

* chore: removed dead constants

* chore: goroutines for ListHosts

* chore: goroutines for ListPods

* chore: goroutines for ListNodes

* chore: goroutines for ListNamespaces

* chore: goroutines for ListClusters

* chore: goroutines for ListDeployments

* chore: goroutines for ListStatefulsets

* chore: added goroutines for ListStatefulsets, ListJobs and ListDaemonsets

* chore: added function

* chore: added struct changes

* chore: added count attr keys

* chore: wired counts to the response fields

* chore: regenerated API spec

* chore: merged main, resolved conflicts

* chore: nodes count surfacing

* chore: integration tests added

* fix: use tuple mapping to uniquely identify attrs:

* chore: integration tests update
2026-07-14 06:38:27 +00:00
97 changed files with 3910 additions and 931 deletions

View File

@@ -4259,6 +4259,34 @@ components:
type: number
clusterName:
type: string
counts:
properties:
daemonSets:
format: int64
type: integer
deployments:
format: int64
type: integer
jobs:
format: int64
type: integer
namespaces:
format: int64
type: integer
nodes:
format: int64
type: integer
statefulSets:
format: int64
type: integer
required:
- nodes
- namespaces
- deployments
- daemonSets
- jobs
- statefulSets
type: object
meta:
additionalProperties:
type: string
@@ -4279,6 +4307,7 @@ components:
- nodeCountsByReadiness
- podCountsByPhase
- podCountsByStatus
- counts
- meta
type: object
InframonitoringtypesClusters:
@@ -4800,6 +4829,26 @@ components:
type: object
InframonitoringtypesNamespaceRecord:
properties:
counts:
properties:
daemonSets:
format: int64
type: integer
deployments:
format: int64
type: integer
jobs:
format: int64
type: integer
statefulSets:
format: int64
type: integer
required:
- deployments
- daemonSets
- jobs
- statefulSets
type: object
meta:
additionalProperties:
type: string
@@ -4823,6 +4872,7 @@ components:
- namespaceMemory
- podCountsByPhase
- podCountsByStatus
- counts
- meta
type: object
InframonitoringtypesNamespaces:

View File

@@ -5669,6 +5669,39 @@ export interface InframonitoringtypesChecksDTO {
type: InframonitoringtypesCheckTypeDTO;
}
export type InframonitoringtypesClusterRecordDTOCounts = {
/**
* @type integer
* @format int64
*/
daemonSets: number;
/**
* @type integer
* @format int64
*/
deployments: number;
/**
* @type integer
* @format int64
*/
jobs: number;
/**
* @type integer
* @format int64
*/
namespaces: number;
/**
* @type integer
* @format int64
*/
nodes: number;
/**
* @type integer
* @format int64
*/
statefulSets: number;
};
export type InframonitoringtypesClusterRecordDTOMetaAnyOf = {
[key: string]: string;
};
@@ -5813,6 +5846,10 @@ export interface InframonitoringtypesClusterRecordDTO {
* @type string
*/
clusterName: string;
/**
* @type object
*/
counts: InframonitoringtypesClusterRecordDTOCounts;
/**
* @type object,null
*/
@@ -6368,6 +6405,29 @@ export interface InframonitoringtypesJobsDTO {
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export type InframonitoringtypesNamespaceRecordDTOCounts = {
/**
* @type integer
* @format int64
*/
daemonSets: number;
/**
* @type integer
* @format int64
*/
deployments: number;
/**
* @type integer
* @format int64
*/
jobs: number;
/**
* @type integer
* @format int64
*/
statefulSets: number;
};
export type InframonitoringtypesNamespaceRecordDTOMetaAnyOf = {
[key: string]: string;
};
@@ -6379,6 +6439,10 @@ export type InframonitoringtypesNamespaceRecordDTOMeta =
InframonitoringtypesNamespaceRecordDTOMetaAnyOf | null;
export interface InframonitoringtypesNamespaceRecordDTO {
/**
* @type object
*/
counts: InframonitoringtypesNamespaceRecordDTOCounts;
/**
* @type object,null
*/

View File

@@ -139,21 +139,31 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
</ColumnHeader>
),
accessorFn: (row): number => row.currentNodes,
width: { min: 180 },
width: { min: 210 },
enableSort: false,
enableResize: true,
cell: ({ row }): React.ReactNode => (
<GroupedStatusCounts
items={[
{
value: row.readyNodes,
label: 'Ready',
color: Color.BG_FOREST_500,
},
{
value: row.currentNodes,
label: 'Current',
color: Color.BG_FOREST_500,
color: Color.BG_ROBIN_500,
},
{
value: row.desiredNodes,
label: 'Desired',
color: Color.BG_ROBIN_500,
color: Color.BG_SAKURA_400,
},
{
value: row.misscheduledNodes,
label: 'Misscheduled',
color: Color.BG_AMBER_500,
},
]}
/>
@@ -312,6 +322,30 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
);
},
},
{
id: 'ready_nodes',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#ready">
Ready Nodes
</ColumnHeader>
),
accessorFn: (row): number => row.readyNodes,
width: { min: 140 },
enableSort: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
const readyNodes = value as number;
return (
<ValidateColumnValueWrapper
value={readyNodes}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="ready node"
>
<TanStackTable.Text>{readyNodes}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'current_nodes',
header: (): React.ReactNode => (
@@ -360,4 +394,28 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
);
},
},
{
id: 'misscheduled_nodes',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#misscheduled">
Misscheduled Nodes
</ColumnHeader>
),
accessorFn: (row): number => row.misscheduledNodes,
width: { min: 140 },
enableSort: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
const misscheduledNodes = value as number;
return (
<ValidateColumnValueWrapper
value={misscheduledNodes}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="misscheduled node"
>
<TanStackTable.Text>{misscheduledNodes}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
];

View File

@@ -89,21 +89,20 @@ describe('UnpricedModelsTab (integration)', () => {
await screen.findByTestId(`unpriced-model-name-${MODEL}`);
await selectRule(user, MODEL, 'rule-openai');
const trigger = screen.getByTestId(`map-to-select-${MODEL}`);
expect(
within(trigger).getByText('openai:gpt-4o ($3.00/$9.00)'),
within(screen.getByTestId(`map-to-select-${MODEL}`)).getByText(
'openai:gpt-4o ($3.00/$9.00)',
),
).toBeInTheDocument();
await user.click(await screen.findByTestId('unpriced-map-cancel-btn'));
await waitFor(() =>
await waitFor(() => {
const trigger = screen.getByTestId(`map-to-select-${MODEL}`);
expect(
within(trigger).queryByText('openai:gpt-4o ($3.00/$9.00)'),
).not.toBeInTheDocument(),
);
expect(
within(trigger).getByText('Select / Create a pricing model'),
).toBeInTheDocument();
within(trigger).getByText('Select / Create a pricing model'),
).toBeInTheDocument();
});
});
it('commits the mapping in one request when confirmed', async () => {

View File

@@ -191,14 +191,6 @@
min-height: 0;
overflow-y: visible;
.time-series-view-container-header {
display: flex;
justify-content: flex-start;
align-items: center;
padding: 12px;
flex-shrink: 0;
}
.time-series-view {
flex-shrink: 0;
height: 65vh;

View File

@@ -32,7 +32,6 @@ import {
getListQuery,
getQueryByPanelType,
} from 'container/LogsExplorerViews/explorerUtils';
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters/BuilderUnitsFilter';
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange';
@@ -461,18 +460,17 @@ function LogsExplorerViewsContainer({
)}
{selectedPanelType === PANEL_TYPES.TIME_SERIES && !showLiveLogs && (
<div className="time-series-view-container">
<div className="time-series-view-container-header">
<BuilderUnitsFilter onChange={onUnitChange} yAxisUnit={yAxisUnit} />
</div>
<TimeSeriesView
isLoading={isLoading || isFetching}
data={data}
isError={isError}
error={error as APIError}
yAxisUnit={yAxisUnit}
onYAxisUnitChange={onUnitChange}
isFilterApplied={!isEmpty(listQuery?.filters?.items)}
dataSource={DataSource.LOGS}
setWarning={setWarning}
allowExport
/>
</div>
)}

View File

@@ -311,6 +311,7 @@ function TimeSeries({
dataSource={DataSource.METRICS}
error={queries[index].error as APIError}
setWarning={setWarning}
allowExport
/>
</div>
);

View File

@@ -3,6 +3,14 @@
min-height: 350px;
padding: 0px 12px;
&__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
flex-shrink: 0;
}
.ant-card-body {
height: 50vh;
min-height: 350px;

View File

@@ -16,6 +16,7 @@ import Uplot from 'components/Uplot';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters/BuilderUnitsFilter';
import { getLocalStorageGraphVisibilityState } from 'container/GridCardLayout/GridCard/utils';
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import EmptyMetricsSearch from 'container/MetricsExplorer/Explorer/EmptyMetricsSearch';
@@ -41,11 +42,14 @@ import { SuccessResponse, Warning } from 'types/api';
import { LegendPosition } from 'types/api/dashboard/getAll';
import APIError from 'types/api/error';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { QueryRangeResponseV5 } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import uPlot from 'uplot';
import { getTimeRange } from 'utils/getTimeRange';
import TimeseriesExportMenu from './TimeseriesExportMenu';
import './TimeSeriesView.styles.scss';
function TimeSeriesView({
@@ -59,6 +63,8 @@ function TimeSeriesView({
setWarning,
panelType = PANEL_TYPES.TIME_SERIES,
stackBarChart = false,
allowExport = false,
onYAxisUnitChange,
}: TimeSeriesViewProps): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
@@ -244,10 +250,33 @@ function TimeSeriesView({
[baseChartOptions, stackedBands],
);
const showExport = allowExport && !!data?.rawV5Response;
const showHeader = showExport || !!onYAxisUnitChange;
return (
<div className="time-series-view">
{isError && error && <ErrorInPlace error={error as APIError} />}
{showHeader && (
<div className="time-series-view__header">
<div>
{onYAxisUnitChange && (
<BuilderUnitsFilter onChange={onYAxisUnitChange} yAxisUnit={yAxisUnit} />
)}
</div>
{showExport && data?.rawV5Response && (
<TimeseriesExportMenu
dataSource={dataSource}
yAxisUnit={yAxisUnit}
queryResponse={data.rawV5Response}
query={currentQuery}
legendMap={data.legendMap}
fileName={`${dataSource}-timeseries`}
/>
)}
</div>
)}
<div
className="graph-container"
style={{ height: '100%', width: '100%' }}
@@ -295,7 +324,11 @@ function TimeSeriesView({
}
interface TimeSeriesViewProps {
data?: SuccessResponse<MetricRangePayloadProps> & { warning?: Warning };
data?: SuccessResponse<MetricRangePayloadProps> & {
warning?: Warning;
rawV5Response?: QueryRangeResponseV5;
legendMap?: Record<string, string>;
};
yAxisUnit?: string;
isLoading: boolean;
isError: boolean;
@@ -305,6 +338,11 @@ interface TimeSeriesViewProps {
setWarning?: Dispatch<SetStateAction<Warning | undefined>>;
panelType?: PANEL_TYPES;
stackBarChart?: boolean;
// Opt-in: render the client-side export menu (Logs explorer for now).
allowExport?: boolean;
// Opt-in: render the y-axis unit selector in the header (views without their
// own selector, e.g. Logs). Metrics keeps its separate YAxisUnitSelector.
onYAxisUnitChange?: (value: string) => void;
}
TimeSeriesView.defaultProps = {

View File

@@ -0,0 +1,33 @@
.timeseries-export-popover {
width: 240px;
padding: 0 12px 12px 12px;
.title {
display: flex;
color: var(--l1-foreground);
font-family: Inter;
font-size: var(--periscope-font-size-small);
font-style: normal;
font-weight: 500;
line-height: 18px;
letter-spacing: 0.88px;
text-transform: uppercase;
margin-bottom: 8px;
}
.export-format {
padding: 12px 4px;
display: flex;
flex-direction: column;
// radio option labels — radix popover content inherits the root font
// size; pin to the app's 13px base the antd popover used to impose
label {
font-size: var(--periscope-font-size-base);
}
}
.export-button {
width: 100%;
}
}

View File

@@ -0,0 +1,91 @@
import { Download } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { useClientExport } from 'hooks/useExportData/useClientExport';
import { ExportFormat } from 'lib/exportData/types';
import { useCallback, useState } from 'react';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryRangeResponseV5 } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import './TimeseriesExportMenu.styles.scss';
interface TimeseriesExportMenuProps {
dataSource: DataSource;
queryResponse: QueryRangeResponseV5;
query?: Query;
yAxisUnit?: string;
legendMap?: Record<string, string>;
fileName?: string;
}
// Download menu for in-memory timeseries data (client-side serialization).
// The raw/list backend export keeps its own menu in DownloadOptionsMenu.
export default function TimeseriesExportMenu({
dataSource,
queryResponse,
query,
yAxisUnit,
legendMap,
fileName,
}: TimeseriesExportMenuProps): JSX.Element {
const [exportFormat, setExportFormat] = useState<string>(ExportFormat.Csv);
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const { isExporting, handleExport: handleClientExport } = useClientExport({
response: queryResponse,
query,
yAxisUnit,
legendMap,
fileName,
});
const handleExport = useCallback((): void => {
setIsPopoverOpen(false);
handleClientExport({ format: exportFormat as ExportFormat });
}, [exportFormat, handleClientExport]);
return (
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<TooltipSimple title="Download">
<PopoverTrigger asChild>
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="Download"
data-testid={`timeseries-export-${dataSource}`}
disabled={isExporting}
loading={isExporting}
>
<Download size={14} />
</Button>
</PopoverTrigger>
</TooltipSimple>
<PopoverContent align="end" className="timeseries-export-popover">
<div className="export-format">
<Typography.Text className="title">FORMAT</Typography.Text>
<RadioGroup value={exportFormat} onChange={setExportFormat}>
<RadioGroupItem value={ExportFormat.Csv}>csv</RadioGroupItem>
<RadioGroupItem value={ExportFormat.Jsonl}>jsonl</RadioGroupItem>
</RadioGroup>
</div>
<Button
variant="solid"
color="primary"
className="export-button"
onClick={handleExport}
disabled={isExporting}
loading={isExporting}
prefix={<Download size={16} />}
>
Export
</Button>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,138 @@
import { render } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import configureStore from 'redux-mock-store';
import store from 'store';
import { DataSource } from 'types/common/queryBuilder';
import TimeSeriesView from '../TimeSeriesView';
jest.mock('components/Uplot', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="uplot-chart" />,
}));
jest.mock('../TimeseriesExportMenu', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="timeseries-export-menu" />,
}));
jest.mock('container/QueryBuilder/filters/BuilderUnitsFilter', () => ({
BuilderUnitsFilter: (): JSX.Element => (
<div data-testid="builder-units-filter" />
),
}));
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): unknown => ({ currentQuery: null }),
}));
jest.mock('lib/uPlotLib/getUplotChartOptions', () => ({
getUPlotChartOptions: (): unknown => ({}),
}));
jest.mock('lib/uPlotLib/utils/getUplotChartData', () => ({
getUPlotChartData: (): number[][] => [
[1, 2],
[3, 4],
],
}));
jest.mock(
'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils',
() => ({ stackSeries: (): unknown => ({ data: [], bands: [] }) }),
);
jest.mock('container/GridCardLayout/GridCard/utils', () => ({
getLocalStorageGraphVisibilityState: (): unknown => ({
graphVisibilityStates: [],
}),
}));
jest.mock('providers/Timezone', () => ({
useTimezone: (): unknown => ({ timezone: { value: 'UTC' } }),
}));
jest.mock('hooks/useDimensions', () => ({
useResizeObserver: (): unknown => ({ width: 800, height: 400 }),
}));
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockStore = configureStore([])({ ...store.getState() });
const rawV5Response = {
type: 'time_series',
data: { results: [] },
meta: {},
};
function makeData(withRawV5: boolean): any {
return {
statusCode: 200,
error: null,
message: '',
payload: { data: { result: [], resultType: '' } },
...(withRawV5 ? { rawV5Response, legendMap: {} } : {}),
};
}
function renderView(props: {
allowExport?: boolean;
withRawV5?: boolean;
onYAxisUnitChange?: (value: string) => void;
}): ReturnType<typeof render> {
const { allowExport, withRawV5 = true, onYAxisUnitChange } = props;
return render(
<Provider store={mockStore}>
<MemoryRouter>
<TimeSeriesView
isLoading={false}
isError={false}
isFilterApplied
dataSource={DataSource.LOGS}
data={makeData(withRawV5)}
allowExport={allowExport}
onYAxisUnitChange={onYAxisUnitChange}
/>
</MemoryRouter>
</Provider>,
);
}
describe('TimeSeriesView header gating', () => {
it('renders the export menu when allowExport is set and raw V5 data is present', () => {
const { queryByTestId } = renderView({ allowExport: true });
expect(queryByTestId('timeseries-export-menu')).toBeInTheDocument();
});
it('renders no export menu without allowExport', () => {
const { queryByTestId } = renderView({});
expect(queryByTestId('timeseries-export-menu')).not.toBeInTheDocument();
});
it('renders no export menu when the raw V5 response is missing', () => {
const { queryByTestId } = renderView({ allowExport: true, withRawV5: false });
expect(queryByTestId('timeseries-export-menu')).not.toBeInTheDocument();
});
it('renders the unit selector only when onYAxisUnitChange is passed', () => {
const withUnit = renderView({ onYAxisUnitChange: jest.fn() });
expect(withUnit.queryByTestId('builder-units-filter')).toBeInTheDocument();
withUnit.unmount();
const withoutUnit = renderView({ allowExport: true });
expect(
withoutUnit.queryByTestId('builder-units-filter'),
).not.toBeInTheDocument();
});
it('renders no header row when neither export nor unit selector is enabled', () => {
const { container } = renderView({ withRawV5: false });
expect(container.querySelector('.time-series-view__header')).toBeNull();
});
});

View File

@@ -0,0 +1,84 @@
import { fireEvent, render, screen } from 'tests/test-utils';
import { QueryRangeResponseV5 } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import TimeseriesExportMenu from '../TimeseriesExportMenu';
const mockHandleExport = jest.fn();
let mockIsExporting = false;
jest.mock('hooks/useExportData/useClientExport', () => ({
useClientExport: (): unknown => ({
isExporting: mockIsExporting,
handleExport: mockHandleExport,
}),
}));
const response = {
type: 'time_series',
data: { results: [] },
meta: {},
} as unknown as QueryRangeResponseV5;
const TEST_ID = `timeseries-export-${DataSource.LOGS}`;
function renderMenu(): void {
render(
<TimeseriesExportMenu
dataSource={DataSource.LOGS}
queryResponse={response}
fileName="logs-timeseries"
/>,
);
}
describe('TimeseriesExportMenu', () => {
beforeEach(() => {
mockHandleExport.mockReset();
mockIsExporting = false;
});
it('renders the download trigger button', () => {
renderMenu();
expect(screen.getByTestId(TEST_ID)).toBeInTheDocument();
});
it('shows only format options — no shape, row-count, or column controls', () => {
renderMenu();
fireEvent.click(screen.getByTestId(TEST_ID));
expect(screen.getByText('FORMAT')).toBeInTheDocument();
expect(screen.getByRole('radio', { name: 'csv' })).toBeInTheDocument();
expect(screen.getByRole('radio', { name: 'jsonl' })).toBeInTheDocument();
expect(screen.queryByText('Number of Rows')).not.toBeInTheDocument();
expect(screen.queryByText('Columns')).not.toBeInTheDocument();
expect(screen.queryByRole('radio', { name: 'long' })).not.toBeInTheDocument();
expect(screen.queryByRole('radio', { name: 'wide' })).not.toBeInTheDocument();
});
it('exports as csv by default', () => {
renderMenu();
fireEvent.click(screen.getByTestId(TEST_ID));
fireEvent.click(screen.getByText('Export'));
expect(mockHandleExport).toHaveBeenCalledTimes(1);
expect(mockHandleExport).toHaveBeenCalledWith({ format: 'csv' });
});
it('exports as jsonl when selected', () => {
renderMenu();
fireEvent.click(screen.getByTestId(TEST_ID));
fireEvent.click(screen.getByRole('radio', { name: 'jsonl' }));
fireEvent.click(screen.getByText('Export'));
expect(mockHandleExport).toHaveBeenCalledWith({ format: 'jsonl' });
});
it('disables the trigger while an export is in progress', () => {
mockIsExporting = true;
renderMenu();
expect(screen.getByTestId(TEST_ID)).toBeDisabled();
});
});

View File

@@ -1,10 +1,41 @@
import { SuccessResponse } from 'types/api/index';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { QueryRangeResponseV5, TimeSeriesData } from 'types/api/v5/queryRange';
import { QueryData } from 'types/api/widgets/getQuery';
type ConvertibleData = SuccessResponse<MetricRangePayloadProps> & {
rawV5Response?: QueryRangeResponseV5;
};
// Applies the same ns→ms conversion to the raw V5 tree, so client-side export
// serializes the values the chart displays (not the original nanoseconds).
function convertRawV5ValuesToMs(
response: QueryRangeResponseV5,
): QueryRangeResponseV5 {
if (response.type !== 'time_series') {
return response;
}
const results = (response.data.results as TimeSeriesData[]).map((result) => ({
...result,
aggregations: (result.aggregations ?? []).map((bucket) => ({
...bucket,
series: (bucket.series ?? []).map((series) => ({
...series,
values: (series.values ?? []).map((value) => ({
...value,
value: value.value / 1000000,
})),
})),
})),
}));
return { ...response, data: { ...response.data, results } };
}
export const convertDataValueToMs = (
data?: SuccessResponse<MetricRangePayloadProps>,
): SuccessResponse<MetricRangePayloadProps> | undefined => {
data?: ConvertibleData,
): ConvertibleData | undefined => {
const convertedData = data;
const convertedResult: QueryData[] = data?.payload?.data?.result
@@ -22,5 +53,11 @@ export const convertDataValueToMs = (
convertedData.payload.data.result = convertedResult;
}
if (convertedData?.rawV5Response) {
convertedData.rawV5Response = convertRawV5ValuesToMs(
convertedData.rawV5Response,
);
}
return convertedData;
};

View File

@@ -4,6 +4,8 @@ export function useIntersectionObserver<T extends HTMLElement>(
ref: RefObject<T>,
options?: IntersectionObserverInit,
isObserverOnce?: boolean,
/** Defer observation by this many ms to let a transient mount layout settle. */
startDelayMs = 0,
): boolean {
const [isIntersecting, setIntersecting] = useState(false);
@@ -23,16 +25,28 @@ export function useIntersectionObserver<T extends HTMLElement>(
}
}, options);
if (currentReference) {
observer.observe(currentReference);
const startObserving = (): void => {
if (currentReference) {
observer.observe(currentReference);
}
};
let timer: ReturnType<typeof setTimeout> | undefined;
if (startDelayMs > 0) {
timer = setTimeout(startObserving, startDelayMs);
} else {
startObserving();
}
return (): void => {
if (timer) {
clearTimeout(timer);
}
if (currentReference) {
observer.unobserve(currentReference);
}
};
}, [ref, options, isObserverOnce]);
}, [ref, options, isObserverOnce, startDelayMs]);
return isIntersecting;
}

View File

@@ -22,7 +22,11 @@ import { SuccessResponseV2, Warning } from 'types/api';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
import { ExecStats, MetricRangePayloadV5 } from 'types/api/v5/queryRange';
import {
ExecStats,
MetricRangePayloadV5,
QueryRangeResponseV5,
} from 'types/api/v5/queryRange';
import { QueryData } from 'types/api/widgets/getQuery';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
@@ -192,6 +196,8 @@ export async function GetMetricQueryRange(
| SuccessResponseV2<MetricRangePayloadV5>;
let warning: Warning | undefined;
let meta: ExecStats | undefined;
// Raw V5 response, kept before it's converted to legacy — powers client-side export.
let rawV5Response: QueryRangeResponseV5 | undefined;
const panelType = props.originalGraphType || props.graphType;
@@ -268,6 +274,8 @@ export async function GetMetricQueryRange(
endTime: props.end * 1000,
});
rawV5Response = publicResponse.data.data;
// Convert V5 response to legacy format for components
response = convertV5ResponseToLegacy(
{
@@ -288,6 +296,8 @@ export async function GetMetricQueryRange(
headers,
);
rawV5Response = v5Response.data.data;
// Convert V5 response to legacy format for components
response = convertV5ResponseToLegacy(
{
@@ -366,6 +376,8 @@ export async function GetMetricQueryRange(
...response,
warning,
meta,
rawV5Response,
legendMap,
};
}

View File

@@ -3,6 +3,7 @@ import { EQueryType } from 'types/common/dashboard';
import {
buildVariableReferencePattern,
containsAnyVariableReference,
extractQueryTextStrings,
getVariableReferencesInQuery,
textContainsVariableReference,
@@ -448,3 +449,25 @@ describe('getVariableReferencesInQuery', () => {
expect(getVariableReferencesInQuery(query, [])).toStrictEqual([]);
});
});
describe('containsAnyVariableReference', () => {
it.each([
['SELECT count() FROM t WHERE service = $service.name', true],
['up{env="$deployment_environment"}', true],
['{{.service_name}}', true],
['{{ service_name }}', true],
['[[service_name]]', true],
['$_private', true],
])('detects a reference in %p', (text, expected) => {
expect(containsAnyVariableReference(text)).toBe(expected);
});
it.each([
['SELECT count() FROM t WHERE x = 1', false],
['rate(http_requests[$__interval])', false],
['SELECT $1 FROM t', false],
['', false],
])('does not falsely match %p', (text, expected) => {
expect(containsAnyVariableReference(text)).toBe(expected);
});
});

View File

@@ -1,4 +1,4 @@
import { isArray } from 'lodash-es';
import { escapeRegExp, isArray } from 'lodash-es';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
@@ -33,6 +33,23 @@ export function textContainsVariableReference(
return buildVariableReferencePattern(variableName).test(text);
}
/**
* Matches *any* variable reference in a recognized syntax without knowing the
* name: `{{name}}`, `{{.name}}`, `[[name]]`, or `$name`. The `$` form excludes
* `$__…` macros and positional `$1` so built-ins don't read as variables.
*/
const ANY_VARIABLE_REFERENCE =
/\{\{\s*\.?[\w.]+\s*\}\}|\[\[\s*[\w.]+\s*\]\]|\$(?!__)[a-zA-Z_][\w.]*/;
/**
* Returns true if `text` contains a reference to any variable. Use when the set
* of variable names isn't known yet (e.g. before the fetch context initializes),
* so a name-based {@link textContainsVariableReference} check can't run.
*/
export function containsAnyVariableReference(text: string): boolean {
return !!text && ANY_VARIABLE_REFERENCE.test(text);
}
/**
* Extracts all text strings from a widget Query that could contain variable
* references. Covers:
@@ -134,3 +151,52 @@ export function getVariableReferencesInQuery(
texts.some((text) => textContainsVariableReference(text, name)),
);
}
/**
* Rewrites every reference to `oldName` in `text` to `newName`, preserving the
* surrounding syntax for each recognized form ({{.x}}, {{x}}, $x, [[x]]). Used
* when a variable is renamed so its usages across queries stay valid.
*/
export function rewriteVariableReferences(
text: string,
oldName: string,
newName: string,
): string {
if (!text || !oldName || oldName === newName) {
return text;
}
const name = escapeRegExp(oldName);
return text
.replace(
new RegExp(`(\\{\\{\\s*?\\.)${name}(\\s*?\\}\\})`, 'g'),
`$1${newName}$2`,
)
.replace(new RegExp(`(\\{\\{\\s*)${name}(\\s*\\}\\})`, 'g'), `$1${newName}$2`)
.replace(new RegExp(`\\$${name}\\b`, 'g'), `$${newName}`)
.replace(
new RegExp(`(\\[\\[\\s*)${name}(\\s*\\]\\])`, 'g'),
`$1${newName}$2`,
);
}
/**
* Best-effort removal of the clause that references `variableName` from an
* ` AND `-joined filter expression (e.g. a builder query's `filter.expression`).
* Any top-level `AND` part that references the variable is dropped. It does not
* understand `OR`/nested parentheses, so it is a starting point the user reviews
* before applying — never an automatic edit of raw PromQL/ClickHouse.
*/
export function removeVariableReferenceClause(
expression: string,
variableName: string,
): string {
if (!expression) {
return expression;
}
return expression
.split(' AND ')
.map((part) => part.trim())
.filter(Boolean)
.filter((part) => !textContainsVariableReference(part, variableName))
.join(' AND ');
}

View File

@@ -81,6 +81,18 @@ describe('exportTimeseriesData', () => {
]);
});
it('omits display-only format ids (short/none) from headers', () => {
const data = [
makeQuery('A', [{ series: [makeSeries({ service: 'a' }, [[1000, 1]])] }]),
];
const short = exportTimeseriesData({ data, yAxisUnit: 'short' });
expect(short.headers[short.headers.length - 1]).toBe('value');
const none = exportTimeseriesData({ data, yAxisUnit: 'none' });
expect(none.headers[none.headers.length - 1]).toBe('value');
});
it('multi-query: query is its own column; label keys are unioned', () => {
const data = [
makeQuery('A', [{ series: [makeSeries({ service: 'x' }, [[1000, 1]])] }]),

View File

@@ -98,9 +98,16 @@ function flatten(
return flat;
}
// Display-format ids, not physical units — meaningful on a chart axis
// (compact-number formatting) but misleading in an export header.
const DISPLAY_ONLY_UNITS = new Set(['short', 'none']);
// Appends the y-axis unit to the value header: `value` → `value (ms)`.
function withUnit(header: string, yAxisUnit?: string): string {
return yAxisUnit ? `${header} (${yAxisUnit})` : header;
if (!yAxisUnit || DISPLAY_ONLY_UNITS.has(yAxisUnit)) {
return header;
}
return `${header} (${yAxisUnit})`;
}
function toIso(timestamp: number): string {

View File

@@ -0,0 +1,86 @@
.body {
display: flex;
flex-direction: column;
gap: 12px;
max-height: 60vh;
overflow-y: auto;
}
.intro {
color: var(--l2-foreground);
font-size: 13px;
line-height: 1.5;
}
.rows {
display: flex;
flex-direction: column;
gap: 12px;
}
.row {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
border: 1px solid var(--l2-border);
border-radius: 6px;
background: var(--l1-background);
}
.rowHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.sourceLabel {
color: var(--l1-foreground);
font-weight: 600;
font-size: 13px;
}
.kindTag {
color: var(--l2-foreground);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 2px 8px;
border: 1px solid var(--l2-border);
border-radius: 4px;
white-space: nowrap;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.fieldLabel {
color: var(--l2-foreground);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.textArea {
font-family: var(--font-mono, monospace);
font-size: 12px;
}
.disabled {
opacity: 0.6;
}
.warning {
color: var(--warning-foreground, #d97706);
font-size: 11px;
}
.footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}

View File

@@ -0,0 +1,159 @@
import { Check, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Typography } from '@signozhq/ui/typography';
// eslint-disable-next-line signoz/no-antd-components -- multiline TextArea + Checkbox have no @signozhq/ui equivalent yet
import { Checkbox, Input as AntdInput } from 'antd';
import cx from 'classnames';
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
import type { VariableImpactMode, VariableUsage } from '../variableUsages';
import { useVariableImpactState } from './useVariableImpactState';
import styles from './VariableImpactDialog.module.scss';
const KIND_LABEL: Record<VariableUsage['kind'], string> = {
builder: 'Query builder',
promql: 'PromQL',
clickhouse: 'ClickHouse',
variable: 'Variable',
};
interface VariableImpactDialogProps {
open: boolean;
mode: VariableImpactMode;
/** The variable being renamed/deleted (its current name). */
variableName: string;
/** The new name (rename mode only). */
newName?: string;
usages: VariableUsage[];
isLoading: boolean;
onConfirm: (resolvedUsages: VariableUsage[]) => void;
onClose: () => void;
}
/**
* Blocks a rename/delete of a referenced variable behind a review step: lists
* every usage across panel queries (builder / PromQL / ClickHouse) and other
* variables, shows the current vs resulting query, and lets the user edit each
* result or exclude it before applying.
*/
function VariableImpactDialog({
open,
mode,
variableName,
newName,
usages,
isLoading,
onConfirm,
onClose,
}: VariableImpactDialogProps): JSX.Element {
const { rows, setResultingText, toggleIncluded, resolvedUsages } =
useVariableImpactState(usages, open);
const isRename = mode === 'rename';
const count = usages.length;
const plural = count === 1 ? '' : 's';
const intro = isRename
? `$${variableName} is used in ${count} place${plural}. Review the updated queries before renaming to $${newName}.`
: `$${variableName} is used in ${count} place${plural}. Edit or remove each usage before deleting.`;
const footer = (
<div className={styles.footer}>
<Button
variant="solid"
color="secondary"
onClick={onClose}
testId="variable-impact-cancel"
>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color={isRename ? 'primary' : 'destructive'}
loading={isLoading}
onClick={(): void => onConfirm(resolvedUsages)}
testId="variable-impact-confirm"
>
<Check size={12} />
{isRename ? 'Rename' : 'Delete'}
</Button>
</div>
);
return (
<DialogWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
title={isRename ? `Rename $${variableName}` : `Delete $${variableName}`}
width="wide"
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}>
<Typography.Text className={styles.intro}>{intro}</Typography.Text>
<div className={styles.rows}>
{rows.map((row) => {
const stillReferences =
row.included &&
textContainsVariableReference(row.resultingText, variableName);
return (
<div
key={row.id}
className={styles.row}
data-testid={`variable-impact-row-${row.id}`}
>
<div className={styles.rowHeader}>
<Checkbox
checked={row.included}
onChange={(): void => toggleIncluded(row.id)}
data-testid={`variable-impact-include-${row.id}`}
>
<span className={styles.sourceLabel}>{row.sourceLabel}</span>
</Checkbox>
<span className={styles.kindTag}>{KIND_LABEL[row.kind]}</span>
</div>
<div className={styles.field}>
<Typography.Text className={styles.fieldLabel}>
Current
</Typography.Text>
<AntdInput.TextArea
className={styles.textArea}
value={row.currentText}
readOnly
autoSize={{ minRows: 1, maxRows: 4 }}
/>
</div>
<div className={styles.field}>
<Typography.Text className={styles.fieldLabel}>Result</Typography.Text>
<AntdInput.TextArea
className={cx(styles.textArea, !row.included && styles.disabled)}
value={row.resultingText}
disabled={!row.included}
autoSize={{ minRows: 1, maxRows: 4 }}
onChange={(e): void => setResultingText(row.id, e.target.value)}
data-testid={`variable-impact-result-${row.id}`}
/>
{stillReferences ? (
<Typography.Text className={styles.warning}>
Still references ${variableName}
</Typography.Text>
) : null}
</div>
</div>
);
})}
</div>
</div>
</DialogWrapper>
);
}
export default VariableImpactDialog;

View File

@@ -0,0 +1,52 @@
import { useCallback, useEffect, useState } from 'react';
import type { VariableUsage } from '../variableUsages';
/** A usage row plus whether its edit will be applied on confirm. */
export interface EditableVariableUsage extends VariableUsage {
included: boolean;
}
interface UseVariableImpactState {
rows: EditableVariableUsage[];
setResultingText: (id: string, text: string) => void;
toggleIncluded: (id: string) => void;
/** The included rows, as plain usages, to build the patch from. */
resolvedUsages: VariableUsage[];
}
/**
* Editable state for the impact dialog: a per-usage copy the user can edit
* (`resultingText`) and include/exclude before applying. Resets whenever the
* dialog (re)opens for a fresh usage set.
*/
export function useVariableImpactState(
usages: VariableUsage[],
open: boolean,
): UseVariableImpactState {
const [rows, setRows] = useState<EditableVariableUsage[]>([]);
useEffect(() => {
if (open) {
setRows(usages.map((usage) => ({ ...usage, included: true })));
}
}, [open, usages]);
const setResultingText = useCallback((id: string, text: string): void => {
setRows((prev) =>
prev.map((row) => (row.id === id ? { ...row, resultingText: text } : row)),
);
}, []);
const toggleIncluded = useCallback((id: string): void => {
setRows((prev) =>
prev.map((row) =>
row.id === id ? { ...row, included: !row.included } : row,
),
);
}, []);
const resolvedUsages: VariableUsage[] = rows.filter((row) => row.included);
return { rows, setResultingText, toggleIncluded, resolvedUsages };
}

View File

@@ -0,0 +1,98 @@
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import {
emptyVariableFormModel,
type VariableFormModel,
} from '../variableFormModel';
import { findVariableUsages } from '../variableUsages';
// Identity adapter so `spec.variables` can be plain form models in the test.
jest.mock('../variableAdapters', () => ({
dtoToFormModel: (dto: unknown): unknown => dto,
}));
function variable(overrides: Partial<VariableFormModel>): VariableFormModel {
return { ...emptyVariableFormModel(), ...overrides };
}
function builderPanel(name: string, expression: string): unknown {
return {
spec: {
display: { name },
queries: [
{
spec: {
plugin: { kind: 'signoz/BuilderQuery', spec: { filter: { expression } } },
},
},
],
},
};
}
function promqlPanel(name: string, query: string): unknown {
return {
spec: {
display: { name },
queries: [
{ spec: { plugin: { kind: 'signoz/PromQLQuery', spec: { query } } } },
],
},
};
}
function dashboard(
panels: Record<string, unknown>,
variables: VariableFormModel[],
): DashboardtypesGettableDashboardV2DTO {
return {
spec: { panels, variables },
} as unknown as DashboardtypesGettableDashboardV2DTO;
}
describe('findVariableUsages', () => {
const dash = dashboard(
{
p1: builderPanel('Panel One', "service IN $svc AND env = 'prod'"),
p2: promqlPanel('Panel Two', 'up{s="$svc"}'),
p3: builderPanel('Unrelated', "env = 'prod'"),
},
[
variable({ name: 'svc', type: 'QUERY' }),
variable({
name: 'other',
type: 'QUERY',
queryValue: 'SELECT x WHERE s = $svc',
}),
variable({ name: 'plain', type: 'QUERY', queryValue: 'SELECT y' }),
],
);
it('finds panel (builder + promql) and variable usages, skipping unrelated ones', () => {
const usages = findVariableUsages(dash, 'svc', 'rename', 'zone');
const ids = usages.map((u) => u.id).sort();
expect(ids).toStrictEqual(['panel:p1:0', 'panel:p2:0', 'variable:other:0']);
});
it('rewrites references for a rename across all kinds', () => {
const usages = findVariableUsages(dash, 'svc', 'rename', 'zone');
const byId = Object.fromEntries(usages.map((u) => [u.id, u.resultingText]));
expect(byId['panel:p1:0']).toBe("service IN $zone AND env = 'prod'");
expect(byId['panel:p2:0']).toBe('up{s="$zone"}');
expect(byId['variable:other:0']).toBe('SELECT x WHERE s = $zone');
});
it('strips builder clauses on delete but leaves raw/variable queries for review', () => {
const usages = findVariableUsages(dash, 'svc', 'delete');
const byId = Object.fromEntries(usages.map((u) => [u.id, u.resultingText]));
// Builder: the clause referencing $svc is dropped.
expect(byId['panel:p1:0']).toBe("env = 'prod'");
// Raw PromQL + variable query: unchanged (user edits).
expect(byId['panel:p2:0']).toBe('up{s="$svc"}');
expect(byId['variable:other:0']).toBe('SELECT x WHERE s = $svc');
});
it('returns nothing for an unreferenced variable', () => {
expect(findVariableUsages(dash, 'nope', 'delete')).toStrictEqual([]);
});
});

View File

@@ -17,7 +17,17 @@ import {
emptyVariableFormModel,
type VariableFormModel,
} from './variableFormModel';
import {
applyVariableQueryEdits,
buildVariableImpactPatch,
} from './variableImpactPatch';
import {
findVariableUsages,
type VariableImpactMode,
type VariableUsage,
} from './variableUsages';
import VariableForm from './VariableForm/VariableForm';
import VariableImpactDialog from './VariableImpactDialog/VariableImpactDialog';
import VariablesList from './VariablesList';
import styles from './Variables.module.scss';
import AddVariableButton from './components/AddVariableButton';
@@ -59,6 +69,16 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
null,
);
const [applyToAllIndex, setApplyToAllIndex] = useState<number | null>(null);
// A pending rename/delete that touches other queries — resolved via the impact
// dialog before it is applied. `nextVariables` is the array to persist (with the
// rename/delete already applied), before any variable-query edits.
const [impact, setImpact] = useState<{
mode: VariableImpactMode;
variableName: string;
newName?: string;
usages: VariableUsage[];
nextVariables: VariableFormModel[];
} | null>(null);
const editingFormModel: VariableFormModel | null = useMemo(() => {
if (!isEditing) {
@@ -104,12 +124,38 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
formModel: VariableFormModel,
selectedPanelIds: string[],
): void => {
const editingIndex = isEditing?.type === 'edit' ? isEditing.index : null;
const oldName = editingIndex !== null ? variables[editingIndex].name : null;
const next = [...variables];
if (isEditing?.type === 'new') {
next.push(formModel);
} else if (isEditing?.type === 'edit') {
next[isEditing.index] = formModel;
} else if (editingIndex !== null) {
next[editingIndex] = formModel;
}
// A rename that other queries/variables reference must be reviewed first, so
// the references are rewritten alongside the rename (never left dangling).
if (oldName && oldName !== formModel.name) {
const usages = findVariableUsages(
dashboard,
oldName,
'rename',
formModel.name,
);
if (usages.length > 0) {
setIsEditing(null);
setImpact({
mode: 'rename',
variableName: oldName,
newName: formModel.name,
usages,
nextVariables: next,
});
return;
}
}
setIsEditing(null);
setVariables(next);
void (async (): Promise<void> => {
@@ -149,6 +195,57 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
setConfirmDeleteIndex(null);
};
// Delete requested from the list: if the variable is referenced anywhere, block
// and open the impact dialog; otherwise fall through to the simple confirm.
const requestDelete = (index: number): void => {
const usages = findVariableUsages(dashboard, variables[index].name, 'delete');
if (usages.length > 0) {
setImpact({
mode: 'delete',
variableName: variables[index].name,
usages,
nextVariables: variables.filter((_, i) => i !== index),
});
return;
}
setConfirmDeleteIndex(index);
};
// Applies a resolved rename/delete: the variables array (rename/delete + edited
// variable queries) and each touched panel's queries, in one atomic patch.
const handleImpactConfirm = async (
resolvedUsages: VariableUsage[],
): Promise<void> => {
if (!impact) {
return;
}
const nextVariables = applyVariableQueryEdits(
impact.nextVariables,
resolvedUsages,
);
const ops = buildVariableImpactPatch(
dashboard,
nextVariables,
resolvedUsages,
);
setVariables(nextVariables);
try {
await patchAsync(ops);
toast.success(
impact.mode === 'rename'
? `Renamed to $${impact.newName}`
: `Deleted $${impact.variableName}`,
);
} catch {
toast.error(
impact.mode === 'rename'
? 'Could not rename the variable'
: 'Could not delete the variable',
);
}
setImpact(null);
};
const applyToAllVariable =
applyToAllIndex === null ? null : variables[applyToAllIndex];
@@ -202,7 +299,7 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
canEdit={isEditable}
confirmingIndex={confirmDeleteIndex}
onEdit={(index): void => setIsEditing({ type: 'edit', index })}
onRequestDelete={(index): void => setConfirmDeleteIndex(index)}
onRequestDelete={requestDelete}
onConfirmDelete={handleConfirmDelete}
onCancelDelete={(): void => setConfirmDeleteIndex(null)}
onMove={handleMove}
@@ -220,6 +317,16 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
onConfirm={(): void => void handleConfirmApplyToAll()}
onClose={(): void => setApplyToAllIndex(null)}
/>
<VariableImpactDialog
open={impact !== null}
mode={impact?.mode ?? 'delete'}
variableName={impact?.variableName ?? ''}
newName={impact?.newName}
usages={impact?.usages ?? []}
isLoading={isPatching}
onConfirm={(resolved): void => void handleImpactConfirm(resolved)}
onClose={(): void => setImpact(null)}
/>
</div>
);
}

View File

@@ -0,0 +1,123 @@
import type {
DashboardtypesGettableDashboardV2DTO,
DashboardtypesJSONPatchOperationDTO,
DashboardtypesQueryDTO,
Querybuildertypesv5CompositeQueryDTO,
} from 'api/generated/services/sigNoz.schemas';
import { cloneDeep } from 'lodash-es';
import { formModelToDto } from './variableAdapters';
import type { VariableFormModel } from './variableFormModel';
import { buildVariablesPatch } from './variablePatchOps';
import type { VariableUsage, VariableUsageKind } from './variableUsages';
/** Minimal writable view of an envelope spec's reference-bearing fields. */
interface WritableSpec {
query?: string;
filter?: { expression?: string };
}
/** Writes the resolved text into the spec's builder filter or raw query field. */
function writeSpecText(
spec: WritableSpec,
kind: VariableUsageKind,
text: string,
): void {
if (kind === 'builder') {
spec.filter = { ...(spec.filter ?? {}), expression: text };
} else {
spec.query = text;
}
}
/** Applies one panel usage's edited text into a (cloned) queries array in place. */
function applyPanelUsage(
queries: DashboardtypesQueryDTO[],
usage: VariableUsage,
): void {
const plugin = queries[0]?.spec?.plugin;
if (!plugin?.spec) {
return;
}
if (plugin.kind === 'signoz/CompositeQuery') {
const composite = plugin.spec as Querybuildertypesv5CompositeQueryDTO;
const envelope = (composite.queries ?? [])[usage.envelopeIndex];
if (envelope?.spec) {
writeSpecText(
envelope.spec as WritableSpec,
usage.kind,
usage.resultingText,
);
}
} else {
// Bare BuilderQuery / PromQLQuery / ClickHouseSQL — the plugin spec is the
// single envelope (index 0).
writeSpecText(plugin.spec as WritableSpec, usage.kind, usage.resultingText);
}
}
/**
* Applies the variable-definition usages' edited text back into the matching
* variable's `queryValue`, so a renamed/deleted variable's references inside
* another query variable are updated alongside the panels.
*/
export function applyVariableQueryEdits(
variables: VariableFormModel[],
usages: VariableUsage[],
): VariableFormModel[] {
const edits = new Map(
usages
.filter((usage) => usage.sourceType === 'variable')
.map((usage) => [usage.sourceId, usage.resultingText]),
);
if (edits.size === 0) {
return variables;
}
return variables.map((variable) =>
edits.has(variable.name)
? { ...variable, queryValue: edits.get(variable.name) as string }
: variable,
);
}
/**
* Builds the atomic JSON-Patch for a variable rename/delete impact: replaces the
* whole variables array (which the caller has already updated for the rename/
* delete and any variable-query edits) and replaces each touched panel's queries
* with the user's resolved text.
*/
export function buildVariableImpactPatch(
dashboard: DashboardtypesGettableDashboardV2DTO,
nextVariables: VariableFormModel[],
usages: VariableUsage[],
): DashboardtypesJSONPatchOperationDTO[] {
const ops: DashboardtypesJSONPatchOperationDTO[] = [
...buildVariablesPatch(nextVariables.map(formModelToDto)),
];
const panels = dashboard.spec.panels ?? {};
const byPanel = new Map<string, VariableUsage[]>();
usages
.filter((usage) => usage.sourceType === 'panel')
.forEach((usage) => {
const list = byPanel.get(usage.sourceId) ?? [];
list.push(usage);
byPanel.set(usage.sourceId, list);
});
byPanel.forEach((list, panelId) => {
const panel = panels[panelId];
if (!panel?.spec?.queries?.length) {
return;
}
const queries = cloneDeep(panel.spec.queries);
list.forEach((usage) => applyPanelUsage(queries, usage));
ops.push({
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
path: `/spec/panels/${panelId}/spec/queries`,
value: queries,
});
});
return ops;
}

View File

@@ -0,0 +1,163 @@
import type {
DashboardtypesGettableDashboardV2DTO,
Querybuildertypesv5QueryEnvelopeDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
removeVariableReferenceClause,
rewriteVariableReferences,
textContainsVariableReference,
} from 'lib/dashboardVariables/variableReference';
import { toQueryEnvelopes } from '../../queryV5/buildQueryRangeRequest';
import { dtoToFormModel } from './variableAdapters';
/** The kind of query text a variable is referenced from. */
export type VariableUsageKind =
| 'builder'
| 'promql'
| 'clickhouse'
| 'variable';
/** Whether the impact is a rename (rewrite refs) or a delete (remove refs). */
export type VariableImpactMode = 'rename' | 'delete';
/**
* One place a variable is referenced — a panel query's builder filter expression,
* a PromQL/ClickHouse query string, or another variable's query definition. Each
* usage is a single editable text field: `currentText` is what exists today,
* `resultingText` is the proposed rewrite (rename) or removal (delete) the user
* can review and edit before applying.
*/
export interface VariableUsage {
/** Stable key: `${sourceType}:${sourceId}:${envelopeIndex}`. */
id: string;
sourceType: 'panel' | 'variable';
/** Panel id or referencing variable's name. */
sourceId: string;
/** Human label: panel display name or `$variableName`. */
sourceLabel: string;
kind: VariableUsageKind;
/** Index into the panel's query envelopes (0 for a variable definition). */
envelopeIndex: number;
currentText: string;
resultingText: string;
}
/** The reference-bearing text + kind for one query envelope, if any. */
function envelopeReferenceText(
envelope: Querybuildertypesv5QueryEnvelopeDTO,
): { kind: VariableUsageKind; text: string } | null {
const spec = envelope.spec as
| { query?: string; filter?: { expression?: string } }
| undefined;
if (envelope.type === 'builder_query') {
const text = spec?.filter?.expression;
return typeof text === 'string' ? { kind: 'builder', text } : null;
}
if (envelope.type === 'promql') {
return typeof spec?.query === 'string'
? { kind: 'promql', text: spec.query }
: null;
}
if (envelope.type === 'clickhouse_sql') {
return typeof spec?.query === 'string'
? { kind: 'clickhouse', text: spec.query }
: null;
}
return null;
}
/** The proposed text after a rename (rewrite) or delete (best-effort removal). */
function computeResultingText(
kind: VariableUsageKind,
text: string,
variableName: string,
mode: VariableImpactMode,
newName: string,
): string {
if (mode === 'rename') {
return rewriteVariableReferences(text, variableName, newName);
}
// delete: only builder filter clauses can be safely auto-stripped; raw PromQL/
// ClickHouse and variable queries are left for the user to edit.
return kind === 'builder'
? removeVariableReferenceClause(text, variableName)
: text;
}
/**
* Finds every usage of `variableName` across the dashboard's panel queries
* (builder / PromQL / ClickHouse) and other variables' query definitions, with a
* proposed `resultingText` for the given mode. Consumed by the impact dialog that
* blocks a rename/delete until the user resolves each usage.
*/
export function findVariableUsages(
dashboard: DashboardtypesGettableDashboardV2DTO,
variableName: string,
mode: VariableImpactMode,
newName = '',
): VariableUsage[] {
if (!variableName) {
return [];
}
const usages: VariableUsage[] = [];
const spec = dashboard.spec;
Object.entries(spec.panels ?? {}).forEach(([panelId, panel]) => {
const queries = panel?.spec?.queries;
if (!queries?.length) {
return;
}
toQueryEnvelopes(queries).forEach((envelope, index) => {
const ref = envelopeReferenceText(envelope);
if (!ref || !textContainsVariableReference(ref.text, variableName)) {
return;
}
usages.push({
id: `panel:${panelId}:${index}`,
sourceType: 'panel',
sourceId: panelId,
sourceLabel: panel.spec?.display?.name || panelId,
kind: ref.kind,
envelopeIndex: index,
currentText: ref.text,
resultingText: computeResultingText(
ref.kind,
ref.text,
variableName,
mode,
newName,
),
});
});
});
(spec.variables ?? []).map(dtoToFormModel).forEach((variable) => {
if (
variable.name === variableName ||
variable.type !== 'QUERY' ||
!variable.queryValue ||
!textContainsVariableReference(variable.queryValue, variableName)
) {
return;
}
usages.push({
id: `variable:${variable.name}:0`,
sourceType: 'variable',
sourceId: variable.name,
sourceLabel: `$${variable.name}`,
kind: 'variable',
envelopeIndex: 0,
currentText: variable.queryValue,
resultingText: computeResultingText(
'variable',
variable.queryValue,
variableName,
mode,
newName,
),
});
});
return usages;
}

View File

@@ -92,6 +92,7 @@ function Panel({
panelId={panelId}
data={data}
isFetching={isFetching}
isVisible={isVisible}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}

View File

@@ -23,6 +23,8 @@ interface PanelBodyProps {
panelId: string;
data: PanelQueryData;
isFetching: boolean;
/** Panel not yet scrolled into view — its fetch is deferred, so show the loader rather than NoData. */
isVisible?: boolean;
/** Showing a prior page's data while the next loads; forwarded so list renderers can show skeletons. */
isPreviousData?: boolean;
error: Error | null;
@@ -54,6 +56,7 @@ function PanelBody({
panelId,
data,
isFetching,
isVisible,
isPreviousData,
error,
refetch,
@@ -105,9 +108,9 @@ function PanelBody({
);
}
// Full-panel loader only on first fetch; a refetch over existing data keeps the renderer
// mounted (e.g. list page change). A refetch over empty data loads via NoData.
if (isFetching && !hasData) {
// Full-panel loader on first fetch or while the fetch is deferred (off-screen); a refetch
// over existing data keeps the renderer mounted, empty data loads via NoData.
if ((isFetching || isVisible === false) && !hasData) {
return <PanelLoader />;
}

View File

@@ -79,6 +79,21 @@ describe('PanelBody', () => {
expect(screen.queryByTestId('mock-renderer')).not.toBeInTheDocument();
});
it('shows the loader while the fetch is deferred (panel not yet scrolled into view)', () => {
render(
<PanelBody
{...baseProps}
panel={runnablePanel()}
data={{} as PanelQueryData}
isFetching={false}
isVisible={false}
/>,
);
expect(screen.getByTestId('panel-loading')).toBeInTheDocument();
expect(screen.queryByTestId('mock-renderer')).not.toBeInTheDocument();
});
it('keeps the renderer mounted during a refetch over existing data (e.g. list page change)', () => {
render(
<PanelBody

View File

@@ -113,8 +113,8 @@ function ViewPanelModalHeader({
/>
<Button
size="icon"
variant="solid"
color="primary"
variant="outlined"
color="secondary"
onClick={onRefresh}
disabled={isFetching}
aria-label="Refresh"

View File

@@ -22,6 +22,7 @@ function SectionGrid({
sections,
}: SectionGridProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const rglLayout = useMemo<Layout[]>(
() =>
items.map((item) => ({

View File

@@ -10,6 +10,10 @@ const VIEWPORT_OBSERVER_OPTIONS: IntersectionObserverInit = {
rootMargin: '200px',
};
// Start observing after RGL's mount unfold settles, so a panel that only
// transiently overlaps the viewport during layout doesn't fire a throwaway fetch.
const OBSERVER_START_DELAY_MS = 350;
interface SectionGridItemProps {
panel: DashboardtypesPanelDTO;
panelId: string;
@@ -17,9 +21,9 @@ interface SectionGridItemProps {
}
/**
* Lazy-loads a single panel: watches its own viewport intersection (latched) and
* passes it to the presentational Panel as `isVisible`, so a board of many panels
* only fetches what's on screen.
* Lazy-loads a single panel: tracks its live viewport intersection and passes it to
* the presentational Panel as `isVisible`, so a board of many panels only fetches
* (and refetches on time change / auto-refresh) what's on screen.
*/
function SectionGridItem({
panel,
@@ -30,7 +34,10 @@ function SectionGridItem({
const isVisible = useIntersectionObserver(
containerRef,
VIEWPORT_OBSERVER_OPTIONS,
true,
// Not once: track the live viewport so a time change / auto-refresh only
// refetches on-screen panels (off-screen ones stay query-disabled).
false,
OBSERVER_START_DELAY_MS,
);
useScrollIntoView(panelId, containerRef);

View File

@@ -23,7 +23,7 @@ describe('useScrollIntoView', () => {
expect(scrollIntoView).toHaveBeenCalledWith({
behavior: 'smooth',
block: 'start',
block: 'center',
});
expect(useScrollIntoViewStore.getState().scrollTargetId).toBeNull();
});
@@ -38,7 +38,7 @@ describe('useScrollIntoView', () => {
expect(scrollIntoView).toHaveBeenCalledWith({
behavior: 'smooth',
block: 'start',
block: 'center',
});
expect(useScrollIntoViewStore.getState().scrollTargetId).toBeNull();
});

View File

@@ -9,7 +9,7 @@ import { useScrollIntoViewStore } from '../../../store/useScrollIntoViewStore';
export function useScrollIntoView(
id: string,
ref: RefObject<HTMLElement>,
block: ScrollLogicalPosition = 'start',
block: ScrollLogicalPosition = 'center',
): void {
const scrollTargetId = useScrollIntoViewStore((s) => s.scrollTargetId);
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);

View File

@@ -1,15 +1,17 @@
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 { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
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 VariableValueControl from './selectors/VariableValueControl';
import { useVariableFetchState } from './useVariableFetchState';
import styles from './VariablesBar.module.scss';
import VariableTooltip from './VariableTooltip';
interface VariableSelectorProps {
variable: VariableFormModel;
@@ -32,50 +34,63 @@ function VariableSelector({
onChange,
onAutoSelect,
}: VariableSelectorProps): JSX.Element {
const renderControl = (): JSX.Element => {
switch (variable.type) {
case 'TEXT':
return (
<TextSelector
selection={selection}
defaultValue={variable.textValue}
onChange={onChange}
testId={`variable-input-${variable.name}`}
/>
);
case 'QUERY':
return (
<QuerySelector
variable={variable}
selections={selections}
selection={selection}
onChange={onChange}
onAutoSelect={onAutoSelect}
/>
);
case 'DYNAMIC':
return (
<DynamicSelector
variable={variable}
variables={variables}
selections={selections}
selection={selection}
onChange={onChange}
onAutoSelect={onAutoSelect}
/>
);
case 'CUSTOM':
default:
return (
<CustomSelector
variable={variable}
selection={selection}
onChange={onChange}
onAutoSelect={onAutoSelect}
/>
);
}
};
// Dependency links shown in the hover tooltip: variables this one's query
// references (dependsOn) and query variables that reference this one (usedBy).
const { dependsOn, usedBy } = useMemo(() => {
const references = (text: string | undefined, name: string): boolean =>
!!text && !!name && textContainsVariableReference(text, name);
return {
dependsOn:
variable.type === 'QUERY'
? variables
.filter(
(v) =>
v.name !== variable.name && references(variable.queryValue, v.name),
)
.map((v) => v.name)
: [],
usedBy: variables
.filter(
(v) =>
v.type === 'QUERY' &&
v.name !== variable.name &&
references(v.queryValue, variable.name),
)
.map((v) => v.name),
};
}, [variable, variables]);
const hasTooltip =
!!variable.description || dependsOn.length > 0 || usedBy.length > 0;
// Surface the fetch on the bar itself: a bar flush along the control's bottom
// edge while a QUERY/DYNAMIC variable is loading (or waiting on a parent), so the
// user sees options are being fetched without opening the dropdown.
const { isVariableFetching, isVariableWaiting } = useVariableFetchState(
variable.name,
);
const isFetchingOptions =
(variable.type === 'QUERY' || variable.type === 'DYNAMIC') &&
(isVariableFetching || isVariableWaiting);
const renderControl = (): JSX.Element =>
variable.type === 'TEXT' ? (
<TextSelector
selection={selection}
defaultValue={variable.textValue}
onChange={onChange}
testId={`variable-input-${variable.name}`}
/>
) : (
<VariableValueControl
variable={variable}
variables={variables}
selections={selections}
selection={selection}
onChange={onChange}
onAutoSelect={onAutoSelect}
/>
);
return (
<div
@@ -84,14 +99,29 @@ function VariableSelector({
>
<Typography.Text className={styles.variableName}>
${variable.name}
{variable.description ? (
<Tooltip title={variable.description}>
{hasTooltip ? (
<Tooltip
title={
<VariableTooltip
description={variable.description}
dependsOn={dependsOn}
usedBy={usedBy}
/>
}
>
<SolidInfoCircle className={styles.infoIcon} size={14} />
</Tooltip>
) : null}
</Typography.Text>
<div className={styles.variableValue}>{renderControl()}</div>
{isFetchingOptions ? (
<span
className={styles.loadingBar}
data-testid={`variable-loading-${variable.name}`}
/>
) : null}
</div>
);
}

View File

@@ -0,0 +1,67 @@
import cx from 'classnames';
import styles from './VariablesBar.module.scss';
interface VariableTooltipProps {
description?: string;
/** Variables this one references (its query depends on their values). */
dependsOn: string[];
/** Variables whose queries reference this one. */
usedBy: string[];
}
/** Hover-tooltip body for a variable: its description plus its dependencies. */
function VariableTooltip({
description,
dependsOn,
usedBy,
}: VariableTooltipProps): JSX.Element {
const hasDependencies = dependsOn.length > 0 || usedBy.length > 0;
return (
<div className={styles.tooltipContent}>
{description ? (
<div className={styles.tooltipDescription}>{description}</div>
) : null}
{hasDependencies ? (
<>
{description ? <div className={styles.tooltipDivider} /> : null}
{dependsOn.length > 0 ? (
<div className={styles.tooltipSection}>
<div className={cx(styles.tooltipLabel, styles.dependsColor)}>
Depends on
</div>
<div className={styles.tooltipRefs}>
{dependsOn.map((name) => (
<span
key={name}
className={cx(styles.tooltipRef, styles.dependsColor)}
>
${name}
</span>
))}
</div>
</div>
) : null}
{usedBy.length > 0 ? (
<div className={styles.tooltipSection}>
<div className={cx(styles.tooltipLabel, styles.usedByColor)}>
Used by
</div>
<div className={styles.tooltipRefs}>
{usedBy.map((name) => (
<span key={name} className={cx(styles.tooltipRef, styles.usedByColor)}>
${name}
</span>
))}
</div>
</div>
) : null}
</>
) : null}
</div>
);
}
export default VariableTooltip;

View File

@@ -73,10 +73,57 @@
}
.variableItem {
position: relative;
display: flex;
align-items: center;
}
// Loading indicator: an indeterminate bar flush along the control's bottom edge,
// full width and overlaying the border so it reads as the input's own edge rather
// than a separate element. Non-interactive so the name/description stays hoverable.
.loadingBar {
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 2px;
overflow: hidden;
border-radius: 0 0 2px 2px;
background: color-mix(in srgb, var(--bg-robin-500) 20%, transparent);
pointer-events: none;
}
.loadingBar::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 40%;
border-radius: 2px;
background: var(--bg-robin-500);
animation: variable-loading-slide 1.1s ease-in-out infinite;
}
@keyframes variable-loading-slide {
0% {
left: -40%;
}
100% {
left: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.loadingBar::after {
left: 0;
width: 100%;
animation: none;
opacity: 0.7;
}
}
.variableName {
display: flex;
min-width: 56px;
@@ -87,7 +134,7 @@
border: 1px solid var(--l3-border);
border-radius: 2px 0 0 2px;
background: var(--l3-background);
color: var(--bg-robin-300);
color: var(--bg-robin-400);
font-family: Inter;
font-size: 12px;
font-weight: 400;
@@ -97,11 +144,63 @@
.infoIcon {
display: inline-flex;
margin-left: 2px;
margin-left: 6px;
color: var(--l2-foreground);
vertical-align: middle;
}
.tooltipContent {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 240px;
}
.tooltipDescription {
font-size: 12px;
line-height: 1.5;
}
// Divider and labels use the tooltip's own text color at reduced opacity so they
// read on the tooltip surface in either theme without hard-coding a palette.
.tooltipDivider {
height: 1px;
background: currentColor;
opacity: 0.16;
}
.tooltipSection {
display: flex;
flex-direction: column;
gap: 4px;
}
.tooltipLabel {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.tooltipRefs {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.tooltipRef {
font-size: 12px;
}
// Directional colors: parents (Depends on) in forest, children (Used by) in amber.
.dependsColor {
color: var(--bg-forest-500);
}
.usedByColor {
color: var(--bg-amber-500);
}
.variableValue {
display: flex;
min-width: 120px;

View File

@@ -0,0 +1,153 @@
import {
emptyVariableFormModel,
type VariableFormModel,
} from '../../DashboardSettings/Variables/variableFormModel';
import {
configuredDefaultValue,
reconcileWithOptions,
resolveDefaultSelection,
} from '../resolveVariableSelection';
function model(overrides: Partial<VariableFormModel>): VariableFormModel {
return { ...emptyVariableFormModel(), ...overrides };
}
describe('resolveDefaultSelection', () => {
it('TEXT: uses defaultValue, then textValue, else empty string', () => {
expect(
resolveDefaultSelection(model({ type: 'TEXT', defaultValue: 'd' })),
).toStrictEqual({ value: 'd', allSelected: false });
expect(
resolveDefaultSelection(model({ type: 'TEXT', textValue: 't' })),
).toStrictEqual({ value: 't', allSelected: false });
expect(resolveDefaultSelection(model({ type: 'TEXT' }))).toStrictEqual({
value: '',
allSelected: false,
});
});
it('list: ALL when allowAll (multi + showAllOption) and no default', () => {
expect(
resolveDefaultSelection(
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
),
).toStrictEqual({ value: null, allSelected: true });
});
it('list: ALL sentinel default → ALL', () => {
expect(
resolveDefaultSelection(
model({ type: 'CUSTOM', multiSelect: true, defaultValue: '__ALL__' }),
),
).toStrictEqual({ value: null, allSelected: true });
});
it('list: configured default wins over ALL default', () => {
expect(
resolveDefaultSelection(
model({
type: 'QUERY',
multiSelect: true,
showAllOption: true,
defaultValue: 'x',
}),
),
).toStrictEqual({ value: ['x'], allSelected: false });
});
it('list: no default and no allowAll → empty placeholder (filled after fetch)', () => {
expect(resolveDefaultSelection(model({ type: 'QUERY' }))).toStrictEqual({
value: '',
allSelected: false,
});
expect(
resolveDefaultSelection(model({ type: 'QUERY', multiSelect: true })),
).toStrictEqual({ value: [], allSelected: false });
});
});
describe('reconcileWithOptions', () => {
it('leaves a valid single selection untouched (local-first)', () => {
expect(
reconcileWithOptions(
model({ type: 'QUERY' }),
{ value: 'b', allSelected: false },
['a', 'b'],
),
).toBeNull();
});
it('materializes query ALL to the full option array', () => {
expect(
reconcileWithOptions(
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
{ value: null, allSelected: true },
['a', 'b'],
),
).toStrictEqual({ value: ['a', 'b'], allSelected: true });
});
it('does not materialize dynamic ALL (sends __all__)', () => {
expect(
reconcileWithOptions(
model({ type: 'DYNAMIC', multiSelect: true, showAllOption: true }),
{ value: null, allSelected: true },
['a', 'b'],
),
).toBeNull();
});
it('keeps the still-valid subset when options re-scope', () => {
expect(
reconcileWithOptions(
model({ type: 'QUERY', multiSelect: true }),
{ value: ['a', 'b', 'c'], allSelected: false },
['a', 'b', 'd'],
),
).toStrictEqual({ value: ['a', 'b'], allSelected: false });
});
it('falls back to the configured default (else first) when invalid', () => {
expect(
reconcileWithOptions(
model({ type: 'QUERY', defaultValue: 'b' }),
{ value: '', allSelected: false },
['a', 'b', 'c'],
),
).toStrictEqual({ value: 'b', allSelected: false });
expect(
reconcileWithOptions(
model({ type: 'QUERY' }),
{ value: '', allSelected: false },
['a', 'b'],
),
).toStrictEqual({ value: 'a', allSelected: false });
});
it('does nothing while options are empty', () => {
expect(
reconcileWithOptions(
model({ type: 'QUERY' }),
{ value: '', allSelected: false },
[],
),
).toBeNull();
});
});
describe('configuredDefaultValue', () => {
it('TEXT: textValue fallback; list: defaultValue only (no ALL synthesis)', () => {
expect(configuredDefaultValue(model({ type: 'TEXT', textValue: 't' }))).toBe(
't',
);
expect(
configuredDefaultValue(model({ type: 'QUERY', defaultValue: 'x' })),
).toBe('x');
// ALL-by-default list variable is not expanded here (options unknown).
expect(
configuredDefaultValue(
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
),
).toBeUndefined();
});
});

View File

@@ -1,31 +0,0 @@
import { withVariablesSearch } from '../variablesUrlState';
jest.mock('nuqs', () => ({
parseAsJson: (): unknown => ({ withOptions: (): unknown => ({}) }),
}));
describe('withVariablesSearch', () => {
const current = `?compositeQuery=abc&variables=${encodeURIComponent(
'{"env":"prod"}',
)}`;
it('returns the base unchanged when the current search has no variables', () => {
expect(withVariablesSearch('', '?compositeQuery=abc')).toBe('');
expect(withVariablesSearch('?panelKind=signoz/TablePanel', '')).toBe(
'?panelKind=signoz/TablePanel',
);
});
it('carries only the variables param onto an empty base', () => {
const result = withVariablesSearch('', current);
expect(new URLSearchParams(result).get('variables')).toBe('{"env":"prod"}');
expect(new URLSearchParams(result).get('compositeQuery')).toBeNull();
});
it('appends the variables param to existing base params', () => {
const result = withVariablesSearch('?panelKind=signoz/TablePanel', current);
const params = new URLSearchParams(result);
expect(params.get('panelKind')).toBe('signoz/TablePanel');
expect(params.get('variables')).toBe('{"env":"prod"}');
});
});

View File

@@ -0,0 +1,183 @@
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
import type {
SelectedVariableValue,
VariableSelection,
} from './selectionTypes';
import { ALL_SELECTED } from './variablesUrlState';
/**
* Single source of truth for "what value does this variable hold?", shared by the
* three surfaces that used to each own a divergent copy of the rule:
* - {@link resolveDefaultSelection} — the seed-time default (no options yet).
* - {@link reconcileWithOptions} — the post-fetch reconcile (options known).
* - {@link configuredDefaultValue} — the payload fallback when nothing is picked.
*
* Keeping them here means the variable bar, the fetch gate and the panel-query
* payload can never disagree about a variable's default (the previous split
* produced "bar shows ALL while the query omits the variable").
*/
/** An "every option selected" (ALL) selection. */
const ALL_SELECTION: VariableSelection = { value: null, allSelected: true };
/** The `defaultValue` reduced to a single string, or undefined when unset. */
function firstConfiguredDefault(model: VariableFormModel): string | undefined {
const def = model.defaultValue;
if (Array.isArray(def)) {
return def.length > 0 ? String(def[0]) : undefined;
}
if (typeof def === 'string' && def !== '') {
return def;
}
return undefined;
}
/** Whether the configured default marks the ALL sentinel. */
function isAllDefault(def: VariableFormModel['defaultValue']): boolean {
return (
def === ALL_SELECTED ||
(Array.isArray(def) && def.length === 1 && def[0] === ALL_SELECTED)
);
}
function isValidSingle(
value: SelectedVariableValue,
options: string[],
): boolean {
return (
!Array.isArray(value) &&
value !== '' &&
value !== null &&
value !== undefined &&
options.includes(String(value))
);
}
/** The configured default (or first option) as a fresh selection. */
function fillDefault(
model: VariableFormModel,
options: string[],
): VariableSelection {
const fallback = firstConfiguredDefault(model);
const initial = fallback && options.includes(fallback) ? fallback : options[0];
return {
value: model.multiSelect ? [initial] : initial,
allSelected: false,
};
}
/**
* For an ALL selection, the value to materialize (or null when unchanged).
* Dynamic ALL travels as the `__all__` wire sentinel and renders ALL from the
* flag, so it needs no materialized value. Query/custom ALL must carry the full
* option array (the payload builder cannot expand it) — keep it in sync.
*/
function materializeAll(
model: VariableFormModel,
options: string[],
current: SelectedVariableValue,
): VariableSelection | null {
if (!model.multiSelect || model.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 };
}
/**
* The seed-time default for a variable, before any options are fetched.
* - TEXT: the configured default (`defaultValue` → `textValue`), else empty.
* - CUSTOM/QUERY/DYNAMIC: the configured default; else ALL when allowAll is on;
* else a placeholder that {@link reconcileWithOptions} fills with the first
* option once the options resolve.
*/
export function resolveDefaultSelection(
model: VariableFormModel,
): VariableSelection {
if (model.type === 'TEXT') {
return {
value: firstConfiguredDefault(model) ?? model.textValue ?? '',
allSelected: false,
};
}
const def = model.defaultValue;
if (isAllDefault(def)) {
return ALL_SELECTION;
}
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 ALL_SELECTION;
}
return { value: model.multiSelect ? [] : '', allSelected: false };
}
/**
* Reconciles a variable's current selection against its freshly-fetched options.
* Returns the next selection, or null when nothing should change (a valid pick is
* left untouched — local-first). Behaviour, in order:
* - materialize ALL to the full option set (query/custom);
* - keep a still-valid multi-select subset, dropping only invalid entries;
* - otherwise auto-pick the default (or first option) so dependent variables and
* panels always resolve against a usable value.
*/
export function reconcileWithOptions(
model: VariableFormModel,
current: VariableSelection,
options: string[],
): VariableSelection | null {
if (options.length === 0) {
return null;
}
if (current.allSelected) {
return materializeAll(model, options, current.value);
}
if (
model.multiSelect &&
Array.isArray(current.value) &&
current.value.length > 0
) {
const valid = current.value.map(String).filter((c) => options.includes(c));
if (valid.length === current.value.length) {
return null;
}
return valid.length > 0
? { value: valid, allSelected: false }
: fillDefault(model, options);
}
if (!model.multiSelect && isValidSingle(current.value, options)) {
return null;
}
return fillDefault(model, options);
}
/**
* The value to send for a variable when the user has made no selection yet
* (the payload fallback). Mirrors the configured default only — an ALL-by-default
* list variable resolves to `undefined` here (its concrete values are carried by
* the materialized selection once options are known), so it is omitted until then
* rather than sent wrong.
*/
export function configuredDefaultValue(
model: VariableFormModel,
): SelectedVariableValue | undefined {
if (model.type === 'TEXT') {
return firstConfiguredDefault(model) ?? model.textValue ?? undefined;
}
const def = model.defaultValue;
if (Array.isArray(def)) {
return def.length > 0 ? def : undefined;
}
return def || undefined;
}

View File

@@ -1,3 +1,4 @@
import type { VariableType } from '../DashboardSettings/Variables/variableFormModel';
import type {
SelectedVariableValue,
VariableSelection,
@@ -19,6 +20,32 @@ export function isResolved(selection?: VariableSelection): boolean {
return value !== '' && value !== null && value !== undefined;
}
/**
* Whether a selection carries a value usable when scheduling a dependent
* variable/panel fetch. Unlike {@link isResolved}, a QUERY/CUSTOM ALL counts only
* once materialized into the concrete array (an unmaterialized ALL isn't usable),
* while a DYNAMIC ALL is usable immediately via the `__all__` sentinel.
*/
export function hasUsableValue(
selection: VariableSelection | undefined,
type: VariableType | undefined,
): boolean {
if (!selection) {
return false;
}
if (selection.allSelected) {
if (type === 'DYNAMIC') {
return true;
}
return Array.isArray(selection.value) && selection.value.length > 0;
}
const { value } = selection;
if (Array.isArray(value)) {
return value.length > 0;
}
return value !== '' && value !== null && value !== undefined;
}
/** Flatten the selection map into the `{ name: value }` payload a query expects. */
export function selectionToPayload(
selection: VariableSelectionMap,

View File

@@ -1,50 +0,0 @@
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,140 +0,0 @@
import { useMemo } from 'react';
import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
import {
DASHBOARD_CACHE_TIME,
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
} from 'constants/queryCacheTime';
import type { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
import {
signalForApi,
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 {
variable: VariableFormModel;
/** All variables + current selections, to scope options by sibling dynamics. */
variables: VariableFormModel[];
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`). 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,
variables,
selections,
selection,
onChange,
onAutoSelect,
}: DynamicSelectorProps): JSX.Element {
const { minTime, maxTime, isAutoRefreshDisabled } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const existingQuery = useMemo(
() => buildExistingDynamicVariableQuery(variables, selections, variable.name),
[variables, selections, variable.name],
);
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; 0 under auto-refresh so entries don't pile up (V1 parity).
cacheTime: isAutoRefreshDisabled
? DASHBOARD_CACHE_TIME
: DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
onSettled: (_, error) =>
error
? onVariableFetchFailure(variable.name)
: onVariableFetchComplete(variable.name),
},
);
const options = useMemo(() => {
const payload = data?.data;
const values =
payload?.normalizedValues ?? payload?.values?.StringValues ?? [];
return sortValuesByOrder(values, variable.sort).map(String);
}, [data, variable.sort]);
useAutoSelect(variable, options, selection, onAutoSelect);
return (
<ValueSelector
options={options}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
loading={isFetching || isVariableWaiting}
errorMessage={error ? (error as Error).message || null : null}
onRetry={(): void => {
void refetch();
}}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}
/>
);
}
export default DynamicSelector;

View File

@@ -1,127 +0,0 @@
import { useMemo } from 'react';
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,
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
} 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 { selectionToPayload } from '../selectionUtils';
import { useAutoSelect } from '../useAutoSelect';
import { useVariableFetchState } from '../useVariableFetchState';
import ValueSelector from './ValueSelector';
interface QuerySelectorProps {
variable: VariableFormModel;
/** 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. 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,
selections,
selection,
onChange,
onAutoSelect,
}: QuerySelectorProps): JSX.Element {
const { minTime, maxTime, isAutoRefreshDisabled } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const payload = useMemo(() => selectionToPayload(selections), [selections]);
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,
minTime,
maxTime,
variableFetchCycleId,
],
() =>
dashboardVariablesQuery({
query: variable.queryValue,
variables: payload,
}),
{
enabled: isVariableFetching || (isVariableSettled && hasVariableFetchedOnce),
refetchOnWindowFocus: false,
// Each cycle mints a fresh key; 0 under auto-refresh so entries don't pile up (V1 parity).
cacheTime: isAutoRefreshDisabled
? DASHBOARD_CACHE_TIME
: DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
onSettled: (_, error) =>
error
? onVariableFetchFailure(variable.name)
: onVariableFetchComplete(variable.name),
},
);
const options = useMemo(() => {
if (!data || data.statusCode !== 200 || !data.payload) {
return [] as string[];
}
return sortValuesByOrder(
data.payload.variableValues ?? [],
variable.sort,
).map(String);
}, [data, variable.sort]);
useAutoSelect(variable, options, selection, onAutoSelect);
return (
<ValueSelector
options={options}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
loading={isFetching || isVariableWaiting}
errorMessage={error ? (error as Error).message || null : null}
onRetry={(): void => {
void refetch();
}}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}
/>
);
}
export default QuerySelector;

View File

@@ -0,0 +1,59 @@
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
import type {
VariableSelection,
VariableSelectionMap,
} from '../selectionTypes';
import { useAutoSelect } from '../useAutoSelect';
import ValueSelector from './ValueSelector';
import { useVariableOptions } from './useVariableOptions';
interface VariableValueControlProps {
variable: VariableFormModel;
/** All variables (Dynamic scopes its options by sibling selections). */
variables: VariableFormModel[];
/** All current selections (fed to the Query request payload). */
selections: VariableSelectionMap;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
/** Batched auto-selection fill applied when options resolve. */
onAutoSelect: (selection: VariableSelection) => void;
}
/**
* The single value picker for QUERY / CUSTOM / DYNAMIC variables. Options + fetch
* state come from {@link useVariableOptions}; this component only reconciles the
* selection against the options and renders — the view is decoupled from how the
* options are sourced (Container/Presentational).
*/
function VariableValueControl({
variable,
variables,
selections,
selection,
onChange,
onAutoSelect,
}: VariableValueControlProps): JSX.Element {
const { options, loading, errorMessage, onRetry } = useVariableOptions(
variable,
variables,
selections,
);
useAutoSelect(variable, options, selection, onAutoSelect);
return (
<ValueSelector
options={options}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
loading={loading}
errorMessage={errorMessage}
onRetry={onRetry}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}
/>
);
}
export default VariableValueControl;

View File

@@ -0,0 +1,213 @@
import { useEffect, useMemo } from 'react';
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 { getFieldValues } from 'api/dynamicVariables/getFieldValues';
import {
DASHBOARD_CACHE_TIME,
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
} from 'constants/queryCacheTime';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import type { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
import {
signalForApi,
sortValuesByOrder,
} from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
import { useDashboardStore } from '../../store/useDashboardStore';
import { buildExistingDynamicVariableQuery } from '../dynamicFilter';
import type { VariableSelectionMap } from '../selectionTypes';
import { selectionToPayload } from '../selectionUtils';
import { useVariableFetchState } from '../useVariableFetchState';
export interface VariableOptions {
options: string[];
loading: boolean;
errorMessage: string | null;
onRetry?: () => void;
}
/**
* The option list for a list variable (QUERY / CUSTOM / DYNAMIC), plus its loading
* and error state — the single place the three list types get their options.
* QUERY/DYNAMIC fetch via react-query (WHEN owned by the fetch engine: `enabled`
* gated on the variable's fetch state, keyed by `cycleId`, never by the current
* selections or time — those feed the fetchers (which read the current time at
* call), so the debounced fetch cycle drives refetches). CUSTOM is parsed
* synchronously from its comma list. TEXT never reaches here (it has no options).
*/
export function useVariableOptions(
variable: VariableFormModel,
variables: VariableFormModel[],
selections: VariableSelectionMap,
): VariableOptions {
const { minTime, maxTime, isAutoRefreshDisabled } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
// Bound cache churn: 0 under auto-refresh so entries don't pile up (V1 parity).
const cacheTime = isAutoRefreshDisabled
? DASHBOARD_CACHE_TIME
: DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED;
const {
variableFetchCycleId,
isVariableFetching,
isVariableSettled,
isVariableWaiting,
hasVariableFetchedOnce,
} = useVariableFetchState(variable.name);
const onVariableFetchComplete = useDashboardStore(
(s) => s.onVariableFetchComplete,
);
const onVariableFetchFailure = useDashboardStore(
(s) => s.onVariableFetchFailure,
);
const setVariableResolvedEmpty = useDashboardStore(
(s) => s.setVariableResolvedEmpty,
);
// Fetch while this variable is actively fetching, or once settled after a first
// fetch (so a `cycleId` bump re-runs it). Combined with a per-type guard below.
const canFetch =
isVariableFetching || (isVariableSettled && hasVariableFetchedOnce);
// QUERY — options from the test-run endpoint; selections feed the payload, not the key.
const payload = useMemo(() => selectionToPayload(selections), [selections]);
const queryResult = useQuery(
[
'dashboard-variable',
variable.name,
variable.queryValue,
variableFetchCycleId,
],
() =>
dashboardVariablesQuery({
query: variable.queryValue,
variables: payload,
}),
{
enabled: variable.type === 'QUERY' && canFetch,
refetchOnWindowFocus: false,
cacheTime,
onSettled: (_, error) =>
error
? onVariableFetchFailure(variable.name)
: onVariableFetchComplete(variable.name),
},
);
// DYNAMIC — telemetry field values scoped by sibling dynamics via `existingQuery`
// (fed to the fetcher only, not the key — see DynamicSelector history).
const existingQuery = useMemo(
() => buildExistingDynamicVariableQuery(variables, selections, variable.name),
[variables, selections, variable.name],
);
const dynamicResult = useQuery(
[
'dashboard-variable-dynamic',
variable.name,
variable.dynamicSignal,
variable.dynamicAttribute,
variableFetchCycleId,
],
() =>
getFieldValues(
signalForApi(variable.dynamicSignal),
variable.dynamicAttribute,
undefined,
minTime,
maxTime,
existingQuery || undefined,
),
{
enabled:
variable.type === 'DYNAMIC' && !!variable.dynamicAttribute && canFetch,
refetchOnWindowFocus: false,
cacheTime,
onSettled: (_, error) =>
error
? onVariableFetchFailure(variable.name)
: onVariableFetchComplete(variable.name),
},
);
const queryOptions = useMemo(() => {
const data = queryResult.data;
if (!data || data.statusCode !== 200 || !data.payload) {
return [] as string[];
}
return sortValuesByOrder(
data.payload.variableValues ?? [],
variable.sort,
).map(String);
}, [queryResult.data, variable.sort]);
const dynamicOptions = useMemo(() => {
const data = dynamicResult.data?.data;
const values = data?.normalizedValues ?? data?.values?.StringValues ?? [];
return sortValuesByOrder(values, variable.sort).map(String);
}, [dynamicResult.data, variable.sort]);
const customOptions = useMemo(
() =>
variable.type === 'CUSTOM'
? sortValuesByOrder(
commaValuesParser(variable.customValue),
variable.sort,
).map(String)
: ([] as string[]),
[variable.type, variable.customValue, variable.sort],
);
// Flag a variable that settled with zero options so dependent panels fall through
// to "no data" instead of waiting forever. hasFetchedOnce excludes the pre-fetch state.
const effectiveOptions =
variable.type === 'DYNAMIC' ? dynamicOptions : queryOptions;
useEffect(() => {
if (variable.type !== 'QUERY' && variable.type !== 'DYNAMIC') {
return;
}
setVariableResolvedEmpty(
variable.name,
hasVariableFetchedOnce &&
!isVariableFetching &&
effectiveOptions.length === 0,
);
}, [
variable.type,
variable.name,
hasVariableFetchedOnce,
isVariableFetching,
effectiveOptions.length,
setVariableResolvedEmpty,
]);
if (variable.type === 'CUSTOM') {
return { options: customOptions, loading: false, errorMessage: null };
}
if (variable.type === 'DYNAMIC') {
return {
options: dynamicOptions,
loading: dynamicResult.isFetching || isVariableWaiting,
errorMessage: dynamicResult.error
? (dynamicResult.error as Error).message || null
: null,
onRetry: (): void => {
void dynamicResult.refetch();
},
};
}
return {
options: queryOptions,
loading: queryResult.isFetching || isVariableWaiting,
errorMessage: queryResult.error
? (queryResult.error as Error).message || null
: null,
onRetry: (): void => {
void queryResult.refetch();
},
};
}

View File

@@ -1,61 +1,14 @@
import { useEffect } from 'react';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
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))
);
}
import { reconcileWithOptions } from './resolveVariableSelection';
import type { VariableSelection } from './selectionTypes';
/**
* 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.
* Reconciles a variable's selection with its freshly-fetched options and fires
* `onAutoSelect` only when the value must change. The reconcile rule lives in
* {@link reconcileWithOptions} (shared with seed + payload defaulting) so the bar
* and the panel query can never disagree about a variable's default.
*/
export function useAutoSelect(
variable: VariableFormModel,
@@ -64,36 +17,10 @@ export function useAutoSelect(
onAutoSelect: (selection: VariableSelection) => void,
): void {
useEffect(() => {
if (options.length === 0) {
return;
const next = reconcileWithOptions(variable, selection, options);
if (next) {
onAutoSelect(next);
}
const current = selection.value;
if (selection.allSelected) {
const next = reconcileAllSelected(variable, options, current);
if (next) {
onAutoSelect(next);
}
return;
}
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

@@ -6,6 +6,7 @@ import { dtoToFormModel } from '../DashboardSettings/Variables/variableAdapters'
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
import { selectVariableValues } from '../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../store/useDashboardStore';
import { resolveDefaultSelection } from './resolveVariableSelection';
import type {
SelectedVariableValue,
VariableSelection,
@@ -17,26 +18,6 @@ import {
} from './variableDependencies';
import { ALL_SELECTED, variablesUrlParser } from './variablesUrlState';
function defaultSelection(model: VariableFormModel): VariableSelection {
const def = model.defaultValue;
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 };
}
// The `__ALL__` sentinel only means "ALL" for variables that support it — a
// legitimate value of "__ALL__" (e.g. a text var) is taken literally.
function fromUrlValue(
@@ -88,12 +69,20 @@ export function useSeedVariableSelection(
const seeded: VariableSelectionMap = {};
variables.forEach((variable) => {
const urlValue = urlValues?.[variable.name];
const stored = selection[variable.name];
if (urlValue !== undefined) {
seeded[variable.name] = fromUrlValue(urlValue, variable);
} else if (selection[variable.name]) {
seeded[variable.name] = selection[variable.name];
const fromUrl = fromUrlValue(urlValue, variable);
// When the URL carries only the ALL sentinel but the store already holds
// the materialized full-option array, reuse it — avoids the re-fetch +
// re-materialize round-trip (and its dependent-refetch cascade) on load.
seeded[variable.name] =
fromUrl.allSelected && stored?.allSelected && Array.isArray(stored.value)
? stored
: fromUrl;
} else if (stored) {
seeded[variable.name] = stored;
} else {
seeded[variable.name] = defaultSelection(variable);
seeded[variable.name] = resolveDefaultSelection(variable);
}
});
setVariableValues(dashboardId, seeded);
@@ -116,8 +105,10 @@ export function useSeedVariableSelection(
// eslint-disable-next-line react-hooks/exhaustive-deps -- seed once per dashboard/variable set; the URL is read as of that moment
}, [dashboardId, variables]);
// Always init the context (even with no variables) so panels can tell "ready, none"
// from "not ready yet"; also clears it when the last variable is removed.
useEffect(() => {
if (!dashboardId || variables.length === 0) {
if (!dashboardId) {
return;
}
const names = variables

View File

@@ -11,9 +11,14 @@ import { selectVariableValues } from '../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../store/useDashboardStore';
import type { VariableSelection, VariableSelectionMap } from './selectionTypes';
import { useSeedVariableSelection } from './useSeedVariableSelection';
import { doAllQueryVariablesHaveValues } from './variableDependencies';
import { ALL_SELECTED, variablesUrlParser } from './variablesUrlState';
/**
* Debounce for the fetch cycle, so the on-load time-range settle (default → saved)
* and rapid time-picker changes collapse into one cycle instead of double-fetching.
*/
const FETCH_CYCLE_DEBOUNCE_MS = 250;
interface UseVariableSelection {
variables: VariableFormModel[];
selection: VariableSelectionMap;
@@ -48,9 +53,10 @@ export function useVariableSelection(
(s) => s.enqueueDescendantsBatch,
);
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const { minTime, maxTime, selectedTime } = 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).
@@ -62,20 +68,39 @@ export function useVariableSelection(
variablesUrlParser.withOptions({ history: 'replace' }),
);
// Start a full fetch cycle on load / dependency-order / time change. A value
// change instead goes through `enqueueDescendants`, not this effect.
// Start a full fetch cycle on load / dependency-order / time change, debounced so
// the initial time-window settle (and rapid time changes) collapse into ONE cycle
// instead of double-fetching every variable. Variables stay disabled until the
// cycle runs, so the transient window is never fetched. A value change instead
// goes through `enqueueDescendants` — immediate, not this effect.
const orderKey = `${fetchContext.queryVariableOrder.join(
',',
)}|${fetchContext.dynamicVariableOrder.join(',')}`;
// Key on the time *selection*, not raw min/max: a relative range recomputes those
// as `now` drifts, which shouldn't refetch. The fetchers still read current time.
const timeKey =
selectedTime === 'custom' ? `custom:${minTime}-${maxTime}` : selectedTime;
// A re-mount re-runs this effect with the same key, which enqueueFetchAll skips.
const fetchCycleKey = `${dashboardId}|${orderKey}|${timeKey}`;
const fetchCycleTimer = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
if (!dashboardId || variables.length === 0) {
return;
return undefined;
}
enqueueFetchAll(
doAllQueryVariablesHaveValues(variables, selectionRef.current),
if (fetchCycleTimer.current) {
clearTimeout(fetchCycleTimer.current);
}
fetchCycleTimer.current = setTimeout(
() => enqueueFetchAll(fetchCycleKey),
FETCH_CYCLE_DEBOUNCE_MS,
);
return (): void => {
if (fetchCycleTimer.current) {
clearTimeout(fetchCycleTimer.current);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboardId, orderKey, minTime, maxTime]);
}, [dashboardId, fetchCycleKey]);
const setSelection = useCallback(
(name: string, next: VariableSelection): void => {

View File

@@ -4,8 +4,6 @@ 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
@@ -242,17 +240,3 @@ export function deriveFetchContext(
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,4 +1,3 @@
import { QueryParams } from 'constants/query';
import { parseAsJson } from 'nuqs';
import type { SelectedVariableValue } from './selectionTypes';
@@ -14,21 +13,3 @@ export const variablesUrlParser = parseAsJson<
? (v as Record<string, SelectedVariableValue>)
: null,
);
/**
* Extends a search string with the current `?variables=` param (unchanged when
* absent), so the dashboard ↔ editor handoff keeps the selection in the URL and
* it survives a refresh (V1 parity).
*/
export function withVariablesSearch(
base: string,
currentSearch: string,
): string {
const value = new URLSearchParams(currentSearch).get(QueryParams.variables);
if (!value) {
return base;
}
const params = new URLSearchParams(base);
params.set(QueryParams.variables, value);
return `?${params.toString()}`;
}

View File

@@ -1,12 +1,11 @@
import { useCallback, useState } from 'react';
import { generatePath, useLocation } from 'react-router-dom';
import { generatePath } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { newPanelSearch, NEW_PANEL_ID } from '../PanelEditor/newPanelRoute';
import type { PanelKind } from '../Panels/types/panelKind';
import { useDashboardStore } from '../store/useDashboardStore';
import { withVariablesSearch } from '../VariablesBar/variablesUrlState';
interface UseCreatePanelResult {
isPickerOpen: boolean;
@@ -26,7 +25,6 @@ interface UseCreatePanelResult {
*/
export function useCreatePanel(): UseCreatePanelResult {
const { safeNavigate } = useSafeNavigate();
const { search } = useLocation();
const dashboardId = useDashboardStore((s) => s.dashboardId);
const [isPickerOpen, setIsPickerOpen] = useState(false);
@@ -50,11 +48,10 @@ export function useCreatePanel(): UseCreatePanelResult {
panelId: NEW_PANEL_ID,
});
const target = targetIndex ?? layoutIndex;
safeNavigate(
`${path}${withVariablesSearch(newPanelSearch(panelKind, target), search)}`,
);
// Variable selection is read from the persisted store, not the URL.
safeNavigate(`${path}${newPanelSearch(panelKind, target)}`);
},
[safeNavigate, dashboardId, layoutIndex, search],
[safeNavigate, dashboardId, layoutIndex],
);
return {

View File

@@ -57,5 +57,8 @@ export function useGetQueryRangeV5({
retry: retryUnlessClientError,
keepPreviousData,
cacheTime,
// A resolved window is immutable per key, so a panel scrolled back into view
// serves cache instead of refetching; a key change or manual refetch still runs.
staleTime: Infinity,
});
}

View File

@@ -1,23 +1,51 @@
import { isResolved } from '../VariablesBar/selectionUtils';
import { hasUsableValue } from '../VariablesBar/selectionUtils';
import { VariableFetchState } from '../store/slices/variableFetchSlice';
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`.
* Whether a panel should stay loading because a QUERY/DYNAMIC variable it references
* isn't ready to substitute. A concrete pick (not ALL) and a DYNAMIC ALL are ready
* immediately; an unselected value or a QUERY/CUSTOM ALL waits while it's still
* resolving, then until it settles with a value — so a panel on a chain holds until
* the last variable it depends on resolves. A fetch error or a settled-empty variable
* releases it (no value is coming — render rather than hang).
*/
export function useIsPanelWaitingOnVariable(names: string[]): boolean {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const states = useDashboardStore((s) => s.variableFetchStates);
const variableTypes = useDashboardStore(
(s) => s.variableFetchContext?.variableTypes,
);
const fetchStates = useDashboardStore((s) => s.variableFetchStates);
const resolvedEmpty = useDashboardStore((s) => s.variableResolvedEmpty);
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;
const type = variableTypes?.[name];
if (type !== 'QUERY' && type !== 'DYNAMIC') {
return false;
}
const value = selection[name];
// A concrete pick is authoritative; a DYNAMIC ALL is the stable `__all__`
// sentinel — both ready without waiting.
if (value && !value.allSelected && hasUsableValue(value, type)) {
return false;
}
if (type === 'DYNAMIC' && value?.allSelected) {
return false;
}
// Unselected, or a QUERY/CUSTOM ALL whose array the fetch produces: wait while
// resolving, then until it settles with a usable value.
const state = fetchStates[name];
if (
state === VariableFetchState.Waiting ||
state === VariableFetchState.Loading
) {
return true;
}
if (hasUsableValue(value, type)) {
return false;
}
return state !== VariableFetchState.Error && !resolvedEmpty[name];
});
}

View File

@@ -1,17 +1,16 @@
import { useCallback } from 'react';
import { generatePath, useLocation } from 'react-router-dom';
import { generatePath } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import type { PanelEditorHandoffState } from '../PanelEditor/panelEditorHandoff';
import { useDashboardStore } from '../store/useDashboardStore';
import { withVariablesSearch } from '../VariablesBar/variablesUrlState';
/**
* Returns a callback that opens the V2 panel editor by navigating to its full-page route
* (`/dashboard/:dashboardId/panel/:panelId`). The dashboard id comes from the store, so any
* caller can open the editor with just the panel id. The `?variables=` selection is carried
* along (V1 parity) so it survives a refresh of the editor. The optional `handoffState` is
* caller can open the editor with just the panel id. Variable selection is read from the
* persisted store (localStorage), not carried in the URL. The optional `handoffState` is
* passed as router location state — the View modal uses it to hand its drilldown-edited spec
* off to the editor (view → edit) so the editor opens on those edits rather than the saved
* panel.
@@ -21,19 +20,15 @@ export function useOpenPanelEditor(): (
handoffState?: PanelEditorHandoffState,
) => void {
const { safeNavigate } = useSafeNavigate();
const { search } = useLocation();
const dashboardId = useDashboardStore((s) => s.dashboardId);
return useCallback(
(panelId: string, handoffState?: PanelEditorHandoffState): void => {
safeNavigate(
`${generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, {
dashboardId,
panelId,
})}${withVariablesSearch('', search)}`,
generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, { dashboardId, panelId }),
handoffState ? { state: handoffState } : undefined,
);
},
[safeNavigate, dashboardId, search],
[safeNavigate, dashboardId],
);
}

View File

@@ -19,7 +19,10 @@ import {
} from '../queryV5/buildQueryRangeRequest';
import type { PanelPagination, PanelQueryData } from '../queryV5/types';
import { getRawResults } from '../queryV5/v5ResponseData';
import { getReferencedVariables } from '../queryV5/getReferencedVariables';
import {
getReferencedVariables,
queryReferencesAnyVariable,
} 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';
@@ -57,9 +60,9 @@ export interface PanelQueryTimeOverride {
export interface UsePanelQueryResult {
/** Raw V5 fetch result — response + the request that produced it. */
data: PanelQueryData;
/** First fetch only (no cached data yet) — drives the full-panel loader. A background refetch does NOT set this; use `isFetching`. */
/** First fetch only (no cached data yet), OR waiting on an unresolved referenced variable — drives the full-panel loader. A background refetch does NOT set this; use `isFetching`. */
isLoading: boolean;
/** Any request in flight, including a background refetch over stale data — drives a "refreshing" affordance, never a blank panel. */
/** Any request in flight (including a background refetch over stale data), OR waiting on an unresolved referenced variable — drives the loader / "refreshing" affordance, never a blank panel. */
isFetching: boolean;
/** Showing a prior page's data (keepPreviousData) while the next page loads — list renderers swap in skeleton rows. */
isPreviousData: boolean;
@@ -131,6 +134,13 @@ export function usePanelQuery({
return getReferencedVariables(queries, allNames);
}, [queries, fetchContext]);
// Detected without the fetch context, so the gate below can hold even before it
// initializes.
const hasVariableReference = useMemo(
() => queryReferencesAnyVariable(queries),
[queries],
);
const scopedVariables = useMemo(() => {
const scoped: typeof variables = {};
referencedVariableNames.forEach((name) => {
@@ -141,11 +151,11 @@ export function usePanelQuery({
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,
);
// Hold until referenced variables resolve; also hold before the context is ready
// (we can't yet know which variables to substitute, so firing would drop `$var`s).
const isWaitingOnVariable =
useIsPanelWaitingOnVariable(referencedVariableNames) ||
(hasVariableReference && !fetchContext);
// `visualization` exists only on variants that declare it — read via `in` narrowing over the
// generated union (no cast). `fillSpans` (TimeSeries/Bar only) → formatOptions.fillGaps.
@@ -309,8 +319,10 @@ export function usePanelQuery({
return {
data,
isLoading: response.isLoading,
isFetching: response.isFetching,
// A disabled (waiting-on-variable) query reports neither loading nor fetching, so
// fold the wait in — else the panel body falls through to "No data" mid-load.
isLoading: isWaitingOnVariable || response.isLoading,
isFetching: isWaitingOnVariable || response.isFetching,
isPreviousData: response.isPreviousData,
error: response.error ?? null,
refetch: response.refetch,

View File

@@ -33,6 +33,11 @@ function DashboardContainer({
document.title = name;
}, [name]);
// Store is app-level and outlives the page: clear transient variable fetch state on
// unmount so the next visit doesn't inherit stale states / climbing cycle ids.
const resetVariableFetch = useDashboardStore((s) => s.resetVariableFetch);
useEffect(() => resetVariableFetch, [resetVariableFetch]);
const fullScreenHandle = useFullScreenHandle();
const { isLocked, canEditDashboard } = useDashboardEditGuard(dashboard);

View File

@@ -0,0 +1,69 @@
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import {
getReferencedVariables,
queryReferencesAnyVariable,
} from '../getReferencedVariables';
// Test fixtures are cast at the outer boundary; the perses-generated query
// plugin unions are too verbose to construct field-typed inline.
function clickhouseQuery(query: string): DashboardtypesQueryDTO[] {
return [
{
kind: 'ScalarQuery',
spec: {
plugin: {
kind: 'signoz/CompositeQuery',
spec: {
queries: [{ type: 'clickhouse_sql', spec: { name: 'A', query } }],
},
},
},
},
] as unknown as DashboardtypesQueryDTO[];
}
describe('getReferencedVariables', () => {
it('returns only the variables the query references', () => {
const queries = clickhouseQuery(
'SELECT count() FROM t WHERE service = $service.name',
);
expect(
getReferencedVariables(queries, [
'service.name',
'deployment.environment',
'dyn_service',
]),
).toStrictEqual(['service.name']);
});
it('returns empty when no known name matches', () => {
const queries = clickhouseQuery('SELECT 1');
expect(getReferencedVariables(queries, ['service.name'])).toStrictEqual([]);
});
});
describe('queryReferencesAnyVariable', () => {
it('is true when the query references a variable, even with no known names', () => {
const queries = clickhouseQuery(
'SELECT count() FROM t WHERE service = $service.name',
);
expect(queryReferencesAnyVariable(queries)).toBe(true);
});
it('is false for a query with no variable reference', () => {
expect(queryReferencesAnyVariable(clickhouseQuery('SELECT 1'))).toBe(false);
});
it('does not treat $__ macros as variable references', () => {
expect(
queryReferencesAnyVariable(
clickhouseQuery('SELECT toStartOfInterval(ts, INTERVAL $__interval)'),
),
).toBe(false);
});
it('is false for an empty query list', () => {
expect(queryReferencesAnyVariable([])).toBe(false);
});
});

View File

@@ -8,8 +8,8 @@ import type {
VariableFormModel,
VariableType,
} from '../DashboardSettings/Variables/variableFormModel';
import { configuredDefaultValue } from '../VariablesBar/resolveVariableSelection';
import type {
SelectedVariableValue,
VariableSelection,
VariableSelectionMap,
} from '../VariablesBar/selectionTypes';
@@ -33,21 +33,6 @@ const VARIABLE_TYPE_TO_DTO: Record<
DYNAMIC: Querybuildertypesv5VariableTypeDTO.dynamic,
};
/** The variable's configured default, used when nothing is selected yet. */
function configuredDefault(
definition: VariableFormModel,
): SelectedVariableValue | undefined {
if (definition.type === 'TEXT') {
return definition.textValue || undefined;
}
// `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;
}
/**
* Resolves the wire value for one variable: the dynamic "ALL" sentinel, else the
* user's selection, else the configured default. Returns `undefined` when there
@@ -74,7 +59,7 @@ function resolveValue(
return selected as Querybuildertypesv5VariableItemDTOValue;
}
const fallback = configuredDefault(definition);
const fallback = configuredDefaultValue(definition);
return fallback == null
? undefined
: (fallback as Querybuildertypesv5VariableItemDTOValue);

View File

@@ -1,5 +1,8 @@
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
import {
containsAnyVariableReference,
textContainsVariableReference,
} from 'lib/dashboardVariables/variableReference';
import { toQueryEnvelopes } from './buildQueryRangeRequest';
@@ -45,3 +48,18 @@ export function getReferencedVariables(
texts.some((text) => textContainsVariableReference(text, name)),
);
}
/**
* Whether a panel's queries reference *any* variable, independent of the known
* variable set. Used to hold the panel until the variable fetch context is ready:
* before then the variable names aren't known, so firing would substitute nothing
* (dropping every `$var`) and the query would fail.
*/
export function queryReferencesAnyVariable(
queries: DashboardtypesQueryDTO[],
): boolean {
if (queries.length === 0) {
return false;
}
return extractQueryTexts(queries).some(containsAnyVariableReference);
}

View File

@@ -12,14 +12,25 @@ function model(overrides: Partial<VariableFormModel>): VariableFormModel {
return { ...emptyVariableFormModel(), ...overrides };
}
const DASH = 'test-dash';
function store(): ReturnType<typeof useDashboardStore.getState> {
return useDashboardStore.getState();
}
function states(): Record<string, string> {
return store().variableFetchStates;
}
/** Commit a value for a variable (what a parent must have before a child fetches). */
function resolve(name: string): void {
store().setVariableValue(DASH, name, {
value: `${name}-v`,
allSelected: false,
});
}
function reset(names: string[], context: VariableFetchContext): void {
useDashboardStore.setState({
dashboardId: DASH,
variableValues: {},
variableFetchStates: {},
variableLastUpdated: {},
variableCycleIds: {},
@@ -47,28 +58,37 @@ describe('variableFetchSlice', () => {
});
});
it('enqueueFetchAll loads roots, waits dependents and (ungated) dynamics', () => {
store().enqueueFetchAll(false);
it('loads query roots + dynamics immediately and waits query dependents', () => {
store().enqueueFetchAll();
// Dynamics fetch immediately (not gated on the query chain); the query
// dependent q2 waits for its parent q1.
expect(states()).toMatchObject({
q1: 'loading',
q2: 'waiting',
d1: 'waiting',
d2: 'waiting',
d1: 'loading',
d2: 'loading',
});
});
it('enqueueFetchAll loads dynamics immediately when query values exist', () => {
store().enqueueFetchAll(true);
expect(states().d1).toBe('loading');
it('a completed parent alone does not unblock the child; its committed value does', () => {
store().enqueueFetchAll();
store().onVariableFetchComplete('q1');
// q1 finished fetching but has not auto-selected a value yet, so q2 holds
// rather than fetching with q1 unresolved. Dynamics load regardless.
expect(states()).toMatchObject({ q1: 'idle', q2: 'waiting', d1: 'loading' });
// q1's value commits → the value cascade unblocks q2.
resolve('q1');
store().enqueueDescendants('q1');
expect(states().q2).not.toBe('waiting');
});
it('completing a parent unblocks its query child, then unlocks dynamics', () => {
store().enqueueFetchAll(false);
it('unblocks a query child immediately when its parent already has a value', () => {
// Persisted/pre-seeded selection: q1 has a value before it even fetches, so
// completing its fetch unblocks q2 straight away (a single fetch, no cascade).
resolve('q1');
store().enqueueFetchAll();
store().onVariableFetchComplete('q1');
expect(states()).toMatchObject({ q1: 'idle', q2: 'loading', d1: 'waiting' });
store().onVariableFetchComplete('q2');
expect(states()).toMatchObject({ q2: 'idle', d1: 'loading', d2: 'loading' });
expect(states().q2).not.toBe('waiting');
});
it('ignores a settle for a variable that is not actively fetching', () => {
@@ -79,8 +99,14 @@ describe('variableFetchSlice', () => {
});
it('changing a query variable revalidates query descendants but NOT dynamics', () => {
store().enqueueFetchAll(true);
['q1', 'q2', 'd1', 'd2'].forEach((n) => store().onVariableFetchComplete(n));
// Drive the chain to a fully settled state: q1 fetched + valued, q2 fetched.
store().enqueueFetchAll();
store().onVariableFetchComplete('q1');
resolve('q1');
store().enqueueDescendants('q1');
store().onVariableFetchComplete('q2');
resolve('q2');
['d1', 'd2'].forEach((n) => store().onVariableFetchComplete(n));
const before = { ...store().variableCycleIds };
store().enqueueDescendants('q1');
@@ -91,7 +117,7 @@ describe('variableFetchSlice', () => {
});
it('changing a dynamic refreshes the OTHER dynamics, never itself or query vars', () => {
store().enqueueFetchAll(true);
store().enqueueFetchAll();
['q1', 'q2', 'd1', 'd2'].forEach((n) => store().onVariableFetchComplete(n));
const before = { ...store().variableCycleIds };
@@ -102,13 +128,29 @@ describe('variableFetchSlice', () => {
});
it('a failed parent idles its query descendants', () => {
store().enqueueFetchAll(false);
store().enqueueFetchAll();
store().onVariableFetchFailure('q1');
expect(states().q1).toBe('error');
expect(states().q2).toBe('idle');
});
});
describe('variableFetchSlice — query depends on a dynamic', () => {
// qd (query) references $dyn (a dynamic variable).
const dyn = model({ name: 'dyn', type: 'DYNAMIC', dynamicAttribute: 'pod' });
const qd = model({ name: 'qd', type: 'QUERY', queryValue: 'SELECT $dyn' });
const context = deriveFetchContext([dyn, qd]);
beforeEach(() => reset(['dyn', 'qd'], context));
it('does not wait for a dynamic parent — both load immediately', () => {
store().enqueueFetchAll();
// A dynamic's selected value is already in the selection, so the dependent
// query never waits on the dynamic's option fetch; both start together.
expect(states()).toMatchObject({ dyn: 'loading', qd: 'loading' });
});
});
describe('variableFetchSlice — diamond dependencies', () => {
// qA, qB (roots) → qC (references both $qA and $qB).
const qA = model({ name: 'qA', type: 'QUERY', queryValue: 'SELECT 1' });
@@ -118,15 +160,19 @@ describe('variableFetchSlice — diamond dependencies', () => {
beforeEach(() => reset(['qA', 'qB', 'qC'], context));
it('unblocks the child only once BOTH parents are settled', () => {
store().enqueueFetchAll(false);
it('unblocks the child only once BOTH parents have committed values', () => {
store().enqueueFetchAll();
expect(states().qC).toBe('waiting');
store().onVariableFetchComplete('qA');
expect(states().qC).toBe('waiting'); // qB still loading
resolve('qA');
store().enqueueDescendants('qA');
expect(states().qC).toBe('waiting'); // qB has no value yet
store().onVariableFetchComplete('qB');
expect(states().qC).not.toBe('waiting'); // both settled → fetches
resolve('qB');
store().enqueueDescendants('qB');
expect(states().qC).not.toBe('waiting'); // both valued → fetches
});
});
@@ -139,7 +185,7 @@ describe('variableFetchSlice — dependency cycle', () => {
beforeEach(() => reset(['qX', 'qY'], context));
it('enqueues cyclic query variables as best-effort roots (not silently idle)', () => {
store().enqueueFetchAll(false);
store().enqueueFetchAll();
expect(states().qX).not.toBe('idle');
expect(states().qY).not.toBe('idle');
});

View File

@@ -1,23 +1,42 @@
import type { StateCreator } from 'zustand';
import { hasUsableValue } from '../../VariablesBar/selectionUtils';
import type { VariableSelectionMap } from '../../VariablesBar/selectionTypes';
import type { VariableFetchContext } from '../../VariablesBar/variableDependencies';
import type { DashboardStore } from '../useDashboardStore';
import { selectVariableValues } from './variableSelectionSlice';
import {
areAllQueryVariablesSettled,
type FetchMaps,
isSettled,
isVariableInActiveFetchState,
resolveFetchState,
unlockWaitingDynamicVariables,
VariableFetchState,
} from './variableFetchSlice.utils';
/**
* Whether every QUERY parent of `name` holds a committed value. Gating a child on its
* parents' *values* (not their settled fetch state) makes it fetch once, after the
* values commit — not prematurely on fetch-complete and again on value-commit.
*/
function queryParentsHaveValues(
name: string,
context: VariableFetchContext,
selection: VariableSelectionMap,
): boolean {
const parents = context.dependencyData.parentGraph[name] || [];
return parents.every(
(p) =>
context.variableTypes[p] !== 'QUERY' ||
hasUsableValue(selection[p], context.variableTypes[p]),
);
}
export { VariableFetchState } from './variableFetchSlice.utils';
/**
* Runtime fetch orchestration for dashboard variables — native port of V1's
* `variableFetchStore`. Decides WHEN each variable's options fetch: query
* variables in dependency order, dynamics together once query values exist,
* variables in dependency order, dynamics immediately (they are scoped only by
* sibling dynamic selections, never by query variables, so nothing gates them),
* text/custom never. `cycleIds` is a per-variable request nonce keyed into each
* selector's react-query key (bump = fresh fetch, auto-cancel stale). Transient.
* `enqueueFetchAll` = load/time change; `enqueueDescendants` = one value changed.
@@ -26,13 +45,35 @@ export interface VariableFetchSlice {
variableFetchStates: Record<string, VariableFetchState>;
variableLastUpdated: Record<string, number>;
variableCycleIds: Record<string, number>;
/**
* Whether a QUERY/DYNAMIC variable settled its fetch with zero options (so it
* will never get a value). Lets a dependent panel fall through to "no data"
* instead of waiting forever on a value that isn't coming.
*/
variableResolvedEmpty: Record<string, boolean>;
/** Static dependency context, set by `initVariableFetch` (null before init). */
variableFetchContext: VariableFetchContext | null;
/**
* Signature (dashboard + time + variable order) of the last full fetch cycle.
* A repeat `enqueueFetchAll` with the same signature is skipped, so a component
* re-mount can't redo the cycle and double every variable's fetch.
*/
lastFetchAllKey: string | null;
/** Seed state entries for the current variable set and store the context. */
initVariableFetch: (names: string[], context: VariableFetchContext) => void;
/** Start a full fetch cycle for every fetchable variable (load / time change). */
enqueueFetchAll: (doAllQueryVariablesHaveValuesSelected: boolean) => void;
/**
* Clear all transient fetch state on dashboard-page unmount, so a later visit
* starts clean instead of inheriting stale state from this app-level store.
*/
resetVariableFetch: () => void;
/** Record whether a variable settled with no options (drives the panel gate). */
setVariableResolvedEmpty: (name: string, isEmpty: boolean) => void;
/**
* Start a full fetch cycle for every fetchable variable (load / time change).
* A repeat call with the same signature `key` is a no-op (idempotent re-mount).
*/
enqueueFetchAll: (key?: string) => void;
/** Mark a variable's fetch as done; unblock its waiting children / dynamics. */
onVariableFetchComplete: (name: string) => void;
/** Mark a variable's fetch as failed; idle its query descendants. */
@@ -65,10 +106,32 @@ export const createVariableFetchSlice: StateCreator<
variableFetchStates: {},
variableLastUpdated: {},
variableCycleIds: {},
variableResolvedEmpty: {},
variableFetchContext: null,
lastFetchAllKey: null,
resetVariableFetch: (): void => {
set({
variableFetchStates: {},
variableLastUpdated: {},
variableCycleIds: {},
variableResolvedEmpty: {},
variableFetchContext: null,
lastFetchAllKey: null,
});
},
setVariableResolvedEmpty: (name, isEmpty): void => {
const current = get().variableResolvedEmpty;
if ((current[name] ?? false) === isEmpty) {
return;
}
set({ variableResolvedEmpty: { ...current, [name]: isEmpty } });
},
initVariableFetch: (names, context): void => {
const maps = cloneMaps(get());
const resolvedEmpty = { ...get().variableResolvedEmpty };
// Initialize new variables to idle, preserving existing states.
names.forEach((name) => {
if (!maps.states[name]) {
@@ -82,17 +145,23 @@ export const createVariableFetchSlice: StateCreator<
delete maps.states[name];
delete maps.lastUpdated[name];
delete maps.cycleIds[name];
delete resolvedEmpty[name];
}
});
set({
variableFetchStates: maps.states,
variableLastUpdated: maps.lastUpdated,
variableCycleIds: maps.cycleIds,
variableResolvedEmpty: resolvedEmpty,
variableFetchContext: context,
});
},
enqueueFetchAll: (doAllQueryVariablesHaveValuesSelected): void => {
enqueueFetchAll: (key): void => {
// Skip a redundant re-run (re-mount with identical inputs) — else it doubles.
if (key && key === get().lastFetchAllKey) {
return;
}
const { variableFetchContext } = get();
if (!variableFetchContext) {
return;
@@ -105,7 +174,11 @@ export const createVariableFetchSlice: StateCreator<
} = variableFetchContext;
const maps = cloneMaps(get());
// Query variables: roots start immediately, dependents wait for parents.
// Query variables wait only for their QUERY parents. A DYNAMIC parent does not
// gate: its option fetch feeds only its own dropdown, while its selected value
// (ALL → `__all__`, or a concrete pick) is already in the selection, so a
// dependent query substitutes it immediately and refetches via the cascade if
// it later changes. Text/custom parents resolve synchronously, so nothing waits.
queryVariableOrder.forEach((name) => {
maps.cycleIds[name] = (maps.cycleIds[name] || 0) + 1;
const parents = dependencyData.parentGraph[name] || [];
@@ -116,8 +189,8 @@ export const createVariableFetchSlice: StateCreator<
});
// Query variables dropped from the dependency order (part of a cycle) would
// otherwise never fetch and would stall waiting dynamics — start them as
// best-effort roots so they surface data/an error instead of sitting empty.
// otherwise never fetch — start them as best-effort roots so they surface
// data/an error instead of sitting empty.
const orderedQuery = new Set(queryVariableOrder);
Object.keys(variableTypes).forEach((name) => {
if (variableTypes[name] === 'QUERY' && !orderedQuery.has(name)) {
@@ -126,19 +199,21 @@ export const createVariableFetchSlice: StateCreator<
}
});
// Dynamic variables: start now if query variables already have values,
// otherwise wait until the query variables settle.
// Dynamic variables fetch immediately, in parallel with the query variables:
// their options are scoped only by sibling dynamic selections (never by query
// variables), so there is nothing to wait for. Starting early lets them
// populate fast even when query variables are slow; a sibling selection change
// later refetches them via `enqueueDescendantsBatch`.
dynamicVariableOrder.forEach((name) => {
maps.cycleIds[name] = (maps.cycleIds[name] || 0) + 1;
maps.states[name] = doAllQueryVariablesHaveValuesSelected
? resolveFetchState(maps, name)
: VariableFetchState.Waiting;
maps.states[name] = resolveFetchState(maps, name);
});
set({
variableFetchStates: maps.states,
variableLastUpdated: maps.lastUpdated,
variableCycleIds: maps.cycleIds,
lastFetchAllKey: key ?? get().lastFetchAllKey,
});
},
@@ -154,10 +229,11 @@ export const createVariableFetchSlice: StateCreator<
maps.lastUpdated[name] = Date.now();
if (variableFetchContext) {
const { dependencyData, variableTypes, dynamicVariableOrder } =
variableFetchContext;
// Unblock a waiting query child only once ALL its parents are settled —
// otherwise it would fetch against a not-yet-resolved parent.
const { dependencyData, variableTypes } = variableFetchContext;
const selection = selectVariableValues(get().dashboardId)(get());
// Release a waiting child only if its parents are already valued (e.g. a
// persisted selection). For a just-fetched parent whose value hasn't committed
// yet, the value cascade (enqueueDescendantsBatch) unblocks it instead.
(dependencyData.graph[name] || []).forEach((child) => {
if (
variableTypes[child] !== 'QUERY' ||
@@ -165,18 +241,10 @@ export const createVariableFetchSlice: StateCreator<
) {
return;
}
const parents = dependencyData.parentGraph[child] || [];
if (parents.every((p) => isSettled(maps.states[p]))) {
if (queryParentsHaveValues(child, variableFetchContext, selection)) {
maps.states[child] = resolveFetchState(maps, child);
}
});
// Once all query variables settle, unlock any waiting dynamics.
if (
variableTypes[name] === 'QUERY' &&
areAllQueryVariablesSettled(maps.states, variableTypes)
) {
unlockWaitingDynamicVariables(maps, dynamicVariableOrder);
}
}
set({
@@ -195,20 +263,14 @@ export const createVariableFetchSlice: StateCreator<
maps.states[name] = VariableFetchState.Error;
if (variableFetchContext) {
const { dependencyData, variableTypes, dynamicVariableOrder } =
variableFetchContext;
// Query descendants can't proceed without this parent — idle them.
const { dependencyData, variableTypes } = variableFetchContext;
// Idle query descendants only when a QUERY parent fails (they need its
// value); a DYNAMIC failure doesn't block them (they used its selection).
(dependencyData.transitiveDescendants[name] || []).forEach((desc) => {
if (variableTypes[desc] === 'QUERY') {
if (variableTypes[name] === 'QUERY' && variableTypes[desc] === 'QUERY') {
maps.states[desc] = VariableFetchState.Idle;
}
});
if (
variableTypes[name] === 'QUERY' &&
areAllQueryVariablesSettled(maps.states, variableTypes)
) {
unlockWaitingDynamicVariables(maps, dynamicVariableOrder);
}
}
set({
@@ -231,9 +293,11 @@ export const createVariableFetchSlice: StateCreator<
variableFetchContext;
const maps = cloneMaps(get());
const changed = new Set(names);
// Callers commit values before this runs, so the gate sees the new parent values.
const selection = selectVariableValues(get().dashboardId)(get());
// Union of the changed variables' query descendants (never the changed ones
// themselves), refreshed once each: refetch when all parents are settled.
// Query descendants of the changed vars (not the changed ones): fetch once all
// their query parents have a value, else hold until the rest land.
const queryDescendants = new Set<string>();
names.forEach((name) => {
(dependencyData.transitiveDescendants[name] || []).forEach((desc) => {
@@ -244,27 +308,24 @@ export const createVariableFetchSlice: StateCreator<
});
queryDescendants.forEach((desc) => {
maps.cycleIds[desc] = (maps.cycleIds[desc] || 0) + 1;
const parents = dependencyData.parentGraph[desc] || [];
const allParentsSettled = parents.every((p) => isSettled(maps.states[p]));
maps.states[desc] = allParentsSettled
maps.states[desc] = queryParentsHaveValues(
desc,
variableFetchContext,
selection,
)
? resolveFetchState(maps, desc)
: VariableFetchState.Waiting;
});
// A dynamic's options depend only on its sibling DYNAMIC selections, so only a
// dynamic change affects them — refresh the *other* dynamics (never the one
// that changed, which would refetch its own identical options).
// dynamic change affects them — refresh the *other* dynamics immediately
// (never the one that changed, which would refetch its own identical options).
if (names.some((name) => variableTypes[name] === 'DYNAMIC')) {
dynamicVariableOrder
.filter((dynName) => !changed.has(dynName))
.forEach((dynName) => {
maps.cycleIds[dynName] = (maps.cycleIds[dynName] || 0) + 1;
maps.states[dynName] = areAllQueryVariablesSettled(
maps.states,
variableTypes,
)
? resolveFetchState(maps, dynName)
: VariableFetchState.Waiting;
maps.states[dynName] = resolveFetchState(maps, dynName);
});
}

View File

@@ -1,5 +1,3 @@
import type { VariableType } from '../../DashboardSettings/Variables/variableFormModel';
/** Per-variable fetch lifecycle (ported from V1's `variableFetchStore`). */
export enum VariableFetchState {
Idle = 'idle',
@@ -16,11 +14,6 @@ export interface FetchMaps {
cycleIds: Record<string, number>;
}
/** Settled = can make no further progress (idle or error). */
export function isSettled(state: VariableFetchState | undefined): boolean {
return state === VariableFetchState.Idle || state === VariableFetchState.Error;
}
/** Active = a fetch is in flight; only then should a settle be applied. */
export function isVariableInActiveFetchState(
state: VariableFetchState | undefined,
@@ -37,25 +30,3 @@ export function resolveFetchState(
? VariableFetchState.Revalidating
: VariableFetchState.Loading;
}
/** True once every QUERY variable is settled. */
export function areAllQueryVariablesSettled(
states: Record<string, VariableFetchState>,
variableTypes: Record<string, VariableType>,
): boolean {
return Object.entries(variableTypes)
.filter(([, type]) => type === 'QUERY')
.every(([name]) => isSettled(states[name]));
}
/** Move any `waiting` dynamic variables into loading/revalidating. */
export function unlockWaitingDynamicVariables(
maps: FetchMaps,
dynamicVariableOrder: string[],
): void {
dynamicVariableOrder.forEach((dynName) => {
if (maps.states[dynName] === VariableFetchState.Waiting) {
maps.states[dynName] = resolveFetchState(maps, dynName);
}
});
}

View File

@@ -26,7 +26,6 @@ import { useSyncVariablesForSuggestions } from '../DashboardContainer/hooks/useS
import { createDefaultPanel } from '../DashboardContainer/patchOps';
import { useDashboardStore } from '../DashboardContainer/store/useDashboardStore';
import { useSeedVariableSelection } from '../DashboardContainer/VariablesBar/useSeedVariableSelection';
import { withVariablesSearch } from '../DashboardContainer/VariablesBar/variablesUrlState';
import styles from './PanelEditorPage.module.scss';
/**
@@ -96,15 +95,10 @@ function PanelEditorPage(): JSX.Element {
const layoutIndex = parseNewPanelLayoutIndex(search);
const backToDashboard = useCallback((): void => {
// Carry only dashboard params; drop editor-only URL state (chiefly
// `compositeQuery`) so it doesn't leak into the dashboard. Time lives in Redux.
safeNavigate(
`${generatePath(ROUTES.DASHBOARD, { dashboardId })}${withVariablesSearch(
'',
search,
)}`,
);
}, [safeNavigate, dashboardId, search]);
// Drop editor-only URL state (chiefly `compositeQuery`); the dashboard reads its
// variable selection from the persisted store, and time lives in Redux.
safeNavigate(generatePath(ROUTES.DASHBOARD, { dashboardId }));
}, [safeNavigate, dashboardId]);
if (isLoading) {
return <Spinner tip="Loading dashboard..." />;

View File

@@ -10,7 +10,6 @@ import { useSelector } from 'react-redux';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters';
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
@@ -116,9 +115,6 @@ function TimeSeriesViewContainer({
return (
<div className="trace-explorer-time-series-view-container">
<div className="trace-explorer-time-series-view-container-header">
<BuilderUnitsFilter onChange={onUnitChange} yAxisUnit={yAxisUnit} />
</div>
<TimeSeriesView
isFilterApplied={isFilterApplied}
isError={isError}
@@ -126,8 +122,10 @@ function TimeSeriesViewContainer({
isLoading={isLoading || isFetching}
data={responseData}
yAxisUnit={yAxisUnit}
onYAxisUnitChange={onUnitChange}
dataSource={dataSource}
setWarning={setWarning}
allowExport
/>
</div>
);

View File

@@ -107,6 +107,9 @@ export function ContextMenu({
}
}}
trigger="click"
// Anchor to body (like the backdrop), not the host container: a modal's
// transformed dialog would break `position: fixed` and trap the menu below it.
getPopupContainer={(): HTMLElement => document.body}
overlayStyle={{
position: 'fixed',
left: position.left,

View File

@@ -77,6 +77,11 @@
color: var(--muted-foreground);
}
// Body-portaled overlay: stay clickable when a modal sets `body { pointer-events: none }`.
.context-menu {
pointer-events: auto;
}
.context-menu .ant-popover-inner {
padding: 0;
border-radius: 6px;

View File

@@ -8,7 +8,11 @@ import {
IClickHouseQuery,
IPromQLQuery,
} from '../queryBuilder/queryBuilderData';
import { ExecStats, QueryRangeRequestV5 } from '../v5/queryRange';
import {
ExecStats,
QueryRangeRequestV5,
QueryRangeResponseV5,
} from '../v5/queryRange';
import { QueryData, QueryDataV3 } from '../widgets/getQuery';
export type QueryRangePayload = {
@@ -48,6 +52,9 @@ export interface MetricQueryRangeSuccessResponse extends SuccessResponse<
> {
warning?: Warning;
meta?: ExecStats;
// Raw V5 response (pre-legacy-conversion) + per-query legend map, for client-side export.
rawV5Response?: QueryRangeResponseV5;
legendMap?: Record<string, string>;
}
export interface MetricRangePayloadV3 {

2
go.mod
View File

@@ -83,7 +83,7 @@ require (
go.uber.org/zap v1.27.1
golang.org/x/crypto v0.52.0
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90
golang.org/x/net v0.54.0
golang.org/x/net v0.55.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.20.0
golang.org/x/text v0.37.0

4
go.sum
View File

@@ -1488,8 +1488,8 @@ golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su
golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=

View File

@@ -20,21 +20,21 @@ func (c Compiled) IsEmpty() bool {
// Compile always returns a non-nil *Compiled. An empty query (or one that
// produces no SQL) yields a Compiled with an empty SQL — callers gate on
// SQL != "" rather than a nil check.
//
// A `key OP value` term compiles to a DSL predicate; a bare word is a
// case-insensitive substring search over the dashboard name, description, and tag
// keys/values. They compose through AND/OR/NOT, so `prod payment` matches both
// words (implicit AND) and `prod OR name = 'x'` mixes free text with a filter. A
// quoted token matches literally, e.g. `"prod payment"`.
func Compile(query string, formatter sqlstore.SQLFormatter) (*Compiled, error) {
if len(query) == 0 {
if len(strings.TrimSpace(query)) == 0 {
return &Compiled{}, nil
}
queryVisitor := newVisitor(formatter)
sql, args, syntaxErrs := queryVisitor.compile(query)
if len(syntaxErrs) > 0 {
sql, args, errs := newVisitor(formatter).compile(query)
if len(errs) > 0 {
return nil, errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardListFilterInvalid,
"invalid filter query: %s", strings.Join(syntaxErrs, "; "))
}
if len(queryVisitor.errors) > 0 {
return nil, errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardListFilterInvalid,
"invalid filter query: %s", strings.Join(queryVisitor.errors, "; "))
"invalid filter query: %s", strings.Join(errs, "; "))
}
return &Compiled{

View File

@@ -460,6 +460,83 @@ func TestCompile_ComplexExamples(t *testing.T) {
})
}
func TestCompile_FreeText(t *testing.T) {
// freeTextSQL is the predicate every free-text query compiles to; only the
// bound pattern differs.
freeTextSQL := `
(
lower(COALESCE(json_extract("dashboard"."data", '$.spec.display.name'), '')) LIKE LOWER(?) ESCAPE '\'
OR lower(COALESCE(json_extract("dashboard"."data", '$.spec.display.description'), '')) LIKE LOWER(?) ESCAPE '\'
OR EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = ? AND tr.resource_id = dashboard.id
AND (lower(COALESCE(t.key, '')) LIKE LOWER(?) ESCAPE '\' OR lower(COALESCE(t.value, '')) LIKE LOWER(?) ESCAPE '\')
))`
freeTextArgs := func(pattern string) []any {
return []any{pattern, pattern, kindArg, pattern, pattern}
}
runCompileCases(t, []compileCase{
{
subtestName: "single bare word",
dslQueryToCompile: `payment`,
expectedSQL: freeTextSQL,
expectedArgs: freeTextArgs("%payment%"),
},
{
// consecutive words are implicit-AND per the grammar, so each is its
// own term; `"prod payment"` (below) is the way to match the phrase
subtestName: "words are separate terms AND'd together",
dslQueryToCompile: `prod payment`,
expectedSQL: "(" + freeTextSQL + " AND " + freeTextSQL + ")",
expectedArgs: append(freeTextArgs("%prod%"), freeTextArgs("%payment%")...),
},
{
subtestName: "a quoted token matches the whole phrase",
dslQueryToCompile: `"prod payment"`,
expectedSQL: freeTextSQL,
expectedArgs: freeTextArgs("%prod payment%"),
},
{
subtestName: "quoting is the escape hatch for a DSL-like literal",
dslQueryToCompile: `"team = prod"`,
expectedSQL: freeTextSQL,
expectedArgs: freeTextArgs("%team = prod%"),
},
{
subtestName: "LIKE wildcards in the term are escaped to match literally",
dslQueryToCompile: `"50%"`,
expectedSQL: freeTextSQL,
expectedArgs: freeTextArgs(`%50\%%`),
},
{
subtestName: "surrounding whitespace is trimmed",
dslQueryToCompile: ` payment `,
expectedSQL: freeTextSQL,
expectedArgs: freeTextArgs("%payment%"),
},
{
subtestName: "free-text term composes with a comparison via AND",
dslQueryToCompile: `prod AND name CONTAINS 'signoz'`,
expectedSQL: "(" + freeTextSQL + ` AND json_extract("dashboard"."data", '$.spec.display.name') LIKE ? ESCAPE '\')`,
expectedArgs: append(freeTextArgs("%prod%"), "%signoz%"),
},
{
subtestName: "free-text words compose with a comparison via OR",
dslQueryToCompile: `prod payment OR name = 'x'`,
expectedSQL: "((" + freeTextSQL + " AND " + freeTextSQL + `) OR json_extract("dashboard"."data", '$.spec.display.name') = ?)`,
expectedArgs: append(append(freeTextArgs("%prod%"), freeTextArgs("%payment%")...), "x"),
},
{
subtestName: "NOT negates a free-text term",
dslQueryToCompile: `NOT payment`,
expectedSQL: "NOT (" + freeTextSQL + ")",
expectedArgs: freeTextArgs("%payment%"),
},
})
}
func TestCompile_Rejections(t *testing.T) {
runCompileCases(t, []compileCase{
{

View File

@@ -32,13 +32,19 @@ func newVisitor(formatter sqlstore.SQLFormatter) *visitor {
}
}
// compile turns the parse tree into `?`-placeholder WHERE SQL + arguments for bun.
// compile builds `?`-placeholder WHERE SQL + args for bun. Each term is either a
// `key OP value` comparison or a bare token that becomes a free-text search; the
// two compose through the boolean grammar (AND/OR/NOT). Malformed input is
// returned as errors.
func (v *visitor) compile(query string) (string, []any, []string) {
tree, _, collector := filterquery.Parse(query)
if len(collector.Errors) > 0 {
return "", nil, collector.Errors
}
condition, _ := v.visit(tree).(string)
if len(v.errors) > 0 {
return "", nil, v.errors
}
if condition == "" {
return "", nil, nil
}
@@ -119,10 +125,10 @@ func (v *visitor) VisitPrimary(ctx *grammar.PrimaryContext) any {
if ctx.Comparison() != nil {
return v.visit(ctx.Comparison())
}
// Bare keys, values, full text, and function calls are not part of the
// dashboard list DSL.
v.addError("unsupported expression %q — every term must be of the form `key OP value`", ctx.GetText())
return ""
// A lone key/value/full-text token is a free-text term, composed with any
// comparisons through the boolean grammar. A quoted token matches its contents
// literally — the escape hatch for a phrase or a term that looks like DSL.
return v.buildFreeTextTerm(trimQuotes(ctx.GetText()))
}
// VisitComparison dispatches a single `key OP value` term. A key that matches
@@ -401,6 +407,50 @@ func buildSubqueryForTagKeyAndValue(subqueryBuilder *sqlbuilder.SelectBuilder, t
return buildSubqueryForTagKey(subqueryBuilder, tagKey).Where(valuePredicate)
}
// ─── free-text search ────────────────────────────────────────────────────────
// buildFreeTextTerm matches value as a case-insensitive substring of the
// dashboard name, description, or any tag key/value.
func (v *visitor) buildFreeTextTerm(value string) string {
nameColumn := string(v.formatter.JSONExtractString("dashboard.data", "$.spec.display.name"))
descriptionColumn := string(v.formatter.JSONExtractString("dashboard.data", "$.spec.display.description"))
namePredicate := v.buildFreeTextContains(v.selectBuilder, nameColumn, value)
descriptionPredicate := v.buildFreeTextContains(v.selectBuilder, descriptionColumn, value)
subqueryBuilder := sqlbuilder.NewSelectBuilder()
keyPredicate := v.buildFreeTextContains(subqueryBuilder, "t.key", value)
valuePredicate := v.buildFreeTextContains(subqueryBuilder, "t.value", value)
buildSubqueryForFreeTextTag(subqueryBuilder, keyPredicate, valuePredicate)
tagPredicate := v.selectBuilder.Exists(subqueryBuilder)
return v.selectBuilder.Or(namePredicate, descriptionPredicate, tagPredicate)
}
// buildFreeTextContains emits a case-insensitive contains as
// LOWER(COALESCE(col, '')) LIKE LOWER(?), identical on SQLite and Postgres.
// COALESCE keeps a NULL column (an absent description) false rather than NULL —
// otherwise `NOT (…)` goes NULL and drops every description-less dashboard. The
// value's % and _ are escaped, and ESCAPE pins backslash as the escape char.
func (v *visitor) buildFreeTextContains(builder *sqlbuilder.SelectBuilder, columnExpression, value string) string {
lowerColumn := string(v.formatter.LowerExpression("COALESCE(" + columnExpression + ", '')"))
pattern := "%" + v.formatter.EscapeLikePattern(value) + "%"
return fmt.Sprintf("%s LIKE LOWER(%s) ESCAPE '\\'", lowerColumn, builder.Var(pattern))
}
func buildSubqueryForFreeTextTag(subqueryBuilder *sqlbuilder.SelectBuilder, keyPredicate, valuePredicate string) *sqlbuilder.SelectBuilder {
const dashboardTagKind = `"dashboard"`
return subqueryBuilder.
Select("1").
From("tag_relation tr").
Join("tag t", "t.id = tr.tag_id").
Where(
subqueryBuilder.Equal("tr.kind", dashboardTagKind),
"tr.resource_id = dashboard.id",
subqueryBuilder.Or(keyPredicate, valuePredicate),
)
}
// ─── value extraction helpers ───────────────────────────────────────────────
func (v *visitor) addError(format string, arguments ...any) {

View File

@@ -21,6 +21,7 @@ func buildClusterRecords(
nodeConditionCountsMap map[string]nodeConditionCounts,
podPhaseCountsMap map[string]podPhaseCounts,
podStatusCounts map[string]podStatusCounts,
resourceCounts map[string]map[string]int64,
) []inframonitoringtypes.ClusterRecord {
metricsMap := parseFullQueryResponse(resp, groupBy)
@@ -74,6 +75,15 @@ func buildClusterRecords(
record.PodCountsByStatus = podStatusCountsToResponse(podStatusCountsForGroup)
}
if counts, ok := resourceCounts[compositeKey]; ok {
record.Counts.Nodes = counts[inframonitoringtypes.NodeNameAttrKey]
record.Counts.Namespaces = counts[inframonitoringtypes.NamespaceNameAttrKey]
record.Counts.Deployments = counts[inframonitoringtypes.DeploymentNameAttrKey]
record.Counts.DaemonSets = counts[inframonitoringtypes.DaemonSetNameAttrKey]
record.Counts.Jobs = counts[inframonitoringtypes.JobNameAttrKey]
record.Counts.StatefulSets = counts[inframonitoringtypes.StatefulSetNameAttrKey]
}
if attrs, ok := metadataMap[compositeKey]; ok {
for k, v := range attrs {
record.Meta[k] = v

View File

@@ -1,6 +1,8 @@
package implinframonitoring
import (
"slices"
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
@@ -35,6 +37,26 @@ var clusterAttrKeysForMetadata = []string{
"k8s.cluster.name",
}
// clusterCountAttrKeys are the resource attributes whose distinct values are
// counted per cluster. Node name is read from the node metric universe, while
// namespace + workload names come from the pod metric universe — both unioned
// into clusterCountMetricNamesList.
var clusterCountAttrKeys = []string{
inframonitoringtypes.NodeNameAttrKey,
inframonitoringtypes.NamespaceNameAttrKey,
inframonitoringtypes.DeploymentNameAttrKey,
inframonitoringtypes.DaemonSetNameAttrKey,
inframonitoringtypes.JobNameAttrKey,
inframonitoringtypes.StatefulSetNameAttrKey,
}
// clusterMetricNamesListForCounts is the metric universe for per-cluster distinct
// counts. It unions the pod universe (carries namespace + workload owner labels)
// with the cluster/node universe (carries k8s.node.name), so a single query can
// count nodes, namespaces, and workloads per cluster. Overlapping pod
// phase/status metrics are left in — harmless in a metric_name IN (...) list.
var clusterMetricNamesListForCounts = slices.Concat(podsTableMetricNamesList, clustersTableMetricNamesList)
var orderByToClustersQueryNames = map[string][]string{
inframonitoringtypes.ClustersOrderByCPU: {"A"},
inframonitoringtypes.ClustersOrderByCPUAllocatable: {"B"},

View File

@@ -11,6 +11,7 @@ import (
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
@@ -762,3 +763,197 @@ func (m *module) getMetadata(
return result, nil
}
// countAttrIdentityTuples maps a counted attr to the full label tuple that
// uniquely identifies its entity, so uniqExact counts real entities rather than
// bare names (namespace-scoped workloads with the same name across namespaces,
// or cluster-scoped nodes/namespaces across clusters, would otherwise collapse).
// Attrs absent from this map are counted on their bare name.
var countAttrIdentityTuples = map[string][]string{
inframonitoringtypes.NamespaceNameAttrKey: {inframonitoringtypes.ClusterNameAttrKey, inframonitoringtypes.NamespaceNameAttrKey},
inframonitoringtypes.NodeNameAttrKey: {inframonitoringtypes.ClusterNameAttrKey, inframonitoringtypes.NodeNameAttrKey},
inframonitoringtypes.DeploymentNameAttrKey: {inframonitoringtypes.ClusterNameAttrKey, inframonitoringtypes.NamespaceNameAttrKey, inframonitoringtypes.DeploymentNameAttrKey},
inframonitoringtypes.DaemonSetNameAttrKey: {inframonitoringtypes.ClusterNameAttrKey, inframonitoringtypes.NamespaceNameAttrKey, inframonitoringtypes.DaemonSetNameAttrKey},
inframonitoringtypes.JobNameAttrKey: {inframonitoringtypes.ClusterNameAttrKey, inframonitoringtypes.NamespaceNameAttrKey, inframonitoringtypes.JobNameAttrKey},
inframonitoringtypes.StatefulSetNameAttrKey: {inframonitoringtypes.ClusterNameAttrKey, inframonitoringtypes.NamespaceNameAttrKey, inframonitoringtypes.StatefulSetNameAttrKey},
}
// getPerGroupDistinctCounts returns, per groupBy combination, the exact distinct
// count of each attr in attrNames within the time range and metric universe.
// It mirrors getMetadata: fingerprints come from the samples table, labels are
// read from the timeseries table (raw only, or raw+reduced union when reduction
// is enabled), and the user filter is merged with the page-groups IN clauses.
// The returned map keys group column values by "\x00", mapping to attr -> count.
func (m *module) getPerGroupDistinctCounts(
ctx context.Context,
orgID valuer.UUID,
start, end int64,
filter *qbtypes.Filter,
groupBy []qbtypes.GroupByKey,
pageGroups []map[string]string,
attrNames []string,
metricNames []string,
) (map[string]map[string]int64, error) {
if len(pageGroups) == 0 || len(groupBy) == 0 {
return map[string]map[string]int64{}, nil
}
if len(attrNames) == 0 {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "attrNames must not be empty")
}
if len(metricNames) == 0 {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "metricNames must not be empty")
}
// Merge user filter with page-groups IN clauses.
userFilterExpr := ""
if filter != nil {
userFilterExpr = filter.Expression
}
pageGroupsFilterExpr := buildPageGroupsFilterExpr(pageGroups)
mergedFilterExpr := mergeFilterExpressions(userFilterExpr, pageGroupsFilterExpr)
reductionEnabled := m.fl.BooleanOrEmpty(ctx, flagger.FeatureEnableMetricsReduction, featuretypes.NewFlaggerEvaluationContext(orgID))
// Step-floor the window and pick the right tables — same bounds the QB v5
// metric querier uses (see alignedMetricWindow / getMetadata).
samplesStartMs, flooredEndMs, tsAdjustedStartMs, distributedTimeSeriesTbl, _, _, localSamplesTbl := alignedMetricWindow(start, end)
fpSB := m.buildSamplesTblFingerprintSubQuery(metricNames, localSamplesTbl, samplesStartMs, flooredEndMs)
groupByCols := make([]string, len(groupBy))
for i, key := range groupBy {
groupByCols[i] = key.Name
}
sb := sqlbuilder.NewSelectBuilder()
// SELECT: one JSONExtractString per groupBy col + one uniqExactIf per attr.
selectCols := make([]string, 0, len(groupByCols)+len(attrNames))
for _, col := range groupByCols {
selectCols = append(selectCols,
fmt.Sprintf("JSONExtractString(labels, %s) AS %s", sb.Var(col), quoteIdentifier(col)),
)
}
for _, attr := range attrNames {
// Guard on != '' so a series missing the attr isn't counted as one empty value.
extract := fmt.Sprintf("JSONExtractString(labels, %s)", sb.Var(attr))
// Count on the entity's full identity tuple where one is defined, so
// same-named entities in different scopes (e.g. workloads sharing a name
// across namespaces) aren't collapsed. Falls back to the bare name.
valueExpr := extract
if tuple, ok := countAttrIdentityTuples[attr]; ok {
parts := make([]string, len(tuple))
for i, col := range tuple {
parts[i] = fmt.Sprintf("JSONExtractString(labels, %s)", sb.Var(col))
}
valueExpr = fmt.Sprintf("(%s)", strings.Join(parts, ", "))
}
selectCols = append(selectCols,
fmt.Sprintf("uniqExactIf(%s, %s != '') AS %s", valueExpr, extract, quoteIdentifier(attr)),
)
}
sb.Select(selectCols...)
if reductionEnabled {
var filterClause *sqlbuilder.WhereClause
if mergedFilterExpr != "" {
var err error
filterClause, err = m.buildFilterClause(ctx, &qbtypes.Filter{Expression: mergedFilterExpr}, start, end)
if err != nil {
return nil, err
}
}
reducedFpSB := m.buildReducedSamplesTblFingerprintSubQuery(metricNames, samplesStartMs, flooredEndMs)
rawSrc := sqlbuilder.NewSelectBuilder()
rawSrc.Select("labels")
rawSrc.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedTimeSeriesTbl))
rawSrc.Where(
rawSrc.In("metric_name", sqlbuilder.List(metricNames)),
rawSrc.GE("unix_milli", tsAdjustedStartMs),
rawSrc.LE("unix_milli", flooredEndMs),
fmt.Sprintf("fingerprint IN (%s)", rawSrc.Var(fpSB)),
)
if filterClause != nil {
rawSrc.AddWhereClause(sqlbuilder.CopyWhereClause(filterClause))
}
reducedSrc := sqlbuilder.NewSelectBuilder()
reducedSrc.Select("labels")
reducedSrc.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV4ReducedTableName))
reducedSrc.Where(
reducedSrc.In("metric_name", sqlbuilder.List(metricNames)),
reducedSrc.GE("unix_milli", tsAdjustedStartMs),
reducedSrc.LE("unix_milli", flooredEndMs),
fmt.Sprintf("fingerprint IN (%s)", reducedSrc.Var(reducedFpSB)),
)
if filterClause != nil {
reducedSrc.AddWhereClause(sqlbuilder.CopyWhereClause(filterClause))
}
sb.From(sb.BuilderAs(sqlbuilder.UnionAll(rawSrc, reducedSrc), "series"))
} else {
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedTimeSeriesTbl))
sb.Where(
sb.In("metric_name", sqlbuilder.List(metricNames)),
sb.GE("unix_milli", tsAdjustedStartMs),
sb.LE("unix_milli", flooredEndMs),
fmt.Sprintf("fingerprint IN (%s)", sb.Var(fpSB)),
)
if mergedFilterExpr != "" {
filterClause, err := m.buildFilterClause(ctx, &qbtypes.Filter{Expression: mergedFilterExpr}, start, end)
if err != nil {
return nil, err
}
if filterClause != nil {
sb.AddWhereClause(filterClause)
}
}
}
groupByAliases := make([]string, 0, len(groupByCols))
for _, col := range groupByCols {
groupByAliases = append(groupByAliases, quoteIdentifier(col))
}
sb.GroupBy(groupByAliases...)
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
rows, err := m.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[string]map[string]int64)
for rows.Next() {
groupVals := make([]string, len(groupByCols))
counts := make([]uint64, len(attrNames))
scanPtrs := make([]any, 0, len(groupByCols)+len(attrNames))
for i := range groupVals {
scanPtrs = append(scanPtrs, &groupVals[i])
}
for i := range counts {
scanPtrs = append(scanPtrs, &counts[i])
}
if err := rows.Scan(scanPtrs...); err != nil {
return nil, err
}
attrCounts := make(map[string]int64, len(attrNames))
for i, attr := range attrNames {
attrCounts[attr] = int64(counts[i])
}
result[compositeKeyFromList(groupVals)] = attrCounts
}
if err := rows.Err(); err != nil {
return nil, err
}
return result, nil
}

View File

@@ -632,6 +632,7 @@ func (m *module) ListNamespaces(ctx context.Context, orgID valuer.UUID, req *inf
phaseCounts map[string]podPhaseCounts
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
resourceCounts map[string]map[string]int64
)
g, gCtx := errgroup.WithContext(ctx)
@@ -651,12 +652,17 @@ func (m *module) ListNamespaces(ctx context.Context, orgID valuer.UUID, req *inf
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
resourceCounts, err = m.getPerGroupDistinctCounts(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups, namespaceCountAttrKeys, namespacesTableMetricNamesList)
return err
})
if err := g.Wait(); err != nil {
return nil, err
}
resp.Records = buildNamespaceRecords(queryResp, pageGroups, req.GroupBy, metadataMap, phaseCounts, podStatusCounts)
resp.Records = buildNamespaceRecords(queryResp, pageGroups, req.GroupBy, metadataMap, phaseCounts, podStatusCounts, resourceCounts)
resp.Warning = mergeQueryWarnings(queryResp.Warning, podStatusWarning)
return resp, nil
@@ -732,6 +738,7 @@ func (m *module) ListClusters(ctx context.Context, orgID valuer.UUID, req *infra
podPhaseCountsMap map[string]podPhaseCounts
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
resourceCounts map[string]map[string]int64
)
g, gCtx := errgroup.WithContext(ctx)
@@ -756,12 +763,17 @@ func (m *module) ListClusters(ctx context.Context, orgID valuer.UUID, req *infra
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
resourceCounts, err = m.getPerGroupDistinctCounts(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups, clusterCountAttrKeys, clusterMetricNamesListForCounts)
return err
})
if err := g.Wait(); err != nil {
return nil, err
}
resp.Records = buildClusterRecords(queryResp, pageGroups, req.GroupBy, metadataMap, nodeConditionCountsMap, podPhaseCountsMap, podStatusCounts)
resp.Records = buildClusterRecords(queryResp, pageGroups, req.GroupBy, metadataMap, nodeConditionCountsMap, podPhaseCountsMap, podStatusCounts, resourceCounts)
resp.Warning = mergeQueryWarnings(queryResp.Warning, podStatusWarning)
return resp, nil

View File

@@ -19,6 +19,7 @@ func buildNamespaceRecords(
metadataMap map[string]map[string]string,
phaseCounts map[string]podPhaseCounts,
podStatusCounts map[string]podStatusCounts,
resourceCounts map[string]map[string]int64,
) []inframonitoringtypes.NamespaceRecord {
metricsMap := parseFullQueryResponse(resp, groupBy)
@@ -57,6 +58,13 @@ func buildNamespaceRecords(
record.PodCountsByStatus = podStatusCountsToResponse(podStatusCountsForGroup)
}
if counts, ok := resourceCounts[compositeKey]; ok {
record.Counts.Deployments = counts[inframonitoringtypes.DeploymentNameAttrKey]
record.Counts.DaemonSets = counts[inframonitoringtypes.DaemonSetNameAttrKey]
record.Counts.Jobs = counts[inframonitoringtypes.JobNameAttrKey]
record.Counts.StatefulSets = counts[inframonitoringtypes.StatefulSetNameAttrKey]
}
if attrs, ok := metadataMap[compositeKey]; ok {
for k, v := range attrs {
record.Meta[k] = v

View File

@@ -32,6 +32,16 @@ var namespaceAttrKeysForMetadata = []string{
"k8s.cluster.name",
}
// namespaceCountAttrKeys are the workload resource attributes whose distinct
// values are counted per namespace. They are read from the pod metric universe,
// which carries the owner workload names for each pod series.
var namespaceCountAttrKeys = []string{
inframonitoringtypes.DeploymentNameAttrKey,
inframonitoringtypes.DaemonSetNameAttrKey,
inframonitoringtypes.JobNameAttrKey,
inframonitoringtypes.StatefulSetNameAttrKey,
}
var orderByToNamespacesQueryNames = map[string][]string{
inframonitoringtypes.NamespacesOrderByCPU: {"A"},
inframonitoringtypes.NamespacesOrderByMemory: {"D"},

View File

@@ -98,7 +98,7 @@ func (s *store) CreateOrGet(ctx context.Context, tags []*tagtypes.Tag) ([]*tagty
BunDBCtx(ctx).
NewInsert().
Model(&tags).
// On("CONFLICT (org_id, kind, (LOWER(key)), (LOWER(value))) DO UPDATE").
On("CONFLICT (org_id, kind, (LOWER(key)), (LOWER(value))) DO UPDATE").
Set("key = tag.key").
Returning("*").
Scan(ctx)

View File

@@ -88,62 +88,60 @@ func TestStore_Create_PopulatesIDsOnFreshInsert(t *testing.T) {
assert.Equal(t, preIDB, stored["team\x00blr"].ID)
}
// todo (@namanverma): uncomment once unique index is there.
//
// func TestStore_Create_ConflictReturnsExistingRowID(t *testing.T) {
// ctx := context.Background()
// sqlstore := newTestStore(t)
// s := NewStore(sqlstore)
func TestStore_Create_ConflictReturnsExistingRowID(t *testing.T) {
ctx := context.Background()
sqlstore := newTestStore(t)
s := NewStore(sqlstore)
// orgID := valuer.GenerateUUID()
orgID := valuer.GenerateUUID()
// // Simulate a concurrent insert: someone else has already inserted "tag:Database".
// winner := tagtypes.NewTag(orgID, dashboardKind, "tag", "Database")
// _, err := s.CreateOrGet(ctx, []*tagtypes.Tag{winner})
// require.NoError(t, err)
// winnerID := winner.ID
// Simulate a concurrent insert: someone else has already inserted "tag:Database".
winner := tagtypes.NewTag(orgID, dashboardKind, "tag", "Database")
_, err := s.CreateOrGet(ctx, []*tagtypes.Tag{winner})
require.NoError(t, err)
winnerID := winner.ID
// // Now our request runs with a different pre-generated ID for the same
// // (key, value) — case differs but the functional unique index collapses
// // them. RETURNING should overwrite our stale ID with winner's ID.
// loser := tagtypes.NewTag(orgID, dashboardKind, "TAG", "DATABASE")
// loserPreID := loser.ID
// require.NotEqual(t, winnerID, loserPreID, "pre-generated IDs must differ for this test to be meaningful")
// Now our request runs with a different pre-generated ID for the same
// (key, value) — case differs but the functional unique index collapses
// them. RETURNING should overwrite our stale ID with winner's ID.
loser := tagtypes.NewTag(orgID, dashboardKind, "TAG", "DATABASE")
loserPreID := loser.ID
require.NotEqual(t, winnerID, loserPreID, "pre-generated IDs must differ for this test to be meaningful")
// got, err := s.CreateOrGet(ctx, []*tagtypes.Tag{loser})
// require.NoError(t, err)
// require.Len(t, got, 1)
got, err := s.CreateOrGet(ctx, []*tagtypes.Tag{loser})
require.NoError(t, err)
require.Len(t, got, 1)
// assert.Equal(t, winnerID, got[0].ID, "returned slice should carry the existing row's ID, not our stale one")
// assert.Equal(t, winnerID, loser.ID, "input slice element is mutated in place")
assert.Equal(t, winnerID, got[0].ID, "returned slice should carry the existing row's ID, not our stale one")
assert.Equal(t, winnerID, loser.ID, "input slice element is mutated in place")
// // And the DB still has exactly one row for that (lower(key), lower(value)) — winner's, with winner's casing.
// stored := tagsByLowerKeyValue(t, sqlstore.BunDB())
// require.Len(t, stored, 1)
// assert.Equal(t, winnerID, stored["tag\x00database"].ID)
// assert.Equal(t, "tag", stored["tag\x00database"].Key, "winner's casing preserved in key")
// assert.Equal(t, "Database", stored["tag\x00database"].Value, "winner's casing preserved in value")
// }
// And the DB still has exactly one row for that (lower(key), lower(value)) — winner's, with winner's casing.
stored := tagsByLowerKeyValue(t, sqlstore.BunDB())
require.Len(t, stored, 1)
assert.Equal(t, winnerID, stored["tag\x00database"].ID)
assert.Equal(t, "tag", stored["tag\x00database"].Key, "winner's casing preserved in key")
assert.Equal(t, "Database", stored["tag\x00database"].Value, "winner's casing preserved in value")
}
// func TestStore_Create_MixedFreshAndConflict(t *testing.T) {
// ctx := context.Background()
// sqlstore := newTestStore(t)
// s := NewStore(sqlstore)
func TestStore_Create_MixedFreshAndConflict(t *testing.T) {
ctx := context.Background()
sqlstore := newTestStore(t)
s := NewStore(sqlstore)
// orgID := valuer.GenerateUUID()
// pre := tagtypes.NewTag(orgID, dashboardKind, "tag", "Database")
// _, err := s.CreateOrGet(ctx, []*tagtypes.Tag{pre})
// require.NoError(t, err)
// preExistingID := pre.ID
orgID := valuer.GenerateUUID()
pre := tagtypes.NewTag(orgID, dashboardKind, "tag", "Database")
_, err := s.CreateOrGet(ctx, []*tagtypes.Tag{pre})
require.NoError(t, err)
preExistingID := pre.ID
// conflict := tagtypes.NewTag(orgID, dashboardKind, "tag", "Database")
// fresh := tagtypes.NewTag(orgID, dashboardKind, "team", "BLR")
// freshPreID := fresh.ID
conflict := tagtypes.NewTag(orgID, dashboardKind, "tag", "Database")
fresh := tagtypes.NewTag(orgID, dashboardKind, "team", "BLR")
freshPreID := fresh.ID
// got, err := s.CreateOrGet(ctx, []*tagtypes.Tag{conflict, fresh})
// require.NoError(t, err)
// require.Len(t, got, 2)
got, err := s.CreateOrGet(ctx, []*tagtypes.Tag{conflict, fresh})
require.NoError(t, err)
require.Len(t, got, 2)
// assert.Equal(t, preExistingID, got[0].ID, "conflicting row's ID overwritten with the existing row's")
// assert.Equal(t, freshPreID, got[1].ID, "fresh row's pre-generated ID is preserved")
// }
assert.Equal(t, preExistingID, got[0].ID, "conflicting row's ID overwritten with the existing row's")
assert.Equal(t, freshPreID, got[1].ID, "fresh row's pre-generated ID is preserved")
}

View File

@@ -219,6 +219,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddMetricReductionRulesFactory(sqlstore, sqlschema),
sqlmigration.NewRemoveOrganizationTuplesFactory(sqlstore),
sqlmigration.NewAddRoleTransactionGroupsFactory(sqlstore, sqlschema),
sqlmigration.NewAddTagUniqueIndexFactory(sqlstore, sqlschema),
)
}

View File

@@ -0,0 +1,59 @@
package sqlmigration
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type addTagUniqueIndex struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
func NewAddTagUniqueIndexFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_tag_unique_index"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addTagUniqueIndex{
sqlstore: sqlstore,
sqlschema: sqlschema,
}, nil
})
}
func (migration *addTagUniqueIndex) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addTagUniqueIndex) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
sqls := migration.sqlschema.Operator().CreateIndex(
&sqlschema.UniqueIndexWithExpressions{
TableName: "tag",
Expressions: []string{"org_id", "kind", "LOWER(key)", "LOWER(value)"},
},
)
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *addTagUniqueIndex) Down(_ context.Context, _ *bun.DB) error {
return nil
}

View File

@@ -28,7 +28,15 @@ type ClusterRecord struct {
NodeCountsByReadiness NodeCountsByReadiness `json:"nodeCountsByReadiness" required:"true"`
PodCountsByPhase PodCountsByPhase `json:"podCountsByPhase" required:"true"`
PodCountsByStatus PodCountsByStatus `json:"podCountsByStatus" required:"true"`
Meta map[string]string `json:"meta" required:"true"`
Counts struct {
Nodes int64 `json:"nodes" required:"true"`
Namespaces int64 `json:"namespaces" required:"true"`
Deployments int64 `json:"deployments" required:"true"`
DaemonSets int64 `json:"daemonSets" required:"true"`
Jobs int64 `json:"jobs" required:"true"`
StatefulSets int64 `json:"statefulSets" required:"true"`
} `json:"counts" required:"true"`
Meta map[string]string `json:"meta" required:"true"`
}
// PostableClusters is the request body for the v2 clusters list API.

View File

@@ -22,7 +22,13 @@ type NamespaceRecord struct {
NamespaceMemory float64 `json:"namespaceMemory" required:"true"`
PodCountsByPhase PodCountsByPhase `json:"podCountsByPhase" required:"true"`
PodCountsByStatus PodCountsByStatus `json:"podCountsByStatus" required:"true"`
Meta map[string]string `json:"meta" required:"true"`
Counts struct {
Deployments int64 `json:"deployments" required:"true"`
DaemonSets int64 `json:"daemonSets" required:"true"`
Jobs int64 `json:"jobs" required:"true"`
StatefulSets int64 `json:"statefulSets" required:"true"`
} `json:"counts" required:"true"`
Meta map[string]string `json:"meta" required:"true"`
}
// PostableNamespaces is the request body for the v2 namespaces list API.

View File

@@ -76,9 +76,9 @@ type PostableRule struct {
}
type NotificationSettings struct {
GroupBy []string `json:"groupBy,omitempty"`
Renotify Renotify `json:"renotify,omitzero"`
UsePolicy bool `json:"usePolicy,omitempty"`
GroupBy []string `json:"groupBy,omitempty"`
Renotify *Renotify `json:"renotify,omitempty"`
UsePolicy bool `json:"usePolicy,omitempty"`
// NewGroupEvalDelay is the grace period for new series to be excluded from alerts evaluation
NewGroupEvalDelay valuer.TextDuration `json:"newGroupEvalDelay,omitzero"`
}
@@ -92,7 +92,7 @@ type Renotify struct {
func (ns *NotificationSettings) GetAlertManagerNotificationConfig() alertmanagertypes.NotificationConfig {
var renotifyInterval time.Duration
var noDataRenotifyInterval time.Duration
if ns.Renotify.Enabled {
if ns.Renotify != nil && ns.Renotify.Enabled {
if slices.Contains(ns.Renotify.AlertStates, StateNoData) {
noDataRenotifyInterval = ns.Renotify.ReNotifyInterval.Duration()
}
@@ -204,10 +204,12 @@ func (ns *NotificationSettings) UnmarshalJSON(data []byte) error {
}
// Validate states after unmarshaling
for _, state := range ns.Renotify.AlertStates {
if state != StateFiring && state != StateNoData {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid alert state: %s", state)
if ns.Renotify != nil {
for _, state := range ns.Renotify.AlertStates {
if state != StateFiring && state != StateNoData {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid alert state: %s", state)
}
}
}
return nil
@@ -220,6 +222,11 @@ func (r *PostableRule) processRuleDefaults() {
r.SchemaVersion = DefaultSchemaVersion
}
// TODO(srikanthccv): remove as this is now a legacy field
if r.Version == "" {
r.Version = "v5"
}
// v2alpha1 uses the Evaluation envelope for window/frequency;
// only default top-level fields for v1.
if r.SchemaVersion != SchemaVersionV2Alpha1 {
@@ -271,7 +278,7 @@ func (r *PostableRule) processRuleDefaults() {
r.RuleCondition.Thresholds = &thresholdData
r.Evaluation = &EvaluationEnvelope{RollingEvaluation, RollingWindow{EvalWindow: r.EvalWindow, Frequency: r.Frequency}}
r.NotificationSettings = &NotificationSettings{
Renotify: Renotify{
Renotify: &Renotify{
Enabled: true,
ReNotifyInterval: valuer.MustParseTextDuration("4h"),
AlertStates: []AlertState{StateFiring},
@@ -557,7 +564,7 @@ func (r *PostableRule) validateV2Alpha1() []error {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
"notificationSettings: field is required for schemaVersion %q", SchemaVersionV2Alpha1))
} else {
if r.NotificationSettings.Renotify.Enabled && !r.NotificationSettings.Renotify.ReNotifyInterval.IsPositive() {
if r.NotificationSettings.Renotify != nil && r.NotificationSettings.Renotify.Enabled && !r.NotificationSettings.Renotify.ReNotifyInterval.IsPositive() {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
"notificationSettings.renotify.interval: must be a positive duration when renotify is enabled"))
}

View File

@@ -6,6 +6,7 @@ import (
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
@@ -1327,3 +1328,27 @@ func TestAnomalyNegationEval(t *testing.T) {
})
}
}
func TestVersionDefaultsToV5(t *testing.T) {
content := `{
"alert": "cpu high",
"alertType": "METRIC_BASED_ALERT",
"ruleType": "threshold_rule",
"schemaVersion": "v2alpha1",
"condition": {
"compositeQuery": {
"queries": [{"type": "promql", "spec": {"name": "A", "query": "up"}}],
"panelType": "graph",
"queryType": "promql"
},
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 90, "matchType": "1", "op": "1"}]}
},
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},
"notificationSettings": {"usePolicy": false}
}`
rule := PostableRule{}
require.NoError(t, json.Unmarshal([]byte(content), &rule))
assert.Equal(t, "v5", rule.Version)
assert.NoError(t, rule.Validate())
}

View File

@@ -0,0 +1,258 @@
package ruletypes
import (
"encoding/json"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func readBody(t *testing.T, stored string) map[string]json.RawMessage {
t.Helper()
g := GettableRule{}
require.NoError(t, json.Unmarshal([]byte(stored), &g))
out, err := json.Marshal(NewRule(&g))
require.NoError(t, err)
var body map[string]json.RawMessage
require.NoError(t, json.Unmarshal(out, &body))
return body
}
func TestV2RoundTripRolling(t *testing.T) {
thresholds := `{
"kind": "basic",
"spec": [
{"name": "critical", "target": 90.5, "targetUnit": "%", "recoveryTarget": 80.5, "matchType": "at_least_once", "op": "above", "channels": ["slack-critical"]},
{"name": "warning", "target": 75, "targetUnit": "%", "matchType": "at_least_once", "op": "above", "channels": ["slack-warnings", "email-oncall"]}
]
}`
thresholdsEchoed := `{
"kind": "basic",
"spec": [
{"name": "critical", "target": 90.5, "targetUnit": "%", "recoveryTarget": 80.5, "matchType": "at_least_once", "op": "above", "channels": ["slack-critical"]},
{"name": "warning", "target": 75, "targetUnit": "%", "recoveryTarget": null, "matchType": "at_least_once", "op": "above", "channels": ["slack-warnings", "email-oncall"]}
]
}`
evaluation := `{"kind": "rolling", "spec": {"evalWindow": "90m", "frequency": "90s"}}`
notificationSettings := `{
"groupBy": ["service.name", "deployment.environment"],
"renotify": {"enabled": true, "interval": "45m", "alertStates": ["firing", "nodata"]},
"usePolicy": true,
"newGroupEvalDelay": "10m"
}`
labels := `{"team": "infra", "severity": "critical"}`
annotations := `{"summary": "CPU above {{$threshold}}", "description": "value {{$value}}"}`
stored := `{
"alert": "cpu high",
"alertType": "METRIC_BASED_ALERT",
"description": "watches cpu",
"ruleType": "promql_rule",
"schemaVersion": "v2alpha1",
"version": "v5",
"disabled": true,
"labels": ` + labels + `,
"annotations": ` + annotations + `,
"condition": {
"compositeQuery": {
"queries": [{"type": "promql", "spec": {"name": "A", "query": "avg(cpu_usage)"}}],
"panelType": "graph",
"queryType": "promql",
"unit": "percent"
},
"selectedQueryName": "A",
"alertOnAbsent": true,
"absentFor": 10,
"requireMinPoints": true,
"requiredNumPoints": 4,
"thresholds": ` + thresholds + `
},
"evaluation": ` + evaluation + `,
"notificationSettings": ` + notificationSettings + `
}`
body := readBody(t, stored)
assert.JSONEq(t, `"cpu high"`, string(body["alert"]))
assert.JSONEq(t, `"METRIC_BASED_ALERT"`, string(body["alertType"]))
assert.JSONEq(t, `"watches cpu"`, string(body["description"]))
assert.JSONEq(t, `"promql_rule"`, string(body["ruleType"]))
assert.JSONEq(t, `"v2alpha1"`, string(body["schemaVersion"]))
assert.JSONEq(t, `true`, string(body["disabled"]))
assert.JSONEq(t, labels, string(body["labels"]))
assert.JSONEq(t, annotations, string(body["annotations"]))
assert.JSONEq(t, evaluation, string(body["evaluation"]))
assert.JSONEq(t, notificationSettings, string(body["notificationSettings"]))
var condition map[string]json.RawMessage
require.NoError(t, json.Unmarshal(body["condition"], &condition))
assert.JSONEq(t, thresholdsEchoed, string(condition["thresholds"]))
assert.JSONEq(t, `"A"`, string(condition["selectedQueryName"]))
assert.JSONEq(t, `true`, string(condition["alertOnAbsent"]))
assert.JSONEq(t, `10`, string(condition["absentFor"]))
assert.JSONEq(t, `true`, string(condition["requireMinPoints"]))
assert.JSONEq(t, `4`, string(condition["requiredNumPoints"]))
}
func TestV2RoundTripCumulative(t *testing.T) {
evaluation := `{
"kind": "cumulative",
"spec": {
"schedule": {"type": "daily", "minute": 30, "hour": 9},
"frequency": "5m",
"timezone": "America/New_York"
}
}`
stored := `{
"alert": "daily budget",
"alertType": "METRIC_BASED_ALERT",
"ruleType": "threshold_rule",
"schemaVersion": "v2alpha1",
"condition": {
"compositeQuery": {
"queries": [{"type": "promql", "spec": {"name": "A", "query": "sum(cost)"}}],
"panelType": "graph",
"queryType": "promql"
},
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 100, "matchType": "at_least_once", "op": "above"}]}
},
"evaluation": ` + evaluation + `,
"notificationSettings": {"usePolicy": false}
}`
body := readBody(t, stored)
assert.JSONEq(t, evaluation, string(body["evaluation"]))
}
func TestV2MinimalReadShape(t *testing.T) {
stored := `{
"alert": "minimal",
"alertType": "METRIC_BASED_ALERT",
"ruleType": "threshold_rule",
"schemaVersion": "v2alpha1",
"condition": {
"compositeQuery": {
"queries": [{"type": "promql", "spec": {"name": "A", "query": "up"}}],
"panelType": "graph",
"queryType": "promql"
},
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 90, "matchType": "at_least_once", "op": "above"}]}
},
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},
"notificationSettings": {"usePolicy": false}
}`
body := readBody(t, stored)
for _, field := range []string{"labels", "annotations", "description", "preferredChannels", "evalWindow", "frequency"} {
assert.NotContains(t, body, field)
}
var ns map[string]json.RawMessage
require.NoError(t, json.Unmarshal(body["notificationSettings"], &ns))
for _, field := range []string{"renotify", "groupBy", "newGroupEvalDelay", "usePolicy"} {
assert.NotContains(t, ns, field, "notificationSettings.%s", field)
}
assert.JSONEq(t, `false`, string(body["disabled"]))
assert.JSONEq(t, `"v5"`, string(body["version"]))
var condition struct {
Thresholds struct {
Spec []map[string]json.RawMessage `json:"spec"`
} `json:"thresholds"`
}
require.NoError(t, json.Unmarshal(body["condition"], &condition))
require.Len(t, condition.Thresholds.Spec, 1)
spec := condition.Thresholds.Spec[0]
assert.JSONEq(t, `""`, string(spec["targetUnit"]))
assert.JSONEq(t, `null`, string(spec["channels"]))
assert.JSONEq(t, `null`, string(spec["recoveryTarget"]))
}
func TestRenotifyRoundTrip(t *testing.T) {
base := `{
"alert": "cpu high",
"alertType": "METRIC_BASED_ALERT",
"ruleType": "threshold_rule",
"schemaVersion": "v2alpha1",
"condition": {
"compositeQuery": {
"queries": [{"type": "promql", "spec": {"name": "A", "query": "up"}}],
"panelType": "graph",
"queryType": "promql"
},
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 90, "matchType": "at_least_once", "op": "above"}]}
},
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},
"notificationSettings": %s
}`
cases := []struct {
name string
settings string
wantRenotify string
}{
{
name: "absent renotify stays absent",
settings: `{"usePolicy": false}`,
},
{
name: "explicitly disabled renotify is echoed",
settings: `{"renotify": {"enabled": false}}`,
wantRenotify: `{"enabled": false}`,
},
{
name: "enabled renotify with states is echoed",
settings: `{"renotify": {"enabled": true, "interval": "30m", "alertStates": ["firing"]}}`,
wantRenotify: `{"enabled": true, "interval": "30m", "alertStates": ["firing"]}`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
body := readBody(t, strings.Replace(base, "%s", tc.settings, 1))
var ns map[string]json.RawMessage
require.NoError(t, json.Unmarshal(body["notificationSettings"], &ns))
if tc.wantRenotify == "" {
assert.NotContains(t, ns, "renotify")
} else {
assert.JSONEq(t, tc.wantRenotify, string(ns["renotify"]))
}
})
}
}
func TestPatchMergePreservesUnpatchedFields(t *testing.T) {
stored := `{
"alert": "cpu high",
"alertType": "METRIC_BASED_ALERT",
"ruleType": "threshold_rule",
"schemaVersion": "v2alpha1",
"condition": {
"compositeQuery": {
"queries": [{"type": "promql", "spec": {"name": "A", "query": "up"}}],
"panelType": "graph",
"queryType": "promql"
},
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 90, "matchType": "at_least_once", "op": "above"}]}
},
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},
"notificationSettings": {"renotify": {"enabled": true, "interval": "30m", "alertStates": ["firing"]}}
}`
rule := PostableRule{}
require.NoError(t, json.Unmarshal([]byte(stored), &rule))
require.NoError(t, json.Unmarshal([]byte(`{"disabled": true}`), &rule))
require.NoError(t, rule.Validate())
assert.True(t, rule.Disabled)
assert.NotNil(t, rule.RuleCondition.Thresholds)
assert.NotNil(t, rule.Evaluation)
require.NotNil(t, rule.NotificationSettings)
require.NotNil(t, rule.NotificationSettings.Renotify)
assert.True(t, rule.NotificationSettings.Renotify.Enabled)
}

View File

@@ -169,12 +169,10 @@ func TestValidate_PostableRule_Common(t *testing.T) {
errSubstr: "alert",
},
// only "v5" is allowed
// only "v5" is allowed; missing/empty defaults to "v5"
{
name: "missing version",
json: removeField(validV1Builder(), "version"),
wantErr: true,
errSubstr: "version",
name: "missing version defaults to v5",
json: removeField(validV1Builder(), "version"),
},
{
name: "wrong version v4",
@@ -189,10 +187,8 @@ func TestValidate_PostableRule_Common(t *testing.T) {
errSubstr: "version",
},
{
name: "empty version",
json: patchJSON(validV1Builder(), `{"version": ""}`),
wantErr: true,
errSubstr: "version",
name: "empty version defaults to v5",
json: patchJSON(validV1Builder(), `{"version": ""}`),
},
// alert type, capital case to avoid breaking changes

View File

@@ -13,9 +13,9 @@
{"metric_name":"k8s.node.condition_ready","labels":{"k8s.node.name":"acc-c1-n1","k8s.node.uid":"acc-c1-n1-uid","k8s.cluster.name":"acc-cluster-1"},"timestamp":"2025-01-10T10:00:00+00:00","value":1,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.node.condition_ready","labels":{"k8s.node.name":"acc-c1-n1","k8s.node.uid":"acc-c1-n1-uid","k8s.cluster.name":"acc-cluster-1"},"timestamp":"2025-01-10T10:02:00+00:00","value":1,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.node.condition_ready","labels":{"k8s.node.name":"acc-c1-n1","k8s.node.uid":"acc-c1-n1-uid","k8s.cluster.name":"acc-cluster-1"},"timestamp":"2025-01-10T10:04:00+00:00","value":1,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-c1-n1-p-uid","k8s.pod.name":"acc-c1-n1-p","k8s.namespace.name":"ns-x","k8s.node.name":"acc-c1-n1","k8s.cluster.name":"acc-cluster-1"},"timestamp":"2025-01-10T10:00:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-c1-n1-p-uid","k8s.pod.name":"acc-c1-n1-p","k8s.namespace.name":"ns-x","k8s.node.name":"acc-c1-n1","k8s.cluster.name":"acc-cluster-1"},"timestamp":"2025-01-10T10:02:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-c1-n1-p-uid","k8s.pod.name":"acc-c1-n1-p","k8s.namespace.name":"ns-x","k8s.node.name":"acc-c1-n1","k8s.cluster.name":"acc-cluster-1"},"timestamp":"2025-01-10T10:04:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-c1-n1-p-uid","k8s.pod.name":"acc-c1-n1-p","k8s.namespace.name":"ns-x","k8s.node.name":"acc-c1-n1","k8s.cluster.name":"acc-cluster-1","k8s.deployment.name":"dep-a"},"timestamp":"2025-01-10T10:00:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-c1-n1-p-uid","k8s.pod.name":"acc-c1-n1-p","k8s.namespace.name":"ns-x","k8s.node.name":"acc-c1-n1","k8s.cluster.name":"acc-cluster-1","k8s.deployment.name":"dep-a"},"timestamp":"2025-01-10T10:02:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-c1-n1-p-uid","k8s.pod.name":"acc-c1-n1-p","k8s.namespace.name":"ns-x","k8s.node.name":"acc-c1-n1","k8s.cluster.name":"acc-cluster-1","k8s.deployment.name":"dep-a"},"timestamp":"2025-01-10T10:04:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.node.cpu.usage","labels":{"k8s.node.name":"acc-c1-n2","k8s.node.uid":"acc-c1-n2-uid","k8s.cluster.name":"acc-cluster-1"},"timestamp":"2025-01-10T10:00:00+00:00","value":0.5,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.node.cpu.usage","labels":{"k8s.node.name":"acc-c1-n2","k8s.node.uid":"acc-c1-n2-uid","k8s.cluster.name":"acc-cluster-1"},"timestamp":"2025-01-10T10:02:00+00:00","value":0.5,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.node.cpu.usage","labels":{"k8s.node.name":"acc-c1-n2","k8s.node.uid":"acc-c1-n2-uid","k8s.cluster.name":"acc-cluster-1"},"timestamp":"2025-01-10T10:04:00+00:00","value":0.5,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
@@ -31,9 +31,12 @@
{"metric_name":"k8s.node.condition_ready","labels":{"k8s.node.name":"acc-c1-n2","k8s.node.uid":"acc-c1-n2-uid","k8s.cluster.name":"acc-cluster-1"},"timestamp":"2025-01-10T10:00:00+00:00","value":1,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.node.condition_ready","labels":{"k8s.node.name":"acc-c1-n2","k8s.node.uid":"acc-c1-n2-uid","k8s.cluster.name":"acc-cluster-1"},"timestamp":"2025-01-10T10:02:00+00:00","value":1,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.node.condition_ready","labels":{"k8s.node.name":"acc-c1-n2","k8s.node.uid":"acc-c1-n2-uid","k8s.cluster.name":"acc-cluster-1"},"timestamp":"2025-01-10T10:04:00+00:00","value":1,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-c1-n2-p-uid","k8s.pod.name":"acc-c1-n2-p","k8s.namespace.name":"ns-x","k8s.node.name":"acc-c1-n2","k8s.cluster.name":"acc-cluster-1"},"timestamp":"2025-01-10T10:00:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-c1-n2-p-uid","k8s.pod.name":"acc-c1-n2-p","k8s.namespace.name":"ns-x","k8s.node.name":"acc-c1-n2","k8s.cluster.name":"acc-cluster-1"},"timestamp":"2025-01-10T10:02:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-c1-n2-p-uid","k8s.pod.name":"acc-c1-n2-p","k8s.namespace.name":"ns-x","k8s.node.name":"acc-c1-n2","k8s.cluster.name":"acc-cluster-1"},"timestamp":"2025-01-10T10:04:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-c1-n2-p-uid","k8s.pod.name":"acc-c1-n2-p","k8s.namespace.name":"ns-y","k8s.node.name":"acc-c1-n2","k8s.cluster.name":"acc-cluster-1","k8s.statefulset.name":"sts-a"},"timestamp":"2025-01-10T10:00:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-c1-n2-p-uid","k8s.pod.name":"acc-c1-n2-p","k8s.namespace.name":"ns-y","k8s.node.name":"acc-c1-n2","k8s.cluster.name":"acc-cluster-1","k8s.statefulset.name":"sts-a"},"timestamp":"2025-01-10T10:02:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-c1-n2-p-uid","k8s.pod.name":"acc-c1-n2-p","k8s.namespace.name":"ns-y","k8s.node.name":"acc-c1-n2","k8s.cluster.name":"acc-cluster-1","k8s.statefulset.name":"sts-a"},"timestamp":"2025-01-10T10:04:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-c1-n2-p2-uid","k8s.pod.name":"acc-c1-n2-p2","k8s.namespace.name":"ns-y","k8s.node.name":"acc-c1-n2","k8s.cluster.name":"acc-cluster-1","k8s.deployment.name":"dep-a"},"timestamp":"2025-01-10T10:00:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-c1-n2-p2-uid","k8s.pod.name":"acc-c1-n2-p2","k8s.namespace.name":"ns-y","k8s.node.name":"acc-c1-n2","k8s.cluster.name":"acc-cluster-1","k8s.deployment.name":"dep-a"},"timestamp":"2025-01-10T10:02:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-c1-n2-p2-uid","k8s.pod.name":"acc-c1-n2-p2","k8s.namespace.name":"ns-y","k8s.node.name":"acc-c1-n2","k8s.cluster.name":"acc-cluster-1","k8s.deployment.name":"dep-a"},"timestamp":"2025-01-10T10:04:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.node.cpu.usage","labels":{"k8s.node.name":"acc-c2-n1","k8s.node.uid":"acc-c2-n1-uid","k8s.cluster.name":"acc-cluster-2"},"timestamp":"2025-01-10T10:00:00+00:00","value":1.0,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.node.cpu.usage","labels":{"k8s.node.name":"acc-c2-n1","k8s.node.uid":"acc-c2-n1-uid","k8s.cluster.name":"acc-cluster-2"},"timestamp":"2025-01-10T10:02:00+00:00","value":1.0,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.node.cpu.usage","labels":{"k8s.node.name":"acc-c2-n1","k8s.node.uid":"acc-c2-n1-uid","k8s.cluster.name":"acc-cluster-2"},"timestamp":"2025-01-10T10:04:00+00:00","value":1.0,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
@@ -49,9 +52,9 @@
{"metric_name":"k8s.node.condition_ready","labels":{"k8s.node.name":"acc-c2-n1","k8s.node.uid":"acc-c2-n1-uid","k8s.cluster.name":"acc-cluster-2"},"timestamp":"2025-01-10T10:00:00+00:00","value":1,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.node.condition_ready","labels":{"k8s.node.name":"acc-c2-n1","k8s.node.uid":"acc-c2-n1-uid","k8s.cluster.name":"acc-cluster-2"},"timestamp":"2025-01-10T10:02:00+00:00","value":1,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.node.condition_ready","labels":{"k8s.node.name":"acc-c2-n1","k8s.node.uid":"acc-c2-n1-uid","k8s.cluster.name":"acc-cluster-2"},"timestamp":"2025-01-10T10:04:00+00:00","value":1,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-c2-n1-p-uid","k8s.pod.name":"acc-c2-n1-p","k8s.namespace.name":"ns-x","k8s.node.name":"acc-c2-n1","k8s.cluster.name":"acc-cluster-2"},"timestamp":"2025-01-10T10:00:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-c2-n1-p-uid","k8s.pod.name":"acc-c2-n1-p","k8s.namespace.name":"ns-x","k8s.node.name":"acc-c2-n1","k8s.cluster.name":"acc-cluster-2"},"timestamp":"2025-01-10T10:02:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-c2-n1-p-uid","k8s.pod.name":"acc-c2-n1-p","k8s.namespace.name":"ns-x","k8s.node.name":"acc-c2-n1","k8s.cluster.name":"acc-cluster-2"},"timestamp":"2025-01-10T10:04:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-c2-n1-p-uid","k8s.pod.name":"acc-c2-n1-p","k8s.namespace.name":"ns-x","k8s.node.name":"acc-c2-n1","k8s.cluster.name":"acc-cluster-2","k8s.deployment.name":"dep-b"},"timestamp":"2025-01-10T10:00:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-c2-n1-p-uid","k8s.pod.name":"acc-c2-n1-p","k8s.namespace.name":"ns-x","k8s.node.name":"acc-c2-n1","k8s.cluster.name":"acc-cluster-2","k8s.deployment.name":"dep-b"},"timestamp":"2025-01-10T10:02:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-c2-n1-p-uid","k8s.pod.name":"acc-c2-n1-p","k8s.namespace.name":"ns-x","k8s.node.name":"acc-c2-n1","k8s.cluster.name":"acc-cluster-2","k8s.deployment.name":"dep-b"},"timestamp":"2025-01-10T10:04:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.node.cpu.usage","labels":{"k8s.node.name":"acc-c2-n2","k8s.node.uid":"acc-c2-n2-uid","k8s.cluster.name":"acc-cluster-2"},"timestamp":"2025-01-10T10:00:00+00:00","value":1.0,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.node.cpu.usage","labels":{"k8s.node.name":"acc-c2-n2","k8s.node.uid":"acc-c2-n2-uid","k8s.cluster.name":"acc-cluster-2"},"timestamp":"2025-01-10T10:02:00+00:00","value":1.0,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.node.cpu.usage","labels":{"k8s.node.name":"acc-c2-n2","k8s.node.uid":"acc-c2-n2-uid","k8s.cluster.name":"acc-cluster-2"},"timestamp":"2025-01-10T10:04:00+00:00","value":1.0,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
@@ -67,9 +70,9 @@
{"metric_name":"k8s.node.condition_ready","labels":{"k8s.node.name":"acc-c2-n2","k8s.node.uid":"acc-c2-n2-uid","k8s.cluster.name":"acc-cluster-2"},"timestamp":"2025-01-10T10:00:00+00:00","value":1,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.node.condition_ready","labels":{"k8s.node.name":"acc-c2-n2","k8s.node.uid":"acc-c2-n2-uid","k8s.cluster.name":"acc-cluster-2"},"timestamp":"2025-01-10T10:02:00+00:00","value":1,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.node.condition_ready","labels":{"k8s.node.name":"acc-c2-n2","k8s.node.uid":"acc-c2-n2-uid","k8s.cluster.name":"acc-cluster-2"},"timestamp":"2025-01-10T10:04:00+00:00","value":1,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-c2-n2-p-uid","k8s.pod.name":"acc-c2-n2-p","k8s.namespace.name":"ns-x","k8s.node.name":"acc-c2-n2","k8s.cluster.name":"acc-cluster-2"},"timestamp":"2025-01-10T10:00:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-c2-n2-p-uid","k8s.pod.name":"acc-c2-n2-p","k8s.namespace.name":"ns-x","k8s.node.name":"acc-c2-n2","k8s.cluster.name":"acc-cluster-2"},"timestamp":"2025-01-10T10:02:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-c2-n2-p-uid","k8s.pod.name":"acc-c2-n2-p","k8s.namespace.name":"ns-x","k8s.node.name":"acc-c2-n2","k8s.cluster.name":"acc-cluster-2"},"timestamp":"2025-01-10T10:04:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-c2-n2-p-uid","k8s.pod.name":"acc-c2-n2-p","k8s.namespace.name":"ns-x","k8s.node.name":"acc-c2-n2","k8s.cluster.name":"acc-cluster-2","k8s.daemonset.name":"ds-b"},"timestamp":"2025-01-10T10:00:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-c2-n2-p-uid","k8s.pod.name":"acc-c2-n2-p","k8s.namespace.name":"ns-x","k8s.node.name":"acc-c2-n2","k8s.cluster.name":"acc-cluster-2","k8s.daemonset.name":"ds-b"},"timestamp":"2025-01-10T10:02:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-c2-n2-p-uid","k8s.pod.name":"acc-c2-n2-p","k8s.namespace.name":"ns-x","k8s.node.name":"acc-c2-n2","k8s.cluster.name":"acc-cluster-2","k8s.daemonset.name":"ds-b"},"timestamp":"2025-01-10T10:04:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.node.cpu.usage","labels":{"k8s.node.name":"acc-c2-n3","k8s.node.uid":"acc-c2-n3-uid","k8s.cluster.name":"acc-cluster-2"},"timestamp":"2025-01-10T10:00:00+00:00","value":1.0,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.node.cpu.usage","labels":{"k8s.node.name":"acc-c2-n3","k8s.node.uid":"acc-c2-n3-uid","k8s.cluster.name":"acc-cluster-2"},"timestamp":"2025-01-10T10:02:00+00:00","value":1.0,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.node.cpu.usage","labels":{"k8s.node.name":"acc-c2-n3","k8s.node.uid":"acc-c2-n3-uid","k8s.cluster.name":"acc-cluster-2"},"timestamp":"2025-01-10T10:04:00+00:00","value":1.0,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
@@ -85,6 +88,6 @@
{"metric_name":"k8s.node.condition_ready","labels":{"k8s.node.name":"acc-c2-n3","k8s.node.uid":"acc-c2-n3-uid","k8s.cluster.name":"acc-cluster-2"},"timestamp":"2025-01-10T10:00:00+00:00","value":1,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.node.condition_ready","labels":{"k8s.node.name":"acc-c2-n3","k8s.node.uid":"acc-c2-n3-uid","k8s.cluster.name":"acc-cluster-2"},"timestamp":"2025-01-10T10:02:00+00:00","value":1,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.node.condition_ready","labels":{"k8s.node.name":"acc-c2-n3","k8s.node.uid":"acc-c2-n3-uid","k8s.cluster.name":"acc-cluster-2"},"timestamp":"2025-01-10T10:04:00+00:00","value":1,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-c2-n3-p-uid","k8s.pod.name":"acc-c2-n3-p","k8s.namespace.name":"ns-x","k8s.node.name":"acc-c2-n3","k8s.cluster.name":"acc-cluster-2"},"timestamp":"2025-01-10T10:00:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-c2-n3-p-uid","k8s.pod.name":"acc-c2-n3-p","k8s.namespace.name":"ns-x","k8s.node.name":"acc-c2-n3","k8s.cluster.name":"acc-cluster-2"},"timestamp":"2025-01-10T10:02:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-c2-n3-p-uid","k8s.pod.name":"acc-c2-n3-p","k8s.namespace.name":"ns-x","k8s.node.name":"acc-c2-n3","k8s.cluster.name":"acc-cluster-2"},"timestamp":"2025-01-10T10:04:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-c2-n3-p-uid","k8s.pod.name":"acc-c2-n3-p","k8s.namespace.name":"ns-y","k8s.node.name":"acc-c2-n3","k8s.cluster.name":"acc-cluster-2","k8s.job.name":"job-b"},"timestamp":"2025-01-10T10:00:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-c2-n3-p-uid","k8s.pod.name":"acc-c2-n3-p","k8s.namespace.name":"ns-y","k8s.node.name":"acc-c2-n3","k8s.cluster.name":"acc-cluster-2","k8s.job.name":"job-b"},"timestamp":"2025-01-10T10:02:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-c2-n3-p-uid","k8s.pod.name":"acc-c2-n3-p","k8s.namespace.name":"ns-y","k8s.node.name":"acc-c2-n3","k8s.cluster.name":"acc-cluster-2","k8s.job.name":"job-b"},"timestamp":"2025-01-10T10:04:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}

View File

@@ -12,10 +12,18 @@
},
"podCountsByPhase": {
"pending": 0,
"running": 2,
"running": 3,
"succeeded": 0,
"failed": 0,
"unknown": 0
},
"counts": {
"nodes": 2,
"namespaces": 2,
"deployments": 2,
"daemonSets": 0,
"jobs": 0,
"statefulSets": 1
}
},
{
@@ -34,6 +42,14 @@
"succeeded": 0,
"failed": 0,
"unknown": 0
},
"counts": {
"nodes": 3,
"namespaces": 2,
"deployments": 1,
"daemonSets": 1,
"jobs": 1,
"statefulSets": 0
}
}
]

View File

@@ -1,45 +1,45 @@
{"metric_name":"k8s.pod.cpu.usage","labels":{"k8s.pod.uid":"acc-p1a-uid","k8s.pod.name":"acc-p1a","k8s.namespace.name":"acc-ns-1","k8s.cluster.name":"cluster-x"},"timestamp":"2025-01-10T10:00:00+00:00","value":0.5,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.cpu.usage","labels":{"k8s.pod.uid":"acc-p1a-uid","k8s.pod.name":"acc-p1a","k8s.namespace.name":"acc-ns-1","k8s.cluster.name":"cluster-x"},"timestamp":"2025-01-10T10:02:00+00:00","value":0.5,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.cpu.usage","labels":{"k8s.pod.uid":"acc-p1a-uid","k8s.pod.name":"acc-p1a","k8s.namespace.name":"acc-ns-1","k8s.cluster.name":"cluster-x"},"timestamp":"2025-01-10T10:04:00+00:00","value":0.5,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.memory.working_set","labels":{"k8s.pod.uid":"acc-p1a-uid","k8s.pod.name":"acc-p1a","k8s.namespace.name":"acc-ns-1","k8s.cluster.name":"cluster-x"},"timestamp":"2025-01-10T10:00:00+00:00","value":100000000.0,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.memory.working_set","labels":{"k8s.pod.uid":"acc-p1a-uid","k8s.pod.name":"acc-p1a","k8s.namespace.name":"acc-ns-1","k8s.cluster.name":"cluster-x"},"timestamp":"2025-01-10T10:02:00+00:00","value":100000000.0,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.memory.working_set","labels":{"k8s.pod.uid":"acc-p1a-uid","k8s.pod.name":"acc-p1a","k8s.namespace.name":"acc-ns-1","k8s.cluster.name":"cluster-x"},"timestamp":"2025-01-10T10:04:00+00:00","value":100000000.0,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-p1a-uid","k8s.pod.name":"acc-p1a","k8s.namespace.name":"acc-ns-1","k8s.cluster.name":"cluster-x"},"timestamp":"2025-01-10T10:00:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-p1a-uid","k8s.pod.name":"acc-p1a","k8s.namespace.name":"acc-ns-1","k8s.cluster.name":"cluster-x"},"timestamp":"2025-01-10T10:02:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-p1a-uid","k8s.pod.name":"acc-p1a","k8s.namespace.name":"acc-ns-1","k8s.cluster.name":"cluster-x"},"timestamp":"2025-01-10T10:04:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.cpu.usage","labels":{"k8s.pod.uid":"acc-p1b-uid","k8s.pod.name":"acc-p1b","k8s.namespace.name":"acc-ns-1","k8s.cluster.name":"cluster-x"},"timestamp":"2025-01-10T10:00:00+00:00","value":0.5,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.cpu.usage","labels":{"k8s.pod.uid":"acc-p1b-uid","k8s.pod.name":"acc-p1b","k8s.namespace.name":"acc-ns-1","k8s.cluster.name":"cluster-x"},"timestamp":"2025-01-10T10:02:00+00:00","value":0.5,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.cpu.usage","labels":{"k8s.pod.uid":"acc-p1b-uid","k8s.pod.name":"acc-p1b","k8s.namespace.name":"acc-ns-1","k8s.cluster.name":"cluster-x"},"timestamp":"2025-01-10T10:04:00+00:00","value":0.5,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.memory.working_set","labels":{"k8s.pod.uid":"acc-p1b-uid","k8s.pod.name":"acc-p1b","k8s.namespace.name":"acc-ns-1","k8s.cluster.name":"cluster-x"},"timestamp":"2025-01-10T10:00:00+00:00","value":100000000.0,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.memory.working_set","labels":{"k8s.pod.uid":"acc-p1b-uid","k8s.pod.name":"acc-p1b","k8s.namespace.name":"acc-ns-1","k8s.cluster.name":"cluster-x"},"timestamp":"2025-01-10T10:02:00+00:00","value":100000000.0,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.memory.working_set","labels":{"k8s.pod.uid":"acc-p1b-uid","k8s.pod.name":"acc-p1b","k8s.namespace.name":"acc-ns-1","k8s.cluster.name":"cluster-x"},"timestamp":"2025-01-10T10:04:00+00:00","value":100000000.0,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-p1b-uid","k8s.pod.name":"acc-p1b","k8s.namespace.name":"acc-ns-1","k8s.cluster.name":"cluster-x"},"timestamp":"2025-01-10T10:00:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-p1b-uid","k8s.pod.name":"acc-p1b","k8s.namespace.name":"acc-ns-1","k8s.cluster.name":"cluster-x"},"timestamp":"2025-01-10T10:02:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-p1b-uid","k8s.pod.name":"acc-p1b","k8s.namespace.name":"acc-ns-1","k8s.cluster.name":"cluster-x"},"timestamp":"2025-01-10T10:04:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.cpu.usage","labels":{"k8s.pod.uid":"acc-p2a-uid","k8s.pod.name":"acc-p2a","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y"},"timestamp":"2025-01-10T10:00:00+00:00","value":0.75,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.cpu.usage","labels":{"k8s.pod.uid":"acc-p2a-uid","k8s.pod.name":"acc-p2a","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y"},"timestamp":"2025-01-10T10:02:00+00:00","value":0.75,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.cpu.usage","labels":{"k8s.pod.uid":"acc-p2a-uid","k8s.pod.name":"acc-p2a","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y"},"timestamp":"2025-01-10T10:04:00+00:00","value":0.75,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.memory.working_set","labels":{"k8s.pod.uid":"acc-p2a-uid","k8s.pod.name":"acc-p2a","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y"},"timestamp":"2025-01-10T10:00:00+00:00","value":200000000.0,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.memory.working_set","labels":{"k8s.pod.uid":"acc-p2a-uid","k8s.pod.name":"acc-p2a","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y"},"timestamp":"2025-01-10T10:02:00+00:00","value":200000000.0,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.memory.working_set","labels":{"k8s.pod.uid":"acc-p2a-uid","k8s.pod.name":"acc-p2a","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y"},"timestamp":"2025-01-10T10:04:00+00:00","value":200000000.0,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-p2a-uid","k8s.pod.name":"acc-p2a","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y"},"timestamp":"2025-01-10T10:00:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-p2a-uid","k8s.pod.name":"acc-p2a","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y"},"timestamp":"2025-01-10T10:02:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-p2a-uid","k8s.pod.name":"acc-p2a","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y"},"timestamp":"2025-01-10T10:04:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.cpu.usage","labels":{"k8s.pod.uid":"acc-p2b-uid","k8s.pod.name":"acc-p2b","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y"},"timestamp":"2025-01-10T10:00:00+00:00","value":0.75,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.cpu.usage","labels":{"k8s.pod.uid":"acc-p2b-uid","k8s.pod.name":"acc-p2b","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y"},"timestamp":"2025-01-10T10:02:00+00:00","value":0.75,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.cpu.usage","labels":{"k8s.pod.uid":"acc-p2b-uid","k8s.pod.name":"acc-p2b","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y"},"timestamp":"2025-01-10T10:04:00+00:00","value":0.75,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.memory.working_set","labels":{"k8s.pod.uid":"acc-p2b-uid","k8s.pod.name":"acc-p2b","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y"},"timestamp":"2025-01-10T10:00:00+00:00","value":200000000.0,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.memory.working_set","labels":{"k8s.pod.uid":"acc-p2b-uid","k8s.pod.name":"acc-p2b","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y"},"timestamp":"2025-01-10T10:02:00+00:00","value":200000000.0,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.memory.working_set","labels":{"k8s.pod.uid":"acc-p2b-uid","k8s.pod.name":"acc-p2b","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y"},"timestamp":"2025-01-10T10:04:00+00:00","value":200000000.0,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-p2b-uid","k8s.pod.name":"acc-p2b","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y"},"timestamp":"2025-01-10T10:00:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-p2b-uid","k8s.pod.name":"acc-p2b","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y"},"timestamp":"2025-01-10T10:02:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-p2b-uid","k8s.pod.name":"acc-p2b","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y"},"timestamp":"2025-01-10T10:04:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.cpu.usage","labels":{"k8s.pod.uid":"acc-p2c-uid","k8s.pod.name":"acc-p2c","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y"},"timestamp":"2025-01-10T10:00:00+00:00","value":0.75,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.cpu.usage","labels":{"k8s.pod.uid":"acc-p2c-uid","k8s.pod.name":"acc-p2c","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y"},"timestamp":"2025-01-10T10:02:00+00:00","value":0.75,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.cpu.usage","labels":{"k8s.pod.uid":"acc-p2c-uid","k8s.pod.name":"acc-p2c","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y"},"timestamp":"2025-01-10T10:04:00+00:00","value":0.75,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.memory.working_set","labels":{"k8s.pod.uid":"acc-p2c-uid","k8s.pod.name":"acc-p2c","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y"},"timestamp":"2025-01-10T10:00:00+00:00","value":200000000.0,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.memory.working_set","labels":{"k8s.pod.uid":"acc-p2c-uid","k8s.pod.name":"acc-p2c","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y"},"timestamp":"2025-01-10T10:02:00+00:00","value":200000000.0,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.memory.working_set","labels":{"k8s.pod.uid":"acc-p2c-uid","k8s.pod.name":"acc-p2c","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y"},"timestamp":"2025-01-10T10:04:00+00:00","value":200000000.0,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-p2c-uid","k8s.pod.name":"acc-p2c","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y"},"timestamp":"2025-01-10T10:00:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-p2c-uid","k8s.pod.name":"acc-p2c","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y"},"timestamp":"2025-01-10T10:02:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-p2c-uid","k8s.pod.name":"acc-p2c","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y"},"timestamp":"2025-01-10T10:04:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.cpu.usage","labels":{"k8s.pod.uid":"acc-p1a-uid","k8s.pod.name":"acc-p1a","k8s.namespace.name":"acc-ns-1","k8s.cluster.name":"cluster-x","k8s.deployment.name":"web-1"},"timestamp":"2025-01-10T10:00:00+00:00","value":0.5,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.cpu.usage","labels":{"k8s.pod.uid":"acc-p1a-uid","k8s.pod.name":"acc-p1a","k8s.namespace.name":"acc-ns-1","k8s.cluster.name":"cluster-x","k8s.deployment.name":"web-1"},"timestamp":"2025-01-10T10:02:00+00:00","value":0.5,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.cpu.usage","labels":{"k8s.pod.uid":"acc-p1a-uid","k8s.pod.name":"acc-p1a","k8s.namespace.name":"acc-ns-1","k8s.cluster.name":"cluster-x","k8s.deployment.name":"web-1"},"timestamp":"2025-01-10T10:04:00+00:00","value":0.5,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.memory.working_set","labels":{"k8s.pod.uid":"acc-p1a-uid","k8s.pod.name":"acc-p1a","k8s.namespace.name":"acc-ns-1","k8s.cluster.name":"cluster-x","k8s.deployment.name":"web-1"},"timestamp":"2025-01-10T10:00:00+00:00","value":100000000.0,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.memory.working_set","labels":{"k8s.pod.uid":"acc-p1a-uid","k8s.pod.name":"acc-p1a","k8s.namespace.name":"acc-ns-1","k8s.cluster.name":"cluster-x","k8s.deployment.name":"web-1"},"timestamp":"2025-01-10T10:02:00+00:00","value":100000000.0,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.memory.working_set","labels":{"k8s.pod.uid":"acc-p1a-uid","k8s.pod.name":"acc-p1a","k8s.namespace.name":"acc-ns-1","k8s.cluster.name":"cluster-x","k8s.deployment.name":"web-1"},"timestamp":"2025-01-10T10:04:00+00:00","value":100000000.0,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-p1a-uid","k8s.pod.name":"acc-p1a","k8s.namespace.name":"acc-ns-1","k8s.cluster.name":"cluster-x","k8s.deployment.name":"web-1"},"timestamp":"2025-01-10T10:00:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-p1a-uid","k8s.pod.name":"acc-p1a","k8s.namespace.name":"acc-ns-1","k8s.cluster.name":"cluster-x","k8s.deployment.name":"web-1"},"timestamp":"2025-01-10T10:02:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-p1a-uid","k8s.pod.name":"acc-p1a","k8s.namespace.name":"acc-ns-1","k8s.cluster.name":"cluster-x","k8s.deployment.name":"web-1"},"timestamp":"2025-01-10T10:04:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.cpu.usage","labels":{"k8s.pod.uid":"acc-p1b-uid","k8s.pod.name":"acc-p1b","k8s.namespace.name":"acc-ns-1","k8s.cluster.name":"cluster-x","k8s.deployment.name":"web-2"},"timestamp":"2025-01-10T10:00:00+00:00","value":0.5,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.cpu.usage","labels":{"k8s.pod.uid":"acc-p1b-uid","k8s.pod.name":"acc-p1b","k8s.namespace.name":"acc-ns-1","k8s.cluster.name":"cluster-x","k8s.deployment.name":"web-2"},"timestamp":"2025-01-10T10:02:00+00:00","value":0.5,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.cpu.usage","labels":{"k8s.pod.uid":"acc-p1b-uid","k8s.pod.name":"acc-p1b","k8s.namespace.name":"acc-ns-1","k8s.cluster.name":"cluster-x","k8s.deployment.name":"web-2"},"timestamp":"2025-01-10T10:04:00+00:00","value":0.5,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.memory.working_set","labels":{"k8s.pod.uid":"acc-p1b-uid","k8s.pod.name":"acc-p1b","k8s.namespace.name":"acc-ns-1","k8s.cluster.name":"cluster-x","k8s.deployment.name":"web-2"},"timestamp":"2025-01-10T10:00:00+00:00","value":100000000.0,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.memory.working_set","labels":{"k8s.pod.uid":"acc-p1b-uid","k8s.pod.name":"acc-p1b","k8s.namespace.name":"acc-ns-1","k8s.cluster.name":"cluster-x","k8s.deployment.name":"web-2"},"timestamp":"2025-01-10T10:02:00+00:00","value":100000000.0,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.memory.working_set","labels":{"k8s.pod.uid":"acc-p1b-uid","k8s.pod.name":"acc-p1b","k8s.namespace.name":"acc-ns-1","k8s.cluster.name":"cluster-x","k8s.deployment.name":"web-2"},"timestamp":"2025-01-10T10:04:00+00:00","value":100000000.0,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-p1b-uid","k8s.pod.name":"acc-p1b","k8s.namespace.name":"acc-ns-1","k8s.cluster.name":"cluster-x","k8s.deployment.name":"web-2"},"timestamp":"2025-01-10T10:00:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-p1b-uid","k8s.pod.name":"acc-p1b","k8s.namespace.name":"acc-ns-1","k8s.cluster.name":"cluster-x","k8s.deployment.name":"web-2"},"timestamp":"2025-01-10T10:02:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-p1b-uid","k8s.pod.name":"acc-p1b","k8s.namespace.name":"acc-ns-1","k8s.cluster.name":"cluster-x","k8s.deployment.name":"web-2"},"timestamp":"2025-01-10T10:04:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.cpu.usage","labels":{"k8s.pod.uid":"acc-p2a-uid","k8s.pod.name":"acc-p2a","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y","k8s.deployment.name":"api"},"timestamp":"2025-01-10T10:00:00+00:00","value":0.75,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.cpu.usage","labels":{"k8s.pod.uid":"acc-p2a-uid","k8s.pod.name":"acc-p2a","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y","k8s.deployment.name":"api"},"timestamp":"2025-01-10T10:02:00+00:00","value":0.75,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.cpu.usage","labels":{"k8s.pod.uid":"acc-p2a-uid","k8s.pod.name":"acc-p2a","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y","k8s.deployment.name":"api"},"timestamp":"2025-01-10T10:04:00+00:00","value":0.75,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.memory.working_set","labels":{"k8s.pod.uid":"acc-p2a-uid","k8s.pod.name":"acc-p2a","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y","k8s.deployment.name":"api"},"timestamp":"2025-01-10T10:00:00+00:00","value":200000000.0,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.memory.working_set","labels":{"k8s.pod.uid":"acc-p2a-uid","k8s.pod.name":"acc-p2a","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y","k8s.deployment.name":"api"},"timestamp":"2025-01-10T10:02:00+00:00","value":200000000.0,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.memory.working_set","labels":{"k8s.pod.uid":"acc-p2a-uid","k8s.pod.name":"acc-p2a","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y","k8s.deployment.name":"api"},"timestamp":"2025-01-10T10:04:00+00:00","value":200000000.0,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-p2a-uid","k8s.pod.name":"acc-p2a","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y","k8s.deployment.name":"api"},"timestamp":"2025-01-10T10:00:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-p2a-uid","k8s.pod.name":"acc-p2a","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y","k8s.deployment.name":"api"},"timestamp":"2025-01-10T10:02:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-p2a-uid","k8s.pod.name":"acc-p2a","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y","k8s.deployment.name":"api"},"timestamp":"2025-01-10T10:04:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.cpu.usage","labels":{"k8s.pod.uid":"acc-p2b-uid","k8s.pod.name":"acc-p2b","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y","k8s.daemonset.name":"agent"},"timestamp":"2025-01-10T10:00:00+00:00","value":0.75,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.cpu.usage","labels":{"k8s.pod.uid":"acc-p2b-uid","k8s.pod.name":"acc-p2b","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y","k8s.daemonset.name":"agent"},"timestamp":"2025-01-10T10:02:00+00:00","value":0.75,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.cpu.usage","labels":{"k8s.pod.uid":"acc-p2b-uid","k8s.pod.name":"acc-p2b","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y","k8s.daemonset.name":"agent"},"timestamp":"2025-01-10T10:04:00+00:00","value":0.75,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.memory.working_set","labels":{"k8s.pod.uid":"acc-p2b-uid","k8s.pod.name":"acc-p2b","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y","k8s.daemonset.name":"agent"},"timestamp":"2025-01-10T10:00:00+00:00","value":200000000.0,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.memory.working_set","labels":{"k8s.pod.uid":"acc-p2b-uid","k8s.pod.name":"acc-p2b","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y","k8s.daemonset.name":"agent"},"timestamp":"2025-01-10T10:02:00+00:00","value":200000000.0,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.memory.working_set","labels":{"k8s.pod.uid":"acc-p2b-uid","k8s.pod.name":"acc-p2b","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y","k8s.daemonset.name":"agent"},"timestamp":"2025-01-10T10:04:00+00:00","value":200000000.0,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-p2b-uid","k8s.pod.name":"acc-p2b","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y","k8s.daemonset.name":"agent"},"timestamp":"2025-01-10T10:00:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-p2b-uid","k8s.pod.name":"acc-p2b","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y","k8s.daemonset.name":"agent"},"timestamp":"2025-01-10T10:02:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-p2b-uid","k8s.pod.name":"acc-p2b","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y","k8s.daemonset.name":"agent"},"timestamp":"2025-01-10T10:04:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.cpu.usage","labels":{"k8s.pod.uid":"acc-p2c-uid","k8s.pod.name":"acc-p2c","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y","k8s.job.name":"batch"},"timestamp":"2025-01-10T10:00:00+00:00","value":0.75,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.cpu.usage","labels":{"k8s.pod.uid":"acc-p2c-uid","k8s.pod.name":"acc-p2c","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y","k8s.job.name":"batch"},"timestamp":"2025-01-10T10:02:00+00:00","value":0.75,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.cpu.usage","labels":{"k8s.pod.uid":"acc-p2c-uid","k8s.pod.name":"acc-p2c","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y","k8s.job.name":"batch"},"timestamp":"2025-01-10T10:04:00+00:00","value":0.75,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.memory.working_set","labels":{"k8s.pod.uid":"acc-p2c-uid","k8s.pod.name":"acc-p2c","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y","k8s.job.name":"batch"},"timestamp":"2025-01-10T10:00:00+00:00","value":200000000.0,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.memory.working_set","labels":{"k8s.pod.uid":"acc-p2c-uid","k8s.pod.name":"acc-p2c","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y","k8s.job.name":"batch"},"timestamp":"2025-01-10T10:02:00+00:00","value":200000000.0,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.memory.working_set","labels":{"k8s.pod.uid":"acc-p2c-uid","k8s.pod.name":"acc-p2c","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y","k8s.job.name":"batch"},"timestamp":"2025-01-10T10:04:00+00:00","value":200000000.0,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-p2c-uid","k8s.pod.name":"acc-p2c","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y","k8s.job.name":"batch"},"timestamp":"2025-01-10T10:00:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-p2c-uid","k8s.pod.name":"acc-p2c","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y","k8s.job.name":"batch"},"timestamp":"2025-01-10T10:02:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"acc-p2c-uid","k8s.pod.name":"acc-p2c","k8s.namespace.name":"acc-ns-2","k8s.cluster.name":"cluster-y","k8s.job.name":"batch"},"timestamp":"2025-01-10T10:04:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}

View File

@@ -4,13 +4,15 @@
"namespaceName": "acc-ns-1",
"namespaceCPU": 1.0,
"namespaceMemory": 200000000.0,
"podCountsByPhase": {"pending": 0, "running": 2, "succeeded": 0, "failed": 0, "unknown": 0}
"podCountsByPhase": {"pending": 0, "running": 2, "succeeded": 0, "failed": 0, "unknown": 0},
"counts": {"deployments": 2, "daemonSets": 0, "jobs": 0, "statefulSets": 0}
},
{
"namespaceName": "acc-ns-2",
"namespaceCPU": 2.25,
"namespaceMemory": 600000000.0,
"podCountsByPhase": {"pending": 0, "running": 3, "succeeded": 0, "failed": 0, "unknown": 0}
"podCountsByPhase": {"pending": 0, "running": 3, "succeeded": 0, "failed": 0, "unknown": 0},
"counts": {"deployments": 1, "daemonSets": 1, "jobs": 1, "statefulSets": 0}
}
]
}

View File

@@ -723,7 +723,74 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
assert response.status_code == HTTPStatus.OK, response.text
assert {d["spec"]["display"]["name"] for d in response.json()["data"]["dashboards"]} == expected, query
# ── stage 5: name sort honours order ─────────────────────────────────────
# ── stage 5: free-text search (bare-word terms) ──────────────────────────
# A bare word is a case-insensitive substring search over the name and every
# tag key/value. Consecutive words are separate terms AND'd together (implicit
# AND); a quoted token matches the whole phrase; and a term composes with
# comparisons via AND/OR.
free_text_cases = [
# name substring, matched case-insensitively
("overview", {"Alpha Overview", "Beta Overview", "Zeta Overview"}),
# matches a name substring on some rows and a tag value on the same rows
("storage", {"Gamma Storage", "Delta Storage"}),
# tag value only (no name contains "pulse")
("pulse", {"Alpha Overview", "Beta Overview", "Zeta Overview"}),
# tag value only
("critical", {"Delta Storage", "Epsilon Metrics"}),
# tag key only
("tier", {"Delta Storage", "Epsilon Metrics"}),
# tag key present on every dashboard
(
"env",
{
"Alpha Overview",
"Beta Overview",
"Gamma Storage",
"Delta Storage",
"Epsilon Metrics",
"Zeta Overview",
},
),
# two words AND'd: only Delta matches both "delta" and "storage"
("delta storage", {"Delta Storage"}),
# two words AND'd with no dashboard matching both
("overview storage", set()),
# a quoted token matches the whole phrase
('"Alpha Overview"', {"Alpha Overview"}),
# a free-text term AND'd with a comparison (the reviewer's case)
("pulse AND env = 'prod'", {"Alpha Overview"}),
# a free-text term OR'd with a comparison
(
"storage OR env = 'staging'",
{"Gamma Storage", "Delta Storage", "Epsilon Metrics", "Zeta Overview"},
),
# no match anywhere
("nonexistent", set()),
# NOT over a term nothing matches returns everything — including these
# description-less dashboards, which a negated search must not exclude.
(
"NOT payment",
{
"Alpha Overview",
"Beta Overview",
"Gamma Storage",
"Delta Storage",
"Epsilon Metrics",
"Zeta Overview",
},
),
]
for query, expected in free_text_cases:
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards"),
params={"query": query, "limit": 200},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert {d["spec"]["display"]["name"] for d in response.json()["data"]["dashboards"]} == expected, query
# ── stage 6: name sort honours order ─────────────────────────────────────
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards"),
params={"sort": "name", "order": "asc", "limit": 200},
@@ -753,7 +820,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
"Alpha Overview",
]
# ── stage 6: pinning floats a dashboard to the top of any ordering ───────
# ── stage 7: pinning floats a dashboard to the top of any ordering ───────
assert (
requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/users/me/dashboards/{ids['lc-gamma']}/pins"),
@@ -791,7 +858,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
]
assert all("pinned" not in d for d in response.json()["data"]["dashboards"])
# ── stage 7: unpinning restores the natural ordering ─────────────────────
# ── stage 8: unpinning restores the natural ordering ─────────────────────
assert (
requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/users/me/dashboards/{ids['lc-gamma']}/pins"),
@@ -815,7 +882,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
"Zeta Overview",
]
# ── stage 8: update mutates the spec but keeps the immutable name ────────
# ── stage 9: update mutates the spec but keeps the immutable name ────────
update_body = {
"schemaVersion": "v6",
"name": "lc-alpha",
@@ -840,7 +907,18 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
)
assert response.json()["data"]["spec"]["display"]["description"] == "now with a description"
# ── stage 9: a locked dashboard rejects updates until unlocked ───────────
# free-text search also matches the description (only Alpha has one now);
# quoted so the phrase matches as one substring rather than per-word
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards"),
params={"query": '"now with a description"', "limit": 200},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert {d["spec"]["display"]["name"] for d in response.json()["data"]["dashboards"]} == {"Alpha Overview"}
# ── stage 10: a locked dashboard rejects updates until unlocked ──────────
assert (
requests.put(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{ids['lc-beta']}/lock"),
@@ -880,7 +958,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
== HTTPStatus.OK
)
# ── stage 10: delete removes the dashboard from get and list ─────────────
# ── stage 11: delete removes the dashboard from get and list ─────────────
assert (
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{ids['lc-gamma']}"),
@@ -912,7 +990,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
"Zeta Overview",
}
# ── stage 11: clone suffixes the display name and mints a new, retrievable one ─
# ── stage 12: clone suffixes the display name and mints a new, retrievable one ─
response = requests.post(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{ids['lc-alpha']}/clone"),
headers={"Authorization": f"Bearer {token}"},

View File

@@ -74,6 +74,7 @@ def test_namespaces_accuracy(
"namespaceCPU",
"namespaceMemory",
"podCountsByPhase",
"counts",
"meta",
):
assert field in record, f"missing {field} in {record!r}"
@@ -81,6 +82,9 @@ def test_namespaces_accuracy(
for bucket in ("pending", "running", "succeeded", "failed", "unknown"):
assert bucket in record["podCountsByPhase"]
assert isinstance(record["podCountsByPhase"][bucket], int)
for bucket in ("deployments", "daemonSets", "jobs", "statefulSets"):
assert bucket in record["counts"]
assert isinstance(record["counts"][bucket], int)
assert record["meta"].get("k8s.namespace.name") == record["namespaceName"]
assert "k8s.cluster.name" in record["meta"]
@@ -90,6 +94,7 @@ def test_namespaces_accuracy(
for field in ("namespaceCPU", "namespaceMemory"):
assert compare_values(record[field], exp[field], 1e-6), f"{record['namespaceName']}.{field}: got {record[field]}, expected {exp[field]}"
assert record["podCountsByPhase"] == exp["podCountsByPhase"]
assert record["counts"] == exp["counts"]
@pytest.mark.parametrize(

View File

@@ -78,6 +78,7 @@ def test_clusters_accuracy(
"clusterMemoryAllocatable",
"nodeCountsByReadiness",
"podCountsByPhase",
"counts",
"meta",
):
assert field in record, f"missing {field} in {record!r}"
@@ -88,6 +89,9 @@ def test_clusters_accuracy(
for bucket in ("pending", "running", "succeeded", "failed", "unknown"):
assert bucket in record["podCountsByPhase"]
assert isinstance(record["podCountsByPhase"][bucket], int)
for bucket in ("nodes", "namespaces", "deployments", "daemonSets", "jobs", "statefulSets"):
assert bucket in record["counts"]
assert isinstance(record["counts"][bucket], int)
assert record["meta"].get("k8s.cluster.name") == record["clusterName"]
@@ -102,6 +106,7 @@ def test_clusters_accuracy(
assert compare_values(record[field], exp[field], 1e-6), f"{record['clusterName']}.{field}: got {record[field]}, expected {exp[field]}"
assert record["nodeCountsByReadiness"] == exp["nodeCountsByReadiness"]
assert record["podCountsByPhase"] == exp["podCountsByPhase"]
assert record["counts"] == exp["counts"]
@pytest.mark.parametrize(