Compare commits

..

2 Commits

Author SHA1 Message Date
Ashwin Bhatkal
67b892af5b feat(dashboard): linkify URLs in V1 dashboard description
Apply the linkifyText util to the V1 dashboard description section so
pasted URLs become clickable, matching the V2 behaviour. Preserve line
breaks and wrap long URLs.
2026-07-07 10:45:47 +05:30
Ashwin Bhatkal
0b37cf58e2 feat(dashboard-v2): linkify URLs in dashboard description tooltip
Parse http(s) and www. links in the dashboard description and render
them as clickable anchors opening in a new tab. Add a reusable
linkifyText util under src/utils. Preserve author line breaks and show
the tooltip below the info icon.
2026-07-07 10:33:33 +05:30
25 changed files with 404 additions and 825 deletions

View File

@@ -149,6 +149,18 @@
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,6 +43,7 @@ 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';
@@ -515,7 +516,9 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
</div>
)}
{!isEmpty(description) && (
<section className="dashboard-description-section">{description}</section>
<section className="dashboard-description-section">
{linkifyText(description ?? '')}
</section>
)}
{!isEmpty(dashboardVariables) && (

View File

@@ -32,6 +32,22 @@
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,6 +14,7 @@ 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';
@@ -143,7 +144,14 @@ function DashboardInfo({
)}
{hasDescription && (
<TooltipSimple title={description} disableHoverableContent>
<TooltipSimple
side="bottom"
title={
<span className={styles.descriptionTooltip}>
{linkifyText(description)}
</span>
}
>
<SolidInfoCircle
className={styles.descriptionIcon}
size={14}

View File

@@ -48,13 +48,6 @@
gap: 12px;
}
.footerStatus {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.validation {
min-width: 0;
overflow: hidden;
@@ -64,32 +57,6 @@
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,8 +1,6 @@
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';
@@ -28,18 +26,8 @@ function JsonEditorDrawer({
}: JsonEditorDrawerProps): JSX.Element {
const [, copyToClipboard] = useCopyToClipboard();
const {
draft,
setDraft,
validity,
isDirty,
isSaving,
danglingPanelIds,
missingPanelRefs,
format,
reset,
apply,
} = useJsonEditor({ dashboard, isOpen, onApplied: onClose });
const { draft, setDraft, validity, isDirty, isSaving, format, reset, apply } =
useJsonEditor({ dashboard, isOpen, onApplied: onClose });
const onCopy = useCallback((): void => {
copyToClipboard(draft);
@@ -60,7 +48,6 @@ function JsonEditorDrawer({
const onKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>): void => {
event.stopPropagation();
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault();
void apply();
@@ -73,19 +60,6 @@ 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
@@ -97,49 +71,15 @@ function JsonEditorDrawer({
rootClassName={styles.root}
footer={
<div className={styles.footer}>
<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>
<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.footerActions}>
<Button
variant="outlined"

View File

@@ -1,5 +1,4 @@
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';
@@ -49,13 +48,11 @@ 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', () => {
@@ -84,42 +81,6 @@ 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

@@ -1,69 +0,0 @@
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,47 +203,4 @@ 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

@@ -1,47 +0,0 @@
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,15 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { updateDashboardV2 } from 'api/generated/services/dashboard';
import type {
DashboardtypesDashboardSpecDTO,
DashboardtypesGettableDashboardV2DTO,
} from 'api/generated/services/sigNoz.schemas';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { toAPIError } from 'utils/errorUtils';
import APIError from 'types/api/error';
import { dashboardToUpdatable } from './dashboardToUpdatable';
import { findPanelLayoutIssues } from './danglingPanels';
import { useDashboardStore } from '../../store/useDashboardStore';
export interface JsonValidity {
@@ -32,10 +28,6 @@ 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>;
@@ -117,23 +109,6 @@ 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));
@@ -163,7 +138,7 @@ export function useJsonEditor({
refetch();
onApplied();
} catch (error) {
showErrorModal(toAPIError(error as Parameters<typeof toAPIError>[0]));
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
@@ -184,8 +159,6 @@ export function useJsonEditor({
validity,
isDirty,
isSaving,
danglingPanelIds,
missingPanelRefs,
format,
reset,
apply,

View File

@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { DashboardtypesPatchOpDTO } from 'api/generated/services/sigNoz.schemas';
import type {
DashboardtypesGettableDashboardV2DTO,
DashboardtypesJSONPatchOperationDTO,
@@ -55,32 +54,20 @@ 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(DashboardtypesPatchOpDTO.replace, path, value);
): DashboardtypesJSONPatchOperationDTO => ({
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
path,
value,
});
if (updatedTitle !== title && updatedTitle !== '') {
ops.push(replace('/spec/display/name', updatedTitle));
}
if (updatedDescription !== description) {
// `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,
),
);
ops.push(replace('/spec/display/description', updatedDescription));
}
if (updatedImage !== image) {
ops.push(replace('/image', updatedImage));

View File

@@ -7,7 +7,6 @@ import styles from './DashboardsList.module.scss';
interface Props {
label: string;
count: number;
isModified: boolean;
canCreate: boolean;
onCreate: () => void;
}
@@ -15,7 +14,6 @@ interface Props {
function CommandHeader({
label,
count,
isModified,
canCreate,
onCreate,
}: Props): JSX.Element {
@@ -23,7 +21,6 @@ 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,14 +93,6 @@
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,6 +25,7 @@ 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';
@@ -71,13 +72,13 @@ function DashboardsList(): JSX.Element {
customViewsLoading,
isCustomActive,
isModified,
viewQuery,
clientView,
selectView,
saveView,
saveActiveView,
resetView,
removeView,
renameView,
} = useActiveView({
filters,
applyFilters,
@@ -152,13 +153,13 @@ function DashboardsList(): JSX.Element {
const listParams = useMemo(
() => ({
query: query || undefined,
query: combineQueries(viewQuery, query) || undefined,
sort: sortColumn,
order: sortOrder,
limit: clientView ? CLIENT_VIEW_LIMIT : PAGE_SIZE,
offset: clientView ? 0 : (page - 1) * PAGE_SIZE,
}),
[query, sortColumn, sortOrder, page, clientView],
[viewQuery, query, sortColumn, sortOrder, page, clientView],
);
const {
@@ -194,19 +195,24 @@ function DashboardsList(): JSX.Element {
);
const total = clientView ? dashboards.length : (response?.data?.total ?? 0);
// 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,
});
// 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]);
// 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
@@ -283,8 +289,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}>
@@ -299,7 +305,6 @@ function DashboardsList(): JSX.Element {
<CommandHeader
label={activeLabel}
count={total}
isModified={isModified}
canCreate={canCreateNewDashboard}
onCreate={openCreate}
/>

View File

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

View File

@@ -145,40 +145,30 @@
flex: none;
}
// 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 {
.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.
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
gap: 2px;
opacity: 0;
pointer-events: none;
transition: opacity 0.1s;
}
.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);
width: 20px;
height: 20px;
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 .itemActions {
.row:hover .itemAction {
opacity: 1;
pointer-events: auto;
}

View File

@@ -3,19 +3,12 @@ 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,
PenLine,
Plus,
Search,
Trash2,
} from '@signozhq/icons';
import { Bookmark, CircleAlert, Plus, Search, Trash2 } from '@signozhq/icons';
import cx from 'classnames';
import type { SavedView } from '../../types';
import { type BuiltinView } from '../../utils/views';
import ViewNamePopover from './ViewNamePopover';
import SaveViewPopover from './SaveViewPopover';
import styles from './ViewsRail.module.scss';
@@ -31,8 +24,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 {
@@ -56,11 +49,10 @@ 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();
@@ -130,44 +122,20 @@ function ViewsRail({
)}
</Button>
{row.deletable && (
<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>
<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>
);
@@ -177,13 +145,10 @@ function ViewsRail({
<aside className={cx(styles.rail, { [styles.collapsed]: collapsed })}>
<div className={styles.header}>
<h4 className={styles.headerTitle}>Views</h4>
<ViewNamePopover
<SaveViewPopover
open={saveOpen}
onOpenChange={setSaveOpen}
onSubmit={onSave}
title="Save as view"
confirmLabel="Save view"
testIdPrefix="save-view"
onSave={onSave}
trigger={
<Button
variant="ghost"
@@ -303,8 +268,13 @@ function ViewsRail({
>
Save as new view
</Button>
<Button variant="ghost" color="secondary" size="sm" onClick={onReset}>
Reset
<Button
variant="ghost"
color="secondary"
size="sm"
onClick={onClearFilters}
>
Clear
</Button>
</div>
</div>

View File

@@ -7,6 +7,7 @@ import type {
import {
areFilterStatesEqual,
combineQueries,
DEFAULT_FILTER_STATE,
filterStateToQuery,
} from '../utils/filterQuery';
@@ -14,6 +15,7 @@ import { BuiltinViewId } from '../types';
import type { DashboardFilterState, SavedView } from '../types';
import {
BUILTIN_VIEWS,
builtinViewQuery,
builtinViewSnapshot,
type BuiltinView,
isClientView,
@@ -40,14 +42,15 @@ export interface UseActiveViewResult {
isCustomActive: boolean;
// Current filters diverge from the active view's canonical snapshot.
isModified: boolean;
// Whether the active view constrains the list client-side (pinned/recent).
// Extra server-query fragment the active view contributes, and whether it
// constrains the list client-side (pinned/recent).
viewQuery: string;
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
@@ -124,9 +127,11 @@ export function useActiveView({
const saveView = useCallback(
(name: string): void => {
// 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);
// Fold the current built-in clause + chips into a single query string.
const query = combineQueries(
builtinViewQuery(activeViewId),
filterStateToQuery(filters),
);
void (async (): Promise<void> => {
const created = await createView({
name,
@@ -142,7 +147,15 @@ export function useActiveView({
}
})();
},
[filters, createView, sortColumn, sortOrder, setActiveViewId, applyFilters],
[
activeViewId,
filters,
createView,
sortColumn,
sortOrder,
setActiveViewId,
applyFilters,
],
);
const saveActiveView = useCallback((): void => {
@@ -187,23 +200,6 @@ 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,
@@ -211,12 +207,12 @@ export function useActiveView({
customViewsLoading,
isCustomActive: !!activeCustom,
isModified,
viewQuery: builtinViewQuery(activeViewId),
clientView: isClientView(activeViewId),
selectView,
saveView,
saveActiveView,
resetView,
removeView,
renameView,
};
}

View File

@@ -1,43 +0,0 @@
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,7 +2,6 @@ import { useCallback, useMemo } from 'react';
import { useQueryClient } from 'react-query';
import { toast } from '@signozhq/ui/sonner';
import {
getListDashboardViewsQueryKey,
invalidateListDashboardViews,
useCreateDashboardView,
useDeleteDashboardView,
@@ -13,7 +12,6 @@ import {
type DashboardtypesDashboardViewDTO,
DashboardtypesListOrderDTO,
DashboardtypesListSortDTO,
type ListDashboardViews200,
} from 'api/generated/services/sigNoz.schemas';
import type { SavedView, SavedViewInput } from '../types';
@@ -72,27 +70,9 @@ 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: (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,
),
},
});
},
onSuccess: invalidate,
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 two mechanisms:
// - snapshot: selecting applies a filter snapshot — some seed the search box
// with DSL (My dashboards → created_by; Locked → locked = true)
// 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)
// - 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;
// 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';
// Extra server query fragment a built-in view contributes (AND-ed with chips).
export const builtinViewQuery = (id: string): string =>
id === BuiltinViewId.Locked ? '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,11 +65,10 @@ 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

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

@@ -0,0 +1,71 @@
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,7 +17,6 @@ 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 {
@@ -214,37 +213,23 @@ 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 {
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
hostCounts, err = m.getPerGroupHostStatusCounts(ctx, orgID, req, hostsTableMetricNamesList, pageGroups, sinceUnixMilli)
if err != nil {
return nil, err
}
}
resp.Records = buildHostRecords(isHostNameInGroupBy, queryResp, pageGroups, req.GroupBy, metadataMap, activeHostsMap, hostCounts)
@@ -314,39 +299,23 @@ 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
}
var (
queryResp *qbtypes.QueryRangeResponse
phaseCounts map[string]podPhaseCounts
statusCounts map[string]podStatusCounts
statusWarning *qbtypes.QueryWarnData
restartCounts map[string]int64
)
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
g, gCtx := errgroup.WithContext(ctx)
statusCounts, statusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
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 {
restartCounts, err := m.getPerGroupPodRestartCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
@@ -418,39 +387,23 @@ 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
}
var (
queryResp *qbtypes.QueryRangeResponse
nodeConditionCounts map[string]nodeConditionCounts
podPhaseCounts map[string]podPhaseCounts
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
)
nodeConditionCounts, err := m.getPerGroupNodeConditionCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
g, gCtx := errgroup.WithContext(ctx)
podPhaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
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 {
podStatusCounts, podStatusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
@@ -522,33 +475,18 @@ 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
}
var (
queryResp *qbtypes.QueryRangeResponse
phaseCounts map[string]podPhaseCounts
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
)
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
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 {
podStatusCounts, podStatusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
@@ -619,41 +557,25 @@ 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.
var (
queryResp *qbtypes.QueryRangeResponse
nodeConditionCountsMap map[string]nodeConditionCounts
podPhaseCountsMap map[string]podPhaseCounts
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
)
nodeConditionCountsMap, err := m.getPerGroupNodeConditionCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
g, gCtx := errgroup.WithContext(ctx)
podPhaseCountsMap, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
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 {
podStatusCounts, podStatusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
@@ -808,33 +730,18 @@ 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
}
var (
queryResp *qbtypes.QueryRangeResponse
phaseCounts map[string]podPhaseCounts
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
)
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
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 {
podStatusCounts, podStatusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
@@ -911,35 +818,20 @@ 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.
var (
queryResp *qbtypes.QueryRangeResponse
phaseCounts map[string]podPhaseCounts
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
)
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
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 {
podStatusCounts, podStatusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
@@ -1016,35 +908,20 @@ 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.
var (
queryResp *qbtypes.QueryRangeResponse
phaseCounts map[string]podPhaseCounts
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
)
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
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 {
podStatusCounts, podStatusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
@@ -1121,35 +998,20 @@ 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.
var (
queryResp *qbtypes.QueryRangeResponse
phaseCounts map[string]podPhaseCounts
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
)
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
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 {
podStatusCounts, podStatusWarning, err := m.getPerGroupPodStatusCountsWithReqMetricChecks(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}