Compare commits

..

4 Commits

Author SHA1 Message Date
Ashwin Bhatkal
7839e806fd fix(dashboard-v2): warn about orphaned panels & surface JSON save errors (#11989)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* fix(dashboard-v2): surface the real API error when a JSON save fails

updateDashboardV2 throws the raw generated error; it was passed to the error
modal cast as APIError, so a rejected save (e.g. a locked dashboard) rendered no
message. Run it through toAPIError so the actual error is shown.

* feat(dashboard-v2): warn when JSON edits desync panels and layouts

Editing the dashboard JSON can leave a panel in spec.panels that no layout
places (orphaned — renders nowhere), or a layout item referencing a panel that
no longer exists. Detect both from the draft (findPanelLayoutIssues, typed off
the spec DTO) and show a footer warning with a triangle icon; hovering lists the
exact panel ids. The tooltip sits above the drawer.

* fix(dashboard-v2): keep editor keystrokes from triggering global shortcuts

Stop keydown propagation out of the JSON drawer so Shift-selection and typing in
the editor don't fire app-level keyboard shortcuts.
2026-07-07 05:35:35 +00:00
Ashwin Bhatkal
2dcd51391d feat(dashboard-v2): rename saved views, org-wide creator filter & visible Locked-view query (#11982)
* feat(dashboard-v2): rename saved views via a shared 128-char name popover

Add a per-row rename action wired to renameView() (existing PUT endpoint), and
extract the save/rename name input into a shared ViewNamePopover capped at 128
chars (replaces SaveViewPopover). Group the rename/delete row actions so they no
longer overlap, patch the renamed view into the list cache inline instead of
refetching, and make the active-view 'Reset' restore the view's opening filters.

* feat(dashboard-v2): show locked = true in the query for the Locked view

The Locked built-in view applied its filter as an invisible server-only clause;
seed it into the search box instead so the constraint is visible and editable,
and drop the now-unused viewQuery plumbing.

* feat(dashboard-v2): source the created-by filter from the org user list

The creator filter only listed authors present on the loaded page, so a user who
hadn't authored a visible dashboard couldn't be selected. Source options from the
v2 List-users API via useCreatorOptions (page authors kept as a fallback until it
resolves), labelled with the user's display name.

* feat(dashboard-v2): show the unsaved-changes dot on the results header

Mirror the views rail's dirty indicator next to the active view name in the
results header when the current filters diverge from the view.
2026-07-07 04:42:20 +00:00
Ashwin Bhatkal
6251616d9d fix(dashboard-v2): add (not replace) description when it is empty (#11983)
A dashboard with no description has no /spec/display/description path, so a
JSON-Patch replace failed and the description update silently no-op-d. Use add
(create-or-replace) when the current description is empty.
2026-07-07 04:41:35 +00:00
Nikhil Mantri
652a9044f4 feat(infra-monitoring): goroutines wrapping custom queries for concurrency (#11981)
* 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
2026-07-07 04:39:08 +00:00
25 changed files with 828 additions and 407 deletions

View File

@@ -149,18 +149,6 @@
line-height: 22px; /* 157.143% */
letter-spacing: -0.07px;
padding: 20px 16px 0px 16px;
/* Preserve author-entered line breaks in the description. */
white-space: pre-wrap;
overflow-wrap: anywhere;
a {
color: var(--accent-primary);
text-decoration: underline;
&:hover {
text-decoration: none;
}
}
}
.dashboard-variables {

View File

@@ -43,7 +43,6 @@ import { sortLayout } from 'providers/Dashboard/util';
import { DashboardData } from 'types/api/dashboard/getAll';
import { Props } from 'types/api/dashboard/update';
import { ROLES, USER_ROLES } from 'types/roles';
import { linkifyText } from 'utils/linkifyText';
import { ComponentTypes } from 'utils/permission';
import { v4 as uuid } from 'uuid';
@@ -516,9 +515,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
</div>
)}
{!isEmpty(description) && (
<section className="dashboard-description-section">
{linkifyText(description ?? '')}
</section>
<section className="dashboard-description-section">{description}</section>
)}
{!isEmpty(dashboardVariables) && (

View File

@@ -32,22 +32,6 @@
cursor: help;
}
.descriptionTooltip {
display: block;
overflow-wrap: anywhere;
/* Preserve author-entered line breaks in the description. */
white-space: pre-wrap;
a {
color: var(--accent-primary);
text-decoration: underline;
&:hover {
text-decoration: none;
}
}
}
.publicLink {
display: inline-flex;
align-items: center;

View File

@@ -14,7 +14,6 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { isEmpty } from 'lodash-es';
import { linkifyText } from 'utils/linkifyText';
import { openInNewTab } from 'utils/navigation';
import styles from './DashboardInfo.module.scss';
@@ -144,14 +143,7 @@ function DashboardInfo({
)}
{hasDescription && (
<TooltipSimple
side="bottom"
title={
<span className={styles.descriptionTooltip}>
{linkifyText(description)}
</span>
}
>
<TooltipSimple title={description} disableHoverableContent>
<SolidInfoCircle
className={styles.descriptionIcon}
size={14}

View File

@@ -48,6 +48,13 @@
gap: 12px;
}
.footerStatus {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.validation {
min-width: 0;
overflow: hidden;
@@ -57,6 +64,32 @@
white-space: nowrap;
}
.danglingWarning {
display: flex;
min-width: 0;
align-items: center;
gap: 4px;
color: var(--bg-amber-400);
font-size: 12px;
}
.warningIcon {
flex: none;
}
.warningText {
overflow: hidden;
color: var(--bg-amber-400);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
// Above the JSON drawer (antd Drawer sits at ~1100) so the id list is visible.
.warningTooltip {
z-index: 1101;
}
.validationValid {
color: var(--bg-forest-400);
}

View File

@@ -1,6 +1,8 @@
import { KeyboardEvent, useCallback } from 'react';
import MEditor from '@monaco-editor/react';
import { TriangleAlert } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { Drawer } from 'antd';
@@ -26,8 +28,18 @@ function JsonEditorDrawer({
}: JsonEditorDrawerProps): JSX.Element {
const [, copyToClipboard] = useCopyToClipboard();
const { draft, setDraft, validity, isDirty, isSaving, format, reset, apply } =
useJsonEditor({ dashboard, isOpen, onApplied: onClose });
const {
draft,
setDraft,
validity,
isDirty,
isSaving,
danglingPanelIds,
missingPanelRefs,
format,
reset,
apply,
} = useJsonEditor({ dashboard, isOpen, onApplied: onClose });
const onCopy = useCallback((): void => {
copyToClipboard(draft);
@@ -48,6 +60,7 @@ function JsonEditorDrawer({
const onKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>): void => {
event.stopPropagation();
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault();
void apply();
@@ -60,6 +73,19 @@ function JsonEditorDrawer({
const validationText = validity.valid
? `Valid JSON · ${validity.lineCount} lines`
: `Line ${validity.errorLine ?? '?'} · ${validity.message ?? 'Invalid JSON'}`;
const plural = (n: number): string => (n === 1 ? '' : 's');
const danglingWarning =
danglingPanelIds.length > 0
? `${danglingPanelIds.length} panel${plural(
danglingPanelIds.length,
)} not present in layout — they won't be shown after saving.`
: null;
const missingRefWarning =
missingPanelRefs.length > 0
? `${missingPanelRefs.length} layout item${plural(
missingPanelRefs.length,
)} ${missingPanelRefs.length === 1 ? 'references' : 'reference'} a panel that no longer exists.`
: null;
return (
<Drawer
@@ -71,15 +97,49 @@ function JsonEditorDrawer({
rootClassName={styles.root}
footer={
<div className={styles.footer}>
<Typography.Text
className={cx(styles.validation, {
[styles.validationValid]: validity.valid,
[styles.validationInvalid]: !validity.valid,
})}
data-testid="json-editor-validation"
>
{validationText}
</Typography.Text>
<div className={styles.footerStatus}>
<Typography.Text
className={cx(styles.validation, {
[styles.validationValid]: validity.valid,
[styles.validationInvalid]: !validity.valid,
})}
data-testid="json-editor-validation"
>
{validationText}
</Typography.Text>
{danglingWarning && (
<TooltipSimple
title={danglingPanelIds.join(', ')}
tooltipContentProps={{ className: styles.warningTooltip }}
>
<span
className={styles.danglingWarning}
data-testid="json-editor-dangling-warning"
>
<TriangleAlert size={12} className={styles.warningIcon} />
<Typography.Text className={styles.warningText}>
{danglingWarning}
</Typography.Text>
</span>
</TooltipSimple>
)}
{missingRefWarning && (
<TooltipSimple
title={missingPanelRefs.join(', ')}
tooltipContentProps={{ className: styles.warningTooltip }}
>
<span
className={styles.danglingWarning}
data-testid="json-editor-missing-ref-warning"
>
<TriangleAlert size={12} className={styles.warningIcon} />
<Typography.Text className={styles.warningText}>
{missingRefWarning}
</Typography.Text>
</span>
</TooltipSimple>
)}
</div>
<div className={styles.footerActions}>
<Button
variant="outlined"

View File

@@ -1,4 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import JsonEditorDrawer from '../JsonEditorDrawer';
@@ -48,11 +49,13 @@ function hookValue(
validity: { valid: true, lineCount: 3 },
isDirty: true,
isSaving: false,
danglingPanelIds: [],
missingPanelRefs: [],
format: jest.fn(),
reset: jest.fn(),
apply: jest.fn().mockResolvedValue(undefined),
...overrides,
};
} as ReturnType<typeof useJsonEditor>;
}
describe('JsonEditorDrawer', () => {
@@ -81,6 +84,42 @@ describe('JsonEditorDrawer', () => {
);
});
it('warns about dangling panels, and hides the warning when there are none', () => {
mockUseJsonEditor.mockReturnValue(
hookValue({ danglingPanelIds: ['p1', 'p2'] }),
);
const { rerender } = render(
<TooltipProvider>
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />
</TooltipProvider>,
);
expect(screen.getByTestId('json-editor-dangling-warning')).toHaveTextContent(
'2 panels not present in layout',
);
mockUseJsonEditor.mockReturnValue(hookValue({ danglingPanelIds: [] }));
rerender(
<TooltipProvider>
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />
</TooltipProvider>,
);
expect(
screen.queryByTestId('json-editor-dangling-warning'),
).not.toBeInTheDocument();
});
it('warns about layout refs to missing panels', () => {
mockUseJsonEditor.mockReturnValue(hookValue({ missingPanelRefs: ['ghost'] }));
render(
<TooltipProvider>
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />
</TooltipProvider>,
);
expect(
screen.getByTestId('json-editor-missing-ref-warning'),
).toHaveTextContent('1 layout item references a panel that no longer exists');
});
it('shows the error line and message when invalid', () => {
mockUseJsonEditor.mockReturnValue(
hookValue({

View File

@@ -0,0 +1,69 @@
import type { DashboardtypesDashboardSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { findPanelLayoutIssues } from '../danglingPanels';
const grid = (refs: string[]): unknown => ({
kind: 'Grid',
spec: { items: refs.map((r) => ({ content: { $ref: r } })) },
});
// Cast a loose fixture to the spec type — the helper is defensive against the
// untrusted, hand-edited JSON it runs on.
const spec = (value: unknown): DashboardtypesDashboardSpecDTO =>
value as DashboardtypesDashboardSpecDTO;
describe('findPanelLayoutIssues', () => {
it('flags nothing when panels and layouts agree', () => {
expect(
findPanelLayoutIssues(
spec({
panels: { a: {}, b: {} },
layouts: [grid(['#/spec/panels/a', '#/spec/panels/b'])],
}),
),
).toStrictEqual({ danglingPanelIds: [], missingPanelRefs: [] });
});
it('lists panels placed in no layout as dangling', () => {
const result = findPanelLayoutIssues(
spec({
panels: { a: {}, b: {}, c: {} },
layouts: [grid(['#/spec/panels/a'])],
}),
);
expect(result.danglingPanelIds.sort()).toStrictEqual(['b', 'c']);
expect(result.missingPanelRefs).toStrictEqual([]);
});
it('treats a removed/empty layout as orphaning every panel', () => {
expect(
findPanelLayoutIssues(
spec({ panels: { a: {}, b: {} }, layouts: [] }),
).danglingPanelIds.sort(),
).toStrictEqual(['a', 'b']);
});
it('lists layout refs to a panel that no longer exists as missing', () => {
const result = findPanelLayoutIssues(
spec({
panels: { a: {} },
layouts: [grid(['#/spec/panels/a', '#/spec/panels/ghost'])],
}),
);
expect(result.danglingPanelIds).toStrictEqual([]);
expect(result.missingPanelRefs).toStrictEqual(['ghost']);
});
it('handles empty / malformed specs', () => {
expect(
findPanelLayoutIssues(spec({ panels: {}, layouts: [] })),
).toStrictEqual({
danglingPanelIds: [],
missingPanelRefs: [],
});
expect(findPanelLayoutIssues(undefined)).toStrictEqual({
danglingPanelIds: [],
missingPanelRefs: [],
});
});
});

View File

@@ -203,4 +203,47 @@ describe('useJsonEditor', () => {
rerender({ isOpen: true });
expect(result.current.draft).toBe(serialized);
});
it('reports panels not placed in any layout as dangling', () => {
const withDangling = {
...dashboard,
spec: { ...dashboard.spec, panels: { p1: {} }, layouts: [] },
} as unknown as DashboardtypesGettableDashboardV2DTO;
const { result } = renderHook(() =>
useJsonEditor({
dashboard: withDangling,
isOpen: true,
onApplied: jest.fn(),
}),
);
expect(result.current.danglingPanelIds).toStrictEqual(['p1']);
expect(result.current.missingPanelRefs).toStrictEqual([]);
});
it('reports layout refs to panels that no longer exist as missing', () => {
const withMissing = {
...dashboard,
spec: {
...dashboard.spec,
panels: {},
layouts: [
{
kind: 'Grid',
spec: { items: [{ content: { $ref: '#/spec/panels/ghost' } }] },
},
],
},
} as unknown as DashboardtypesGettableDashboardV2DTO;
const { result } = renderHook(() =>
useJsonEditor({
dashboard: withMissing,
isOpen: true,
onApplied: jest.fn(),
}),
);
expect(result.current.missingPanelRefs).toStrictEqual(['ghost']);
expect(result.current.danglingPanelIds).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,47 @@
import type {
DashboardtypesDashboardSpecDTO,
DashboardtypesLayoutDTO,
} from 'api/generated/services/sigNoz.schemas';
import { extractPanelIdFromRef } from '../../utils';
export interface PanelLayoutIssues {
// Panels defined in `spec.panels` that no layout places — they render nowhere.
danglingPanelIds: string[];
// Panel ids a layout item references that no longer exist in `spec.panels`.
missingPanelRefs: string[];
}
const referencedPanelIds = (
layouts: DashboardtypesLayoutDTO[],
): Set<string> => {
const referenced = new Set<string>();
layouts.forEach((layout) => {
if (layout?.kind !== 'Grid') {
return;
}
(layout.spec?.items ?? []).forEach((item) => {
const id = extractPanelIdFromRef(item?.content?.$ref);
if (id) {
referenced.add(id);
}
});
});
return referenced;
};
// The two ways a hand-edited spec can desync panels and layouts: a panel with no
// layout slot (renders nowhere), or a layout slot pointing at a panel that was
// removed (broken reference). Guarded for untrusted, user-edited JSON.
export function findPanelLayoutIssues(
spec: DashboardtypesDashboardSpecDTO | undefined,
): PanelLayoutIssues {
const panels = spec?.panels ?? {};
const panelIds = Object.keys(panels);
const referenced = referencedPanelIds(spec?.layouts ?? []);
return {
danglingPanelIds: panelIds.filter((id) => !referenced.has(id)),
missingPanelRefs: [...referenced].filter((id) => !(id in panels)),
};
}

View File

@@ -1,11 +1,15 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { updateDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import type {
DashboardtypesDashboardSpecDTO,
DashboardtypesGettableDashboardV2DTO,
} from 'api/generated/services/sigNoz.schemas';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { toAPIError } from 'utils/errorUtils';
import { dashboardToUpdatable } from './dashboardToUpdatable';
import { findPanelLayoutIssues } from './danglingPanels';
import { useDashboardStore } from '../../store/useDashboardStore';
export interface JsonValidity {
@@ -28,6 +32,10 @@ interface Result {
validity: JsonValidity;
isDirty: boolean;
isSaving: boolean;
// Panel ids in the draft's `spec.panels` referenced by no layout — orphaned.
danglingPanelIds: string[];
// Panel ids a layout references that are missing from the draft's `spec.panels`.
missingPanelRefs: string[];
format: () => void;
reset: () => void;
apply: () => Promise<void>;
@@ -109,6 +117,23 @@ export function useJsonEditor({
const isDirty = draft !== appliedText;
const { danglingPanelIds, missingPanelRefs } = useMemo<{
danglingPanelIds: string[];
missingPanelRefs: string[];
}>(() => {
if (!validity.valid) {
return { danglingPanelIds: [], missingPanelRefs: [] };
}
try {
const parsed = JSON.parse(draft) as {
spec?: DashboardtypesDashboardSpecDTO;
};
return findPanelLayoutIssues(parsed.spec);
} catch {
return { danglingPanelIds: [], missingPanelRefs: [] };
}
}, [draft, validity.valid]);
const format = useCallback((): void => {
try {
setDraft(JSON.stringify(JSON.parse(draft), null, 2));
@@ -138,7 +163,7 @@ export function useJsonEditor({
refetch();
onApplied();
} catch (error) {
showErrorModal(error as APIError);
showErrorModal(toAPIError(error as Parameters<typeof toAPIError>[0]));
} finally {
setIsSaving(false);
}
@@ -159,6 +184,8 @@ export function useJsonEditor({
validity,
isDirty,
isSaving,
danglingPanelIds,
missingPanelRefs,
format,
reset,
apply,

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { DashboardtypesPatchOpDTO } from 'api/generated/services/sigNoz.schemas';
import type {
DashboardtypesGettableDashboardV2DTO,
DashboardtypesJSONPatchOperationDTO,
@@ -54,20 +55,32 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
const buildPatch = useCallback((): DashboardtypesJSONPatchOperationDTO[] => {
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
const op = (
operation: DashboardtypesJSONPatchOperationDTO['op'],
path: string,
value: unknown,
): DashboardtypesJSONPatchOperationDTO => ({ op: operation, path, value });
const replace = (
path: string,
value: unknown,
): DashboardtypesJSONPatchOperationDTO => ({
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
path,
value,
});
): DashboardtypesJSONPatchOperationDTO =>
op(DashboardtypesPatchOpDTO.replace, path, value);
if (updatedTitle !== title && updatedTitle !== '') {
ops.push(replace('/spec/display/name', updatedTitle));
}
if (updatedDescription !== description) {
ops.push(replace('/spec/display/description', updatedDescription));
// `replace` fails when the description doesn't exist yet, so add it when
// the current one is empty (`add` creates or replaces the member).
ops.push(
op(
description
? DashboardtypesPatchOpDTO.replace
: DashboardtypesPatchOpDTO.add,
'/spec/display/description',
updatedDescription,
),
);
}
if (updatedImage !== image) {
ops.push(replace('/image', updatedImage));

View File

@@ -7,6 +7,7 @@ import styles from './DashboardsList.module.scss';
interface Props {
label: string;
count: number;
isModified: boolean;
canCreate: boolean;
onCreate: () => void;
}
@@ -14,6 +15,7 @@ interface Props {
function CommandHeader({
label,
count,
isModified,
canCreate,
onCreate,
}: Props): JSX.Element {
@@ -21,6 +23,7 @@ function CommandHeader({
<div className={styles.commandHeader}>
<div className={styles.headingBlock}>
<Typography.Title className={styles.title}>{label}</Typography.Title>
{isModified && <span className={styles.dirtyDot} title="Unsaved changes" />}
<span className={styles.countPill}>{count}</span>
</div>
<div className={styles.grow} />

View File

@@ -93,6 +93,14 @@
flex: 1;
}
.dirtyDot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--warning-background);
flex: none;
}
.countPill {
padding: 2px 9px;
border-radius: 999px;

View File

@@ -10,9 +10,9 @@ import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useAppContext } from 'providers/App/App';
import { toAPIError } from 'utils/errorUtils';
import { combineQueries } from '../../utils/filterQuery';
import { useAccumulatedTags } from '../../hooks/useAccumulatedTags';
import { useActiveView } from '../../hooks/useActiveView';
import { useCreatorOptions } from '../../hooks/useCreatorOptions';
import { useDashboardFilters } from '../../hooks/useDashboardFilters';
import {
usePage,
@@ -25,7 +25,6 @@ import { BuiltinViewId } from '../../types';
import type { SelectedTag, UpdatedWindow } from '../../types';
import type { DashboardListItem } from '../../utils/helpers';
import { applyClientView } from '../../utils/views';
import type { CreatorOption } from '../FilterZone/FilterChips';
import FilterZone from '../FilterZone/FilterZone';
import NewDashboardModal from '../NewDashboardModal/NewDashboardModal';
import StatusBar from '../StatusBar/StatusBar';
@@ -72,13 +71,13 @@ function DashboardsList(): JSX.Element {
customViewsLoading,
isCustomActive,
isModified,
viewQuery,
clientView,
selectView,
saveView,
saveActiveView,
resetView,
removeView,
renameView,
} = useActiveView({
filters,
applyFilters,
@@ -153,13 +152,13 @@ function DashboardsList(): JSX.Element {
const listParams = useMemo(
() => ({
query: combineQueries(viewQuery, query) || undefined,
query: query || undefined,
sort: sortColumn,
order: sortOrder,
limit: clientView ? CLIENT_VIEW_LIMIT : PAGE_SIZE,
offset: clientView ? 0 : (page - 1) * PAGE_SIZE,
}),
[viewQuery, query, sortColumn, sortOrder, page, clientView],
[query, sortColumn, sortOrder, page, clientView],
);
const {
@@ -195,24 +194,19 @@ function DashboardsList(): JSX.Element {
);
const total = clientView ? dashboards.length : (response?.data?.total ?? 0);
// Creator filter options: distinct authors on the loaded page plus the
// current user (so "me" is always selectable). Page-scoped until a members
// source backs this.
const creatorOptions = useMemo<CreatorOption[]>(() => {
const emails = new Set<string>();
if (user.email) {
emails.add(user.email);
}
rawDashboards.forEach((d) => {
if (d.createdBy) {
emails.add(d.createdBy);
}
});
return [...emails].sort().map((email) => ({
email,
label: email === user.email ? `${email} (me)` : email,
}));
}, [rawDashboards, user.email]);
// Authors present on the loaded page — a fallback for the creator filter until
// the org-wide user list resolves.
const pageAuthorEmails = useMemo<string[]>(
() =>
rawDashboards
.map((d) => d.createdBy)
.filter((email): email is string => !!email),
[rawDashboards],
);
const creatorOptions = useCreatorOptions({
currentUserEmail: user.email,
fallbackEmails: pageAuthorEmails,
});
// All key:value tags the API reports for the org's dashboards, powering the
// Tags filter chip and DSL key suggestions. Accumulated across refetches so
@@ -289,8 +283,8 @@ function DashboardsList(): JSX.Element {
onSave={saveView}
onSaveChanges={saveActiveView}
onReset={handleResetView}
onClearFilters={handleClearAll}
onDelete={handleRemoveView}
onRename={renameView}
/>
<div className={styles.main}>
<div className={styles.mainScroll}>
@@ -305,6 +299,7 @@ function DashboardsList(): JSX.Element {
<CommandHeader
label={activeLabel}
count={total}
isModified={isModified}
canCreate={canCreateNewDashboard}
onCreate={openCreate}
/>

View File

@@ -6,32 +6,45 @@ import { Typography } from '@signozhq/ui/typography';
import styles from './ViewsRail.module.scss';
export const VIEW_NAME_MAX_LENGTH = 128;
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (name: string) => void;
onSubmit: (name: string) => void;
trigger: ReactNode;
title: string;
confirmLabel: string;
initialName?: string;
testIdPrefix?: string;
}
function SaveViewPopover({
// Name-input popover shared by "save as view" and "rename view"; enforces the
// view-name length cap in one place.
function ViewNamePopover({
open,
onOpenChange,
onSave,
onSubmit,
trigger,
title,
confirmLabel,
initialName = '',
testIdPrefix = 'view-name',
}: Props): JSX.Element {
const [name, setName] = useState('');
const [name, setName] = useState(initialName);
useEffect(() => {
if (open) {
setName('');
setName(initialName);
}
}, [open]);
}, [open, initialName]);
const canSave = name.trim().length > 0;
const trimmed = name.trim();
const canSave = trimmed.length > 0 && trimmed.length <= VIEW_NAME_MAX_LENGTH;
const handleSave = (): void => {
if (canSave) {
onSave(name);
onSubmit(trimmed);
onOpenChange(false);
}
};
@@ -44,13 +57,14 @@ function SaveViewPopover({
trigger={trigger}
>
<div className={styles.savePopover}>
<div className={styles.saveTitle}>Save as view</div>
<div className={styles.saveTitle}>{title}</div>
<Typography.Text className={styles.saveLabel}>Name</Typography.Text>
<Input
value={name}
autoFocus
maxLength={VIEW_NAME_MAX_LENGTH}
placeholder="e.g. Prod alerts"
testId="save-view-name"
testId={`${testIdPrefix}-name`}
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
setName(e.target.value)
}
@@ -74,10 +88,10 @@ function SaveViewPopover({
color="primary"
size="sm"
disabled={!canSave}
testId="save-view-confirm"
testId={`${testIdPrefix}-confirm`}
onClick={handleSave}
>
Save view
{confirmLabel}
</Button>
</div>
</div>
@@ -85,4 +99,4 @@ function SaveViewPopover({
);
}
export default SaveViewPopover;
export default ViewNamePopover;

View File

@@ -145,30 +145,40 @@
flex: none;
}
.itemAction {
// Blended ghost icon overlaid on the row's right edge — absolutely positioned
// so it never reserves layout space or affects the row height. Transparent so
// the row's hover background shows through; turns red only on its own hover.
// Ghost action icons overlaid on the row's right edge — absolutely positioned so
// they never reserve layout space or affect row height. Hidden until row hover,
// and inert while hidden so they can't intercept clicks meant for the row.
.itemActions {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
--button-height: 20px;
--button-padding: 0;
--button-border-radius: 4px;
--button-variant-ghost-color: var(--l3-foreground);
--button-variant-ghost-hover-background-color: var(--danger-background);
--button-variant-ghost-hover-color: var(--danger-color, #fff);
width: 20px;
height: 20px;
display: flex;
align-items: center;
gap: 2px;
opacity: 0;
// Hidden until row hover, and inert while hidden so it can't intercept clicks
// meant for the row.
pointer-events: none;
transition: opacity 0.1s;
}
.row:hover .itemAction {
.itemAction {
--button-height: 20px;
--button-padding: 0;
--button-border-radius: 4px;
--button-variant-ghost-color: var(--l3-foreground);
--button-variant-ghost-hover-background-color: var(--l1-background);
--button-variant-ghost-hover-color: var(--l1-foreground);
width: 20px;
height: 20px;
flex: none;
}
.itemActionDanger {
--button-variant-ghost-hover-background-color: var(--danger-background);
--button-variant-ghost-hover-color: var(--danger-color, #fff);
}
.row:hover .itemActions {
opacity: 1;
pointer-events: auto;
}

View File

@@ -3,12 +3,19 @@ import { Modal } from 'antd';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import { Bookmark, CircleAlert, Plus, Search, Trash2 } from '@signozhq/icons';
import {
Bookmark,
CircleAlert,
PenLine,
Plus,
Search,
Trash2,
} from '@signozhq/icons';
import cx from 'classnames';
import type { SavedView } from '../../types';
import { type BuiltinView } from '../../utils/views';
import SaveViewPopover from './SaveViewPopover';
import ViewNamePopover from './ViewNamePopover';
import styles from './ViewsRail.module.scss';
@@ -24,8 +31,8 @@ interface Props {
onSave: (name: string) => void;
onSaveChanges: () => void;
onReset: () => void;
onClearFilters: () => void;
onDelete: (id: string) => void;
onRename: (id: string, name: string) => void;
}
interface ViewRow {
@@ -49,10 +56,11 @@ function ViewsRail({
onSave,
onSaveChanges,
onReset,
onClearFilters,
onDelete,
onRename,
}: Props): JSX.Element {
const [saveOpen, setSaveOpen] = useState(false);
const [renamingId, setRenamingId] = useState<string | null>(null);
const [query, setQuery] = useState('');
const [modal, contextHolder] = Modal.useModal();
@@ -122,20 +130,44 @@ function ViewsRail({
)}
</Button>
{row.deletable && (
<Button
variant="ghost"
color="secondary"
size="icon"
className={styles.itemAction}
aria-label="Delete view"
title="Delete view"
onClick={(e): void => {
e.stopPropagation();
confirmDelete(row.id, row.label);
}}
>
<Trash2 size={12} />
</Button>
<div className={styles.itemActions}>
<ViewNamePopover
open={renamingId === row.id}
onOpenChange={(open): void => setRenamingId(open ? row.id : null)}
onSubmit={(name): void => onRename(row.id, name)}
title="Rename view"
confirmLabel="Rename"
initialName={row.label}
testIdPrefix="rename-view"
trigger={
<Button
variant="ghost"
color="secondary"
size="icon"
className={styles.itemAction}
aria-label="Rename view"
title="Rename view"
onClick={(e): void => e.stopPropagation()}
>
<PenLine size={12} />
</Button>
}
/>
<Button
variant="ghost"
color="secondary"
size="icon"
className={cx(styles.itemAction, styles.itemActionDanger)}
aria-label="Delete view"
title="Delete view"
onClick={(e): void => {
e.stopPropagation();
confirmDelete(row.id, row.label);
}}
>
<Trash2 size={12} />
</Button>
</div>
)}
</div>
);
@@ -145,10 +177,13 @@ function ViewsRail({
<aside className={cx(styles.rail, { [styles.collapsed]: collapsed })}>
<div className={styles.header}>
<h4 className={styles.headerTitle}>Views</h4>
<SaveViewPopover
<ViewNamePopover
open={saveOpen}
onOpenChange={setSaveOpen}
onSave={onSave}
onSubmit={onSave}
title="Save as view"
confirmLabel="Save view"
testIdPrefix="save-view"
trigger={
<Button
variant="ghost"
@@ -268,13 +303,8 @@ function ViewsRail({
>
Save as new view
</Button>
<Button
variant="ghost"
color="secondary"
size="sm"
onClick={onClearFilters}
>
Clear
<Button variant="ghost" color="secondary" size="sm" onClick={onReset}>
Reset
</Button>
</div>
</div>

View File

@@ -7,7 +7,6 @@ import type {
import {
areFilterStatesEqual,
combineQueries,
DEFAULT_FILTER_STATE,
filterStateToQuery,
} from '../utils/filterQuery';
@@ -15,7 +14,6 @@ import { BuiltinViewId } from '../types';
import type { DashboardFilterState, SavedView } from '../types';
import {
BUILTIN_VIEWS,
builtinViewQuery,
builtinViewSnapshot,
type BuiltinView,
isClientView,
@@ -42,15 +40,14 @@ export interface UseActiveViewResult {
isCustomActive: boolean;
// Current filters diverge from the active view's canonical snapshot.
isModified: boolean;
// Extra server-query fragment the active view contributes, and whether it
// constrains the list client-side (pinned/recent).
viewQuery: string;
// Whether the active view constrains the list client-side (pinned/recent).
clientView: boolean;
selectView: (id: string) => void;
saveView: (name: string) => void;
saveActiveView: () => void;
resetView: () => void;
removeView: (id: string) => void;
renameView: (id: string, name: string) => void;
}
// The canonical filter snapshot a saved view "is": the backend stores a flat
@@ -127,11 +124,9 @@ export function useActiveView({
const saveView = useCallback(
(name: string): void => {
// Fold the current built-in clause + chips into a single query string.
const query = combineQueries(
builtinViewQuery(activeViewId),
filterStateToQuery(filters),
);
// The active view's clause already lives in the filter state (e.g. Locked
// seeds `locked = true` into search), so the chips fold into one query.
const query = filterStateToQuery(filters);
void (async (): Promise<void> => {
const created = await createView({
name,
@@ -147,15 +142,7 @@ export function useActiveView({
}
})();
},
[
activeViewId,
filters,
createView,
sortColumn,
sortOrder,
setActiveViewId,
applyFilters,
],
[filters, createView, sortColumn, sortOrder, setActiveViewId, applyFilters],
);
const saveActiveView = useCallback((): void => {
@@ -200,6 +187,23 @@ export function useActiveView({
[deleteView, activeViewId, setActiveViewId, applyFilters],
);
// Rename only touches the view's name; its stored query/sort/order are preserved.
const renameView = useCallback(
(id: string, name: string): void => {
const view = customViews.find((v) => v.id === id);
if (!view) {
return;
}
updateView(id, {
name,
query: view.query,
sort: view.sort,
order: view.order,
});
},
[customViews, updateView],
);
return {
activeViewId,
builtinViews: BUILTIN_VIEWS,
@@ -207,12 +211,12 @@ export function useActiveView({
customViewsLoading,
isCustomActive: !!activeCustom,
isModified,
viewQuery: builtinViewQuery(activeViewId),
clientView: isClientView(activeViewId),
selectView,
saveView,
saveActiveView,
resetView,
removeView,
renameView,
};
}

View File

@@ -0,0 +1,43 @@
import { useMemo } from 'react';
import { useListUsers } from 'api/generated/services/users';
import type { CreatorOption } from '../components/FilterZone/FilterChips';
interface Args {
currentUserEmail: string;
// Authors on the loaded page — kept selectable until the org list resolves.
fallbackEmails: string[];
}
// Creator-filter options sourced from the org's full user list, so authors who
// aren't on the current page are still selectable (v2 "List users" API).
export function useCreatorOptions({
currentUserEmail,
fallbackEmails,
}: Args): CreatorOption[] {
const { data } = useListUsers();
return useMemo<CreatorOption[]>(() => {
const users = data?.data ?? [];
const emails = new Set<string>();
if (currentUserEmail) {
emails.add(currentUserEmail);
}
users.forEach((u) => u.email && emails.add(u.email));
// Until the org list resolves (or if it comes back empty), keep the page's
// authors selectable so the filter never regresses to just "me".
if (users.length === 0) {
fallbackEmails.forEach((e) => emails.add(e));
}
const labelFor = (email: string): string => {
if (email === currentUserEmail) {
return `${email} (me)`;
}
const match = users.find((u) => u.email === email);
return match?.displayName ? `${match.displayName} (${email})` : email;
};
return [...emails].sort().map((email) => ({ email, label: labelFor(email) }));
}, [data, currentUserEmail, fallbackEmails]);
}

View File

@@ -2,6 +2,7 @@ import { useCallback, useMemo } from 'react';
import { useQueryClient } from 'react-query';
import { toast } from '@signozhq/ui/sonner';
import {
getListDashboardViewsQueryKey,
invalidateListDashboardViews,
useCreateDashboardView,
useDeleteDashboardView,
@@ -12,6 +13,7 @@ import {
type DashboardtypesDashboardViewDTO,
DashboardtypesListOrderDTO,
DashboardtypesListSortDTO,
type ListDashboardViews200,
} from 'api/generated/services/sigNoz.schemas';
import type { SavedView, SavedViewInput } from '../types';
@@ -70,9 +72,27 @@ export function useSavedViews(): UseSavedViewsResult {
},
},
});
// Rename/save-changes returns the updated view, so patch it into the cached
// list inline instead of refetching the whole list.
const updateMutation = useUpdateDashboardView({
mutation: {
onSuccess: invalidate,
onSuccess: (response): void => {
const updated = response?.data;
const key = getListDashboardViewsQueryKey();
const prev = queryClient.getQueryData<ListDashboardViews200>(key);
if (!updated || !prev) {
return;
}
queryClient.setQueryData<ListDashboardViews200>(key, {
...prev,
data: {
...prev.data,
views: (prev.data.views ?? []).map((v) =>
v.id === updated.id ? updated : v,
),
},
});
},
onError: (): void => {
toast.error('Failed to update view.');
},

View File

@@ -1,7 +1,7 @@
// Built-in view catalogue + the pure logic that maps a view to how it
// constrains the list. Views fall into three mechanisms:
// - snapshot: selecting applies a filter snapshot (All, My dashboards, custom)
// - query: contributes an extra server clause AND-ed with the chips (Locked)
// constrains the list. Views fall into two mechanisms:
// - snapshot: selecting applies a filter snapshot — some seed the search box
// with DSL (My dashboards → created_by; Locked → locked = true)
// - client: constrains by a client-side id set (Favorites, Recently viewed)
import { Clock, Layers, Lock, Pin, User } from '@signozhq/icons';
@@ -49,9 +49,9 @@ export const BUILTIN_VIEWS: BuiltinView[] = [
export const isClientView = (id: string): boolean =>
id === BuiltinViewId.Pinned || id === BuiltinViewId.Recent;
// Extra server query fragment a built-in view contributes (AND-ed with chips).
export const builtinViewQuery = (id: string): string =>
id === BuiltinViewId.Locked ? 'locked = true' : '';
// DSL the Locked view seeds into the search box, so the constraint is visible
// (and editable) rather than applied invisibly behind the scenes.
export const LOCKED_QUERY = 'locked = true';
// The canonical filter snapshot a built-in view applies when selected. `null`
// for ids that aren't built-in (custom views carry their own snapshot).
@@ -65,10 +65,11 @@ export const builtinViewSnapshot = (
...DEFAULT_FILTER_STATE,
createdBy: userEmail ? [userEmail] : [],
};
case BuiltinViewId.Locked:
return { ...DEFAULT_FILTER_STATE, search: LOCKED_QUERY };
case BuiltinViewId.All:
case BuiltinViewId.Pinned:
case BuiltinViewId.Recent:
case BuiltinViewId.Locked:
return { ...DEFAULT_FILTER_STATE };
default:
return null;

View File

@@ -1,66 +0,0 @@
import { render, screen } from '@testing-library/react';
import { linkifyText } from '../linkifyText';
describe('linkifyText', () => {
it('returns plain text unchanged when there are no links', () => {
render(<div>{linkifyText('just a plain description')}</div>);
expect(screen.getByText('just a plain description')).toBeInTheDocument();
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
it('wraps an http(s) URL in an anchor that opens in a new tab', () => {
render(<div>{linkifyText('see https://signoz.io/docs for more')}</div>);
const link = screen.getByRole('link', { name: 'https://signoz.io/docs' });
expect(link).toHaveAttribute('href', 'https://signoz.io/docs');
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
});
it('prefixes bare www. links with https://', () => {
render(<div>{linkifyText('visit www.signoz.io')}</div>);
const link = screen.getByRole('link', { name: 'www.signoz.io' });
expect(link).toHaveAttribute('href', 'https://www.signoz.io');
});
it('keeps trailing punctuation outside the link', () => {
render(<div>{linkifyText('read https://signoz.io.')}</div>);
const link = screen.getByRole('link', { name: 'https://signoz.io' });
expect(link).toHaveAttribute('href', 'https://signoz.io');
});
it('linkifies multiple URLs in the same string', () => {
render(
<div>{linkifyText('a https://one.com and b https://two.com end')}</div>,
);
expect(screen.getAllByRole('link')).toHaveLength(2);
expect(
screen.getByRole('link', { name: 'https://one.com' }),
).toBeInTheDocument();
expect(
screen.getByRole('link', { name: 'https://two.com' }),
).toBeInTheDocument();
});
it('preserves newlines around a link', () => {
const { container } = render(
<div>{linkifyText('line one\nsee https://signoz.io\nline three')}</div>,
);
expect(container.textContent).toBe(
'line one\nsee https://signoz.io\nline three',
);
expect(
screen.getByRole('link', { name: 'https://signoz.io' }),
).toHaveAttribute('href', 'https://signoz.io');
});
it('returns an empty string unchanged', () => {
expect(linkifyText('')).toBe('');
});
});

View File

@@ -1,71 +0,0 @@
import { Fragment, MouseEvent, ReactNode } from 'react';
/** Matches http(s) URLs and bare www. links up to the next whitespace. */
const URL_REGEX = /((?:https?:\/\/|www\.)[^\s]+)/gi;
/** Trailing punctuation that is almost never part of the intended URL. */
const TRAILING_PUNCTUATION = /[.,;:!?)\]}'"]+$/;
const stopPropagation = (
event: MouseEvent<HTMLAnchorElement, globalThis.MouseEvent>,
): void => {
// Prevent parent click listeners (e.g. title edit) from firing.
event.stopPropagation();
};
/**
* Splits `text` into plain-text and anchor segments, wrapping any detected
* URL in an anchor that opens in a new tab. Trailing punctuation is kept
* outside the link so sentences like "see https://signoz.io." stay clean.
*/
export function linkifyText(text: string): ReactNode {
if (!text) {
return text;
}
const segments: ReactNode[] = [];
let lastIndex = 0;
let key = 0;
const matches = text.matchAll(URL_REGEX);
for (const match of matches) {
const matchStart = match.index ?? 0;
const rawUrl = match[0];
const trailing = rawUrl.match(TRAILING_PUNCTUATION)?.[0] ?? '';
const url = trailing ? rawUrl.slice(0, -trailing.length) : rawUrl;
const href = url.startsWith('www.') ? `https://${url}` : url;
if (matchStart > lastIndex) {
segments.push(
<Fragment key={key}>{text.slice(lastIndex, matchStart)}</Fragment>,
);
key += 1;
}
segments.push(
<a
key={key}
href={href}
rel="noopener noreferrer"
target="_blank"
onClick={stopPropagation}
>
{url}
</a>,
);
key += 1;
if (trailing) {
segments.push(<Fragment key={key}>{trailing}</Fragment>);
key += 1;
}
lastIndex = matchStart + rawUrl.length;
}
if (lastIndex < text.length) {
segments.push(<Fragment key={key}>{text.slice(lastIndex)}</Fragment>);
}
return segments;
}

View File

@@ -17,6 +17,7 @@ import (
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"golang.org/x/sync/errgroup"
)
type module struct {
@@ -213,23 +214,37 @@ func (m *module) ListHosts(ctx context.Context, orgID valuer.UUID, req *inframon
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, hostsFilterExpr, req.GroupBy, pageGroups, m.newListHostsQuery())
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
if err != nil {
return nil, err
}
// Compute per-group active/inactive host counts.
// When host.name is in groupBy, each row = one host, so counts are derived
// directly from activeHostsMap in buildHostRecords (no extra query needed).
// When host.name is not in groupBy, we need to run an additional query to get the counts per group for the current page,
// using the same filter expression as the main query (including user filters + page groups IN clause).
hostCounts := make(map[string]groupHostStatusCounts)
isHostNameInGroupBy := isKeyInGroupByAttrs(req.GroupBy, inframonitoringtypes.HostNameAttrKey)
var (
queryResp *qbtypes.QueryRangeResponse
hostCounts = make(map[string]groupHostStatusCounts)
)
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
var err error
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
if !isHostNameInGroupBy {
hostCounts, err = m.getPerGroupHostStatusCounts(ctx, orgID, req, hostsTableMetricNamesList, pageGroups, sinceUnixMilli)
if err != nil {
return nil, err
}
g.Go(func() error {
var err error
hostCounts, err = m.getPerGroupHostStatusCounts(gCtx, orgID, req, hostsTableMetricNamesList, pageGroups, sinceUnixMilli)
return err
})
}
if err := g.Wait(); err != nil {
return nil, err
}
resp.Records = buildHostRecords(isHostNameInGroupBy, queryResp, pageGroups, req.GroupBy, metadataMap, activeHostsMap, hostCounts)
@@ -299,23 +314,39 @@ func (m *module) ListPods(ctx context.Context, orgID valuer.UUID, req *inframoni
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newPodsTableListQuery())
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
if err != nil {
return nil, err
}
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
var (
queryResp *qbtypes.QueryRangeResponse
phaseCounts map[string]podPhaseCounts
statusCounts map[string]podStatusCounts
statusWarning *qbtypes.QueryWarnData
restartCounts map[string]int64
)
statusCounts, statusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
g, gCtx := errgroup.WithContext(ctx)
restartCounts, err := m.getPerGroupPodRestartCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
g.Go(func() error {
var err error
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
g.Go(func() error {
var err error
phaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
statusCounts, statusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
restartCounts, err = m.getPerGroupPodRestartCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
if err := g.Wait(); err != nil {
return nil, err
}
@@ -387,23 +418,39 @@ func (m *module) ListNodes(ctx context.Context, orgID valuer.UUID, req *inframon
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newNodesTableListQuery())
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
if err != nil {
return nil, err
}
nodeConditionCounts, err := m.getPerGroupNodeConditionCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
var (
queryResp *qbtypes.QueryRangeResponse
nodeConditionCounts map[string]nodeConditionCounts
podPhaseCounts map[string]podPhaseCounts
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
)
podPhaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
g, gCtx := errgroup.WithContext(ctx)
podStatusCounts, podStatusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
g.Go(func() error {
var err error
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
g.Go(func() error {
var err error
nodeConditionCounts, err = m.getPerGroupNodeConditionCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
podPhaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
if err := g.Wait(); err != nil {
return nil, err
}
@@ -475,18 +522,33 @@ func (m *module) ListNamespaces(ctx context.Context, orgID valuer.UUID, req *inf
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newNamespacesTableListQuery())
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
if err != nil {
return nil, err
}
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
var (
queryResp *qbtypes.QueryRangeResponse
phaseCounts map[string]podPhaseCounts
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
)
podStatusCounts, podStatusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
var err error
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
g.Go(func() error {
var err error
phaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
if err := g.Wait(); err != nil {
return nil, err
}
@@ -557,25 +619,41 @@ func (m *module) ListClusters(ctx context.Context, orgID valuer.UUID, req *infra
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newClustersTableListQuery())
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
if err != nil {
return nil, err
}
// With default groupBy [k8s.cluster.name], counts are bucketed per cluster;
// with a custom groupBy, they aggregate across clusters in that group.
nodeConditionCountsMap, err := m.getPerGroupNodeConditionCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
var (
queryResp *qbtypes.QueryRangeResponse
nodeConditionCountsMap map[string]nodeConditionCounts
podPhaseCountsMap map[string]podPhaseCounts
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
)
podPhaseCountsMap, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
g, gCtx := errgroup.WithContext(ctx)
podStatusCounts, podStatusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
g.Go(func() error {
var err error
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
g.Go(func() error {
var err error
nodeConditionCountsMap, err = m.getPerGroupNodeConditionCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
podPhaseCountsMap, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
if err := g.Wait(); err != nil {
return nil, err
}
@@ -730,18 +808,33 @@ func (m *module) ListDeployments(ctx context.Context, orgID valuer.UUID, req *in
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newDeploymentsTableListQuery())
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
if err != nil {
return nil, err
}
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
var (
queryResp *qbtypes.QueryRangeResponse
phaseCounts map[string]podPhaseCounts
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
)
podStatusCounts, podStatusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
var err error
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
g.Go(func() error {
var err error
phaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
if err := g.Wait(); err != nil {
return nil, err
}
@@ -818,20 +911,35 @@ func (m *module) ListStatefulSets(ctx context.Context, orgID valuer.UUID, req *i
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newStatefulSetsTableListQuery())
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
if err != nil {
return nil, err
}
// Pods owned by a StatefulSet carry k8s.statefulset.name as a resource attribute,
// so default-groupBy gives per-statefulset phase counts automatically.
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
var (
queryResp *qbtypes.QueryRangeResponse
phaseCounts map[string]podPhaseCounts
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
)
podStatusCounts, podStatusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
var err error
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
g.Go(func() error {
var err error
phaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
if err := g.Wait(); err != nil {
return nil, err
}
@@ -908,20 +1016,35 @@ func (m *module) ListJobs(ctx context.Context, orgID valuer.UUID, req *inframoni
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newJobsTableListQuery())
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
if err != nil {
return nil, err
}
// Pods owned by a Job carry k8s.job.name as a resource attribute, so default-groupBy
// gives per-job phase counts automatically.
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
var (
queryResp *qbtypes.QueryRangeResponse
phaseCounts map[string]podPhaseCounts
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
)
podStatusCounts, podStatusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
var err error
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
g.Go(func() error {
var err error
phaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
if err := g.Wait(); err != nil {
return nil, err
}
@@ -998,20 +1121,35 @@ func (m *module) ListDaemonSets(ctx context.Context, orgID valuer.UUID, req *inf
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newDaemonSetsTableListQuery())
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
if err != nil {
return nil, err
}
// Pods owned by a DaemonSet carry k8s.daemonset.name as a resource attribute,
// so default-groupBy gives per-daemonset phase counts automatically.
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
var (
queryResp *qbtypes.QueryRangeResponse
phaseCounts map[string]podPhaseCounts
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
)
podStatusCounts, podStatusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
var err error
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
g.Go(func() error {
var err error
phaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
if err := g.Wait(); err != nil {
return nil, err
}