Compare commits

..

1 Commits

Author SHA1 Message Date
swapnil-signoz
cc99247ad3 feat: adding support for Azure Container Apps 2026-06-04 13:02:12 +05:30
42 changed files with 1296 additions and 1907 deletions

View File

@@ -1261,6 +1261,7 @@ components:
- sqs
- storageaccountsblob
- cdnprofile
- containerapp
type: string
CloudintegrationtypesServiceMetadata:
properties:

View File

@@ -2574,6 +2574,7 @@ export enum CloudintegrationtypesServiceIDDTO {
sqs = 'sqs',
storageaccountsblob = 'storageaccountsblob',
cdnprofile = 'cdnprofile',
containerapp = 'containerapp',
}
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
/**

View File

@@ -6,10 +6,6 @@ import { EllipsisVertical } from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import type { DashboardSection } from '../../utils';
import type { DeletePanelArgs } from './hooks/useDeletePanel';
import type { MovePanelArgs } from './hooks/useMovePanelToSection';
import PanelActionsMenu from './PanelActionsMenu/PanelActionsMenu';
import styles from './Panel.module.scss';
interface Props {
@@ -21,22 +17,9 @@ interface Props {
* data. Currently unused on purpose.
*/
isVisible?: boolean;
/** Section actions — present only in editable sectioned mode. */
currentLayoutIndex?: number;
sections?: DashboardSection[];
onMovePanel?: (args: MovePanelArgs) => void;
onDeletePanel?: (args: DeletePanelArgs) => void;
}
function Panel({
panel,
panelId,
isVisible,
currentLayoutIndex,
sections,
onMovePanel,
onDeletePanel,
}: Props): JSX.Element {
function Panel({ panel, panelId, isVisible }: Props): JSX.Element {
const name = panel?.spec?.display?.name || `Panel ${panelId.slice(0, 6)}`;
const description = panel?.spec?.display?.description;
const kind = panel?.spec?.plugin?.kind?.replace(/^signoz\//, '') ?? 'unknown';
@@ -65,17 +48,7 @@ function Panel({
</Typography.Text>
<Badge className={styles.badge}>{kind}</Badge>
</div>
{currentLayoutIndex !== undefined && (onMovePanel || onDeletePanel) ? (
<PanelActionsMenu
panelId={panelId}
currentLayoutIndex={currentLayoutIndex}
sections={sections ?? []}
onMovePanel={onMovePanel}
onDeletePanel={onDeletePanel}
/>
) : (
<EllipsisVertical size={14} />
)}
<EllipsisVertical size={14} />
</div>
<div className={styles.body}>

View File

@@ -1,16 +0,0 @@
.trigger {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px;
background: transparent;
border: none;
border-radius: 2px;
color: var(--bg-vanilla-400, #8993ae);
cursor: pointer;
&:hover {
color: var(--bg-vanilla-100, #fff);
background: var(--bg-slate-400, #1d212d);
}
}

View File

@@ -1,95 +0,0 @@
import { useMemo } from 'react';
import { EllipsisVertical, FolderInput, Trash2 } from '@signozhq/icons';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import type { DashboardSection } from '../../../utils';
import type { DeletePanelArgs } from '../hooks/useDeletePanel';
import type { MovePanelArgs } from '../hooks/useMovePanelToSection';
import styles from './PanelActionsMenu.module.scss';
interface Props {
panelId: string;
currentLayoutIndex: number;
sections: DashboardSection[];
onMovePanel?: (args: MovePanelArgs) => void;
onDeletePanel?: (args: DeletePanelArgs) => void;
}
function PanelActionsMenu({
panelId,
currentLayoutIndex,
sections,
onMovePanel,
onDeletePanel,
}: Props): JSX.Element {
const items = useMemo<MenuItem[]>(() => {
const result: MenuItem[] = [];
if (onMovePanel) {
const targets = sections.filter(
(s) => s.title && s.layoutIndex !== currentLayoutIndex,
);
if (targets.length === 0) {
result.push({
key: 'move',
label: 'Move to section',
icon: <FolderInput size={14} />,
disabled: true,
});
} else {
result.push({
key: 'move',
label: 'Move to section',
icon: <FolderInput size={14} />,
children: targets.map((s) => ({
key: `move-${s.layoutIndex}`,
label: s.title,
onClick: (): void =>
onMovePanel({
panelId,
fromLayoutIndex: currentLayoutIndex,
toLayoutIndex: s.layoutIndex,
}),
})),
});
}
}
if (onDeletePanel) {
if (result.length > 0) {
result.push({ type: 'divider' });
}
result.push({
key: 'delete-panel',
danger: true,
icon: <Trash2 size={14} />,
label: 'Delete panel',
onClick: (): void =>
onDeletePanel({ panelId, layoutIndex: currentLayoutIndex }),
});
}
return result;
}, [sections, currentLayoutIndex, panelId, onMovePanel, onDeletePanel]);
return (
<DropdownMenuSimple menu={{ items }}>
<button
type="button"
className={styles.trigger}
aria-label="Panel actions"
data-testid={`panel-actions-${panelId}`}
// Stop pointer/mouse down from reaching the RGL drag handle this
// button lives inside, so opening the menu never starts a panel drag.
onPointerDown={(e): void => e.stopPropagation()}
onMouseDown={(e): void => e.stopPropagation()}
onClick={(e): void => e.stopPropagation()}
>
<EllipsisVertical size={14} />
</button>
</DropdownMenuSimple>
);
}
export default PanelActionsMenu;

View File

@@ -1,22 +0,0 @@
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.typeButton {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
background: var(--bg-ink-400, #0b0c0e);
border: 1px solid var(--bg-slate-400, #1d212d);
border-radius: 4px;
color: var(--bg-vanilla-100, #fff);
cursor: pointer;
text-align: left;
&:hover {
border-color: var(--bg-robin-500);
}
}

View File

@@ -1,82 +0,0 @@
import { Modal } from 'antd';
import {
BarChart,
ChartLine,
ChartPie,
Hash,
List,
Table,
} from '@signozhq/icons';
import styles from './PanelTypeSelectionModal.module.scss';
interface PanelType {
pluginKind: string;
label: string;
icon: JSX.Element;
}
const PANEL_TYPES: PanelType[] = [
{
pluginKind: 'signoz/TimeSeriesPanel',
label: 'Time Series',
icon: <ChartLine size={16} />,
},
{ pluginKind: 'signoz/NumberPanel', label: 'Value', icon: <Hash size={16} /> },
{ pluginKind: 'signoz/TablePanel', label: 'Table', icon: <Table size={16} /> },
{
pluginKind: 'signoz/BarChartPanel',
label: 'Bar Chart',
icon: <BarChart size={16} />,
},
{
pluginKind: 'signoz/PieChartPanel',
label: 'Pie Chart',
icon: <ChartPie size={16} />,
},
{
pluginKind: 'signoz/HistogramPanel',
label: 'Histogram',
icon: <BarChart size={16} />,
},
{ pluginKind: 'signoz/ListPanel', label: 'List', icon: <List size={16} /> },
];
interface Props {
open: boolean;
onClose: () => void;
onSelect: (pluginKind: string) => void;
}
function PanelTypeSelectionModal({
open,
onClose,
onSelect,
}: Props): JSX.Element {
return (
<Modal
open={open}
title="Select a panel type"
onCancel={onClose}
footer={null}
destroyOnClose
>
<div className={styles.grid}>
{PANEL_TYPES.map((type) => (
<button
key={type.pluginKind}
type="button"
className={styles.typeButton}
data-testid={`panel-type-${type.pluginKind}`}
onClick={(): void => onSelect(type.pluginKind)}
>
{type.icon}
{type.label}
</button>
))}
</div>
</Modal>
);
}
export default PanelTypeSelectionModal;

View File

@@ -1,76 +0,0 @@
import { useCallback } from 'react';
import { v4 as uuid } from 'uuid';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import {
addPanelToSectionOps,
createDefaultPanel,
panelRef,
} from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
interface Params {
sections: DashboardSection[];
}
export interface AddPanelArgs {
layoutIndex: number;
pluginKind: string;
}
/**
* Creates a new panel and places its item ref at the bottom of the target
* section, as one atomic patch. Structure-only: the panel is a valid minimal
* placeholder (its query is filled in once the panel editor lands).
*/
export function useAddPanelToSection({
sections,
}: Params): (args: AddPanelArgs) => Promise<void> {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const { showErrorModal } = useErrorModal();
return useCallback(
async ({ layoutIndex, pluginKind }: AddPanelArgs): Promise<void> => {
if (!dashboardId) {
return;
}
const target = sections.find((s) => s.layoutIndex === layoutIndex);
if (!target) {
return;
}
const panelId = uuid();
const nextY = target.items.reduce(
(max, i) => Math.max(max, i.y + i.height),
0,
);
try {
await patchDashboardV2(
{ id: dashboardId },
addPanelToSectionOps({
panelId,
panel: createDefaultPanel(pluginKind),
layoutIndex,
item: {
x: 0,
y: nextY,
width: 6,
height: 6,
content: { $ref: panelRef(panelId) },
},
}),
);
refetch();
} catch (error) {
showErrorModal(error as APIError);
}
},
[sections, dashboardId, refetch, showErrorModal],
);
}

View File

@@ -1,54 +0,0 @@
import { useCallback } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { removePanelOp, replaceSectionItemsOp } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
interface Params {
sections: DashboardSection[];
}
export interface DeletePanelArgs {
panelId: string;
layoutIndex: number;
}
/**
* Removes a panel: drops its item ref from the section's items and deletes the
* panel from `spec.panels`, as one atomic patch.
*/
export function useDeletePanel({
sections,
}: Params): (args: DeletePanelArgs) => Promise<void> {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const { showErrorModal } = useErrorModal();
return useCallback(
async ({ panelId, layoutIndex }: DeletePanelArgs): Promise<void> => {
if (!dashboardId) {
return;
}
const section = sections.find((s) => s.layoutIndex === layoutIndex);
if (!section) {
return;
}
const nextItems = section.items.filter((i) => i.id !== panelId);
try {
await patchDashboardV2({ id: dashboardId }, [
replaceSectionItemsOp(layoutIndex, nextItems),
removePanelOp(panelId),
]);
refetch();
} catch (error) {
showErrorModal(error as APIError);
}
},
[sections, dashboardId, refetch, showErrorModal],
);
}

View File

@@ -1,79 +0,0 @@
import { useCallback } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { movePanelBetweenSectionsOps } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
export interface MovePanelArgs {
panelId: string;
fromLayoutIndex: number;
toLayoutIndex: number;
}
interface Params {
sections: DashboardSection[];
}
/**
* Relocates a panel's item ref from one section to another. The panel itself
* stays in `spec.panels`; only the grid item moves, dropped into a free row at
* the bottom of the target section. Persisted as one atomic patch.
*/
export function useMovePanelToSection({
sections,
}: Params): (args: MovePanelArgs) => Promise<void> {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const { showErrorModal } = useErrorModal();
return useCallback(
async ({
panelId,
fromLayoutIndex,
toLayoutIndex,
}: MovePanelArgs): Promise<void> => {
if (!dashboardId || fromLayoutIndex === toLayoutIndex) {
return;
}
const source = sections.find((s) => s.layoutIndex === fromLayoutIndex);
const target = sections.find((s) => s.layoutIndex === toLayoutIndex);
if (!source || !target) {
return;
}
const moved = source.items.find((i) => i.id === panelId);
if (!moved) {
return;
}
const sourceItems = source.items.filter((i) => i.id !== panelId);
// Place at a fresh row at the bottom of the target section.
const nextY = target.items.reduce(
(max, i) => Math.max(max, i.y + i.height),
0,
);
const targetItems = [...target.items, { ...moved, x: 0, y: nextY }];
try {
await patchDashboardV2(
{ id: dashboardId },
movePanelBetweenSectionsOps({
sourceIndex: fromLayoutIndex,
sourceItems,
targetIndex: toLayoutIndex,
targetItems,
}),
);
refetch();
} catch (error) {
showErrorModal(error as APIError);
}
},
[sections, dashboardId, refetch, showErrorModal],
);
}

View File

@@ -1,17 +0,0 @@
.addButton {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 8px;
padding: 8px 12px;
background: transparent;
border: 1px dashed var(--bg-slate-400, #1d212d);
border-radius: 4px;
color: var(--bg-vanilla-400, #8993ae);
cursor: pointer;
&:hover {
border-color: var(--bg-robin-500);
color: var(--bg-vanilla-100, #fff);
}
}

View File

@@ -1,67 +0,0 @@
import { useState } from 'react';
import { Plus } from '@signozhq/icons';
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
import type { DashboardSection } from '../../../utils';
import { useAddSection } from '../hooks/useAddSection';
import { useFirstSectionMigration } from '../hooks/useFirstSectionMigration';
import FirstSectionMigrationModal from '../FirstSectionMigrationModal';
import styles from './AddSectionControl.module.scss';
const DEFAULT_SECTION_TITLE = 'New section';
interface Props {
sections: DashboardSection[];
layouts: DashboardtypesLayoutDTO[] | undefined | null;
isSectioned: boolean;
}
function AddSectionControl({
sections,
layouts,
isSectioned,
}: Props): JSX.Element {
const [isMigrationOpen, setIsMigrationOpen] = useState(false);
const { addSection } = useAddSection({ layouts });
const { migrate, isSaving } = useFirstSectionMigration({ sections });
// Free-flowing dashboard with existing panels → must migrate before sections
// can coexist (every panel must belong to a section once any exists).
const needsMigration =
!isSectioned && sections.some((s) => s.items.length > 0);
const handleClick = (): void => {
if (needsMigration) {
setIsMigrationOpen(true);
return;
}
void addSection(DEFAULT_SECTION_TITLE);
};
const handleConfirmMigration = async (): Promise<void> => {
await migrate(DEFAULT_SECTION_TITLE);
setIsMigrationOpen(false);
};
return (
<>
<button
type="button"
className={styles.addButton}
onClick={handleClick}
data-testid="add-section"
>
<Plus size={14} />
Add section
</button>
<FirstSectionMigrationModal
open={isMigrationOpen}
isSaving={isSaving}
onClose={(): void => setIsMigrationOpen(false)}
onConfirm={handleConfirmMigration}
/>
</>
);
}
export default AddSectionControl;

View File

@@ -1,41 +0,0 @@
import { Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
interface Props {
open: boolean;
isSaving: boolean;
onClose: () => void;
onConfirm: () => void;
}
/**
* Shown when the user adds the first section to a free-flowing dashboard that
* already has panels. Confirms grouping the existing panels into a section
* before proceeding.
*/
function FirstSectionMigrationModal({
open,
isSaving,
onClose,
onConfirm,
}: Props): JSX.Element {
return (
<Modal
open={open}
title="Group panels into sections?"
onCancel={onClose}
onOk={onConfirm}
okText="Continue"
okButtonProps={{ disabled: isSaving, 'data-testid': 'confirm-migration' }}
destroyOnClose
>
<Typography.Text>
This dashboard&apos;s panels are currently free-flowing. Adding a section
will move the existing panels into their own section, and a new empty
section will be added below. You can rename sections afterwards.
</Typography.Text>
</Modal>
);
}
export default FirstSectionMigrationModal;

View File

@@ -1,64 +0,0 @@
import { useEffect, useState } from 'react';
import { Modal } from 'antd';
import { Input } from '@signozhq/ui/input';
interface Props {
open: boolean;
initialValue: string;
isSaving: boolean;
onClose: () => void;
onSubmit: (title: string) => void;
}
function RenameSectionModal({
open,
initialValue,
isSaving,
onClose,
onSubmit,
}: Props): JSX.Element {
const [value, setValue] = useState<string>(initialValue);
// Reseed the field each time the modal opens.
useEffect(() => {
if (open) {
setValue(initialValue);
}
}, [open, initialValue]);
const submit = (): void => {
const trimmed = value.trim();
if (trimmed) {
onSubmit(trimmed);
}
};
return (
<Modal
open={open}
title="Rename section"
onCancel={onClose}
onOk={submit}
okText="Rename"
okButtonProps={{ disabled: isSaving || !value.trim() }}
destroyOnClose
>
<Input
testId="rename-section-input"
autoFocus
value={value}
maxLength={120}
placeholder="Section name"
onChange={(e): void => setValue(e.target.value)}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
e.preventDefault();
submit();
}
}}
/>
</Modal>
);
}
export default RenameSectionModal;

View File

@@ -1,45 +1,17 @@
import { useRef, useState } from 'react';
import { Modal } from 'antd';
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
import type { DashboardSection } from '../../../utils';
import type { AddPanelArgs } from '../../Panel/hooks/useAddPanelToSection';
import type { DeletePanelArgs } from '../../Panel/hooks/useDeletePanel';
import type { MovePanelArgs } from '../../Panel/hooks/useMovePanelToSection';
import PanelTypeSelectionModal from '../../Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
import { useDashboardStore } from '../../../store/useDashboardStore';
import { useDeleteSection } from '../hooks/useDeleteSection';
import { useRenameSection } from '../hooks/useRenameSection';
import { useToggleSectionCollapse } from '../hooks/useToggleSectionCollapse';
import RenameSectionModal from '../RenameSectionModal';
import SectionGrid from '../SectionGrid/SectionGrid';
import SectionHeader, {
type SectionDragHandle,
} from '../SectionHeader/SectionHeader';
import SectionHeader from '../SectionHeader/SectionHeader';
import styles from './Section.module.scss';
interface Props {
section: DashboardSection;
/** Adds a panel to this section; present only in editable sectioned mode. */
onAddPanel?: (args: AddPanelArgs) => void;
/** All sections + per-panel handlers, for the panel "Move to section" / delete actions. */
sections?: DashboardSection[];
onMovePanel?: (args: MovePanelArgs) => void;
onDeletePanel?: (args: DeletePanelArgs) => void;
/** Provided by SortableSection in sectioned mode; absent for untitled/free-flow. */
dragHandle?: SectionDragHandle;
}
function Section({
section,
onAddPanel,
sections,
onMovePanel,
onDeletePanel,
dragHandle,
}: Props): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
function Section({ section }: Props): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null);
// Placeholder signal for lazy panel query-loading (consumed in a later PR):
// true once the section scrolls into (or near) the viewport.
@@ -47,48 +19,10 @@ function Section({
rootMargin: '200px',
});
const { open, toggle } = useToggleSectionCollapse({ sectionId: section.id });
const [open, setOpen] = useState<boolean>(section.open);
const toggle = (): void => setOpen((prev) => !prev);
const [isRenaming, setIsRenaming] = useState(false);
const { rename, isSaving } = useRenameSection({
layoutIndex: section.layoutIndex,
});
const handleRenameSubmit = async (title: string): Promise<void> => {
const ok = await rename(title);
if (ok) {
setIsRenaming(false);
}
};
const [isAddingPanel, setIsAddingPanel] = useState(false);
const handleSelectPanelType = (pluginKind: string): void => {
onAddPanel?.({ layoutIndex: section.layoutIndex, pluginKind });
setIsAddingPanel(false);
};
const { deleteSection } = useDeleteSection({ section });
const confirmDeleteSection = (): void => {
Modal.confirm({
title: `Delete section "${section.title ?? ''}"?`,
content: 'Panels in this section will be removed.',
okText: 'Delete',
okButtonProps: { danger: true },
centered: true,
onOk: () => deleteSection(),
});
};
const grid = (
<SectionGrid
items={section.items}
layoutIndex={section.layoutIndex}
isVisible={isVisible}
sections={sections}
onMovePanel={onMovePanel}
onDeletePanel={onDeletePanel}
/>
);
const grid = <SectionGrid items={section.items} isVisible={isVisible} />;
if (!section.title) {
// Untitled section — just the grid (no header chrome), but still observed
@@ -117,26 +51,8 @@ function Section({
open={open}
onToggle={toggle}
repeatVariable={section.repeatVariable}
dragHandle={dragHandle}
onRename={isEditable ? (): void => setIsRenaming(true) : undefined}
onAddPanel={
isEditable && onAddPanel ? (): void => setIsAddingPanel(true) : undefined
}
onDeleteSection={isEditable ? confirmDeleteSection : undefined}
/>
{open ? grid : null}
<RenameSectionModal
open={isRenaming}
initialValue={section.title}
isSaving={isSaving}
onClose={(): void => setIsRenaming(false)}
onSubmit={handleRenameSubmit}
/>
<PanelTypeSelectionModal
open={isAddingPanel}
onClose={(): void => setIsAddingPanel(false)}
onSelect={handleSelectPanelType}
/>
</div>
);
}

View File

@@ -1,16 +0,0 @@
.trigger {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px;
background: transparent;
border: none;
border-radius: 2px;
color: var(--bg-vanilla-400, #8993ae);
cursor: pointer;
&:hover {
color: var(--bg-vanilla-100, #fff);
background: var(--bg-slate-400, #1d212d);
}
}

View File

@@ -1,68 +0,0 @@
import { useMemo } from 'react';
import { EllipsisVertical, PenLine, Plus, Trash2 } from '@signozhq/icons';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import styles from './SectionActionsMenu.module.scss';
interface Props {
sectionId: string;
onAddPanel?: () => void;
onRename?: () => void;
onDeleteSection?: () => void;
}
function SectionActionsMenu({
sectionId,
onAddPanel,
onRename,
onDeleteSection,
}: Props): JSX.Element {
const items = useMemo<MenuItem[]>(() => {
const result: MenuItem[] = [];
if (onAddPanel) {
result.push({
key: 'add-panel',
icon: <Plus size={14} />,
label: 'Add panel',
onClick: onAddPanel,
});
}
if (onRename) {
result.push({
key: 'rename',
icon: <PenLine size={14} />,
label: 'Rename section',
onClick: onRename,
});
}
if (onDeleteSection) {
result.push(
{ type: 'divider' },
{
key: 'delete-section',
danger: true,
icon: <Trash2 size={14} />,
label: 'Delete section',
onClick: onDeleteSection,
},
);
}
return result;
}, [onAddPanel, onRename, onDeleteSection]);
return (
<DropdownMenuSimple menu={{ items }}>
<button
type="button"
className={styles.trigger}
aria-label="Section actions"
data-testid={`dashboard-section-actions-${sectionId}`}
>
<EllipsisVertical size={14} />
</button>
</DropdownMenuSimple>
);
}
export default SectionActionsMenu;

View File

@@ -1,7 +0,0 @@
.preview {
border: 1px solid var(--bg-robin-500);
border-radius: 4px;
background: var(--bg-ink-400, #0b0c0e);
box-shadow: 0 8px 24px rgb(0 0 0 / 40%);
cursor: grabbing;
}

View File

@@ -1,32 +0,0 @@
import type { DashboardSection } from '../../../utils';
import SectionHeader from '../SectionHeader/SectionHeader';
import styles from './SectionDragPreview.module.scss';
interface Props {
section: DashboardSection;
}
/**
* Lightweight preview rendered inside the DragOverlay while a section is being
* dragged. Deliberately header-only (no react-grid-layout) so the overlay is
* cheap and never triggers RGL width re-measurement.
*/
function SectionDragPreview({ section }: Props): JSX.Element {
const panelCount = section.items.length;
const title = `${section.title ?? ''} · ${panelCount} ${
panelCount === 1 ? 'panel' : 'panels'
}`;
return (
<div className={styles.preview}>
<SectionHeader
sectionId={`${section.id}-preview`}
title={title}
open={false}
onToggle={(): void => undefined}
/>
</div>
);
}
export default SectionDragPreview;

View File

@@ -2,35 +2,18 @@ import { useMemo } from 'react';
import GridLayout, { WidthProvider, type Layout } from 'react-grid-layout';
import type { DashboardSection } from '../../../utils';
import type { DeletePanelArgs } from '../../Panel/hooks/useDeletePanel';
import type { MovePanelArgs } from '../../Panel/hooks/useMovePanelToSection';
import Panel from '../../Panel/Panel';
import { useDashboardStore } from '../../../store/useDashboardStore';
import { usePersistLayout } from '../hooks/usePersistLayout';
import styles from './SectionGrid.module.scss';
const ResponsiveGridLayout = WidthProvider(GridLayout);
interface Props {
items: DashboardSection['items'];
layoutIndex: number;
/** Forwarded to panels — true when the parent section is in the viewport. */
isVisible?: boolean;
/** All sections + handlers — present only in editable sectioned mode (panel "Move to section" / delete). */
sections?: DashboardSection[];
onMovePanel?: (args: MovePanelArgs) => void;
onDeletePanel?: (args: DeletePanelArgs) => void;
}
function SectionGrid({
items,
layoutIndex,
isVisible,
sections,
onMovePanel,
onDeletePanel,
}: Props): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
function SectionGrid({ items, isVisible }: Props): JSX.Element {
const rglLayout = useMemo<Layout[]>(
() =>
items.map((item) => ({
@@ -43,8 +26,6 @@ function SectionGrid({
[items],
);
const { handleLayoutChange } = usePersistLayout({ layoutIndex, items });
return (
<ResponsiveGridLayout
className={styles.grid}
@@ -53,24 +34,13 @@ function SectionGrid({
autoSize
useCSSTransforms
layout={rglLayout}
draggableHandle=".panel-drag-handle"
isDraggable={isEditable}
isResizable={isEditable}
onDragStop={handleLayoutChange}
onResizeStop={handleLayoutChange}
isDraggable={false}
isResizable={false}
margin={[8, 8]}
>
{items.map((item) => (
<div key={item.id}>
<Panel
panel={item.panel}
panelId={item.id}
isVisible={isVisible}
currentLayoutIndex={layoutIndex}
sections={isEditable ? sections : undefined}
onMovePanel={isEditable ? onMovePanel : undefined}
onDeletePanel={isEditable ? onDeletePanel : undefined}
/>
<Panel panel={item.panel} panelId={item.id} isVisible={isVisible} />
</div>
))}
</ResponsiveGridLayout>

View File

@@ -1,29 +1,15 @@
import type { DraggableAttributes } from '@dnd-kit/core';
import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities';
import { ChevronDown, ChevronRight, GripVertical } from '@signozhq/icons';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import SectionActionsMenu from '../SectionActionsMenu/SectionActionsMenu';
import styles from './SectionHeader.module.scss';
export interface SectionDragHandle {
attributes: DraggableAttributes;
listeners: SyntheticListenerMap | undefined;
setActivatorNodeRef: (element: HTMLElement | null) => void;
}
interface Props {
sectionId: string;
title: string;
open: boolean;
onToggle: () => void;
repeatVariable?: string;
/** Provided by SortableSection in sectioned mode; absent for untitled/free-flow. */
dragHandle?: SectionDragHandle;
onRename?: () => void;
onAddPanel?: () => void;
onDeleteSection?: () => void;
}
function SectionHeader({
@@ -32,27 +18,9 @@ function SectionHeader({
open,
onToggle,
repeatVariable,
dragHandle,
onRename,
onAddPanel,
onDeleteSection,
}: Props): JSX.Element {
const hasActions = !!(onAddPanel || onRename || onDeleteSection);
return (
<div className={cx(styles.header, { [styles.headerOpen]: open })}>
{dragHandle ? (
<button
type="button"
className={styles.dragHandle}
ref={dragHandle.setActivatorNodeRef}
aria-label="Drag to reorder section"
data-testid={`dashboard-section-drag-${sectionId}`}
{...dragHandle.attributes}
{...dragHandle.listeners}
>
<GripVertical size={14} />
</button>
) : null}
<button
type="button"
className={styles.toggle}
@@ -67,14 +35,6 @@ function SectionHeader({
</Typography.Text>
) : null}
</button>
{hasActions ? (
<SectionActionsMenu
sectionId={sectionId}
onAddPanel={onAddPanel}
onRename={onRename}
onDeleteSection={onDeleteSection}
/>
) : null}
</div>
);
}

View File

@@ -1,102 +0,0 @@
import { useMemo } from 'react';
import { closestCenter, DndContext, DragOverlay } from '@dnd-kit/core';
import {
restrictToParentElement,
restrictToVerticalAxis,
} from '@dnd-kit/modifiers';
import {
SortableContext,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
import type { DashboardSection } from '../../utils';
import { useAddPanelToSection } from '../Panel/hooks/useAddPanelToSection';
import { useDeletePanel } from '../Panel/hooks/useDeletePanel';
import { useMovePanelToSection } from '../Panel/hooks/useMovePanelToSection';
import { useDashboardStore } from '../../store/useDashboardStore';
import { useSectionDragReorder } from './hooks/useSectionDragReorder';
import Section from './Section/Section';
import SectionDragPreview from './SectionDragPreview/SectionDragPreview';
import SortableSection from './SortableSection';
interface Props {
sections: DashboardSection[];
layouts: DashboardtypesLayoutDTO[] | undefined | null;
}
function SectionList({ sections, layouts }: Props): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const {
sensors,
orderedSections,
activeSection,
onDragStart,
onDragEnd,
onDragCancel,
} = useSectionDragReorder({ sections, layouts });
const onAddPanel = useAddPanelToSection({ sections });
const onMovePanel = useMovePanelToSection({ sections });
const onDeletePanel = useDeletePanel({ sections });
// Only titled sections participate in reordering; untitled (free-flow)
// blocks render in place without a drag handle.
const sortableIds = useMemo(
() => orderedSections.filter((s) => s.title).map((s) => s.id),
[orderedSections],
);
if (!isEditable) {
return (
<>
{sections.map((section) => (
<Section key={section.id} section={section} />
))}
</>
);
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragCancel={onDragCancel}
>
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
{orderedSections.map((section) =>
section.title ? (
<SortableSection
key={section.id}
section={section}
sections={sections}
onAddPanel={onAddPanel}
onMovePanel={onMovePanel}
onDeletePanel={onDeletePanel}
/>
) : (
<Section
key={section.id}
section={section}
sections={sections}
onAddPanel={onAddPanel}
onMovePanel={onMovePanel}
onDeletePanel={onDeletePanel}
/>
),
)}
</SortableContext>
{/* dropAnimation disabled: optimistic reorder already places the section,
so animating the overlay back would cause a visible snap/shake. */}
<DragOverlay dropAnimation={null}>
{activeSection ? <SectionDragPreview section={activeSection} /> : null}
</DragOverlay>
</DndContext>
);
}
export default SectionList;

View File

@@ -1,59 +0,0 @@
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type { DashboardSection } from '../../utils';
import type { AddPanelArgs } from '../Panel/hooks/useAddPanelToSection';
import type { DeletePanelArgs } from '../Panel/hooks/useDeletePanel';
import type { MovePanelArgs } from '../Panel/hooks/useMovePanelToSection';
import Section from './Section/Section';
interface Props {
section: DashboardSection;
sections: DashboardSection[];
onAddPanel: (args: AddPanelArgs) => void;
onMovePanel: (args: MovePanelArgs) => void;
onDeletePanel: (args: DeletePanelArgs) => void;
}
function SortableSection({
section,
sections,
onAddPanel,
onMovePanel,
onDeletePanel,
}: Props): JSX.Element {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: section.id });
// dnd-kit drives the drag transform per-frame, so this must be an inline
// style — there is no static-stylesheet equivalent for a live transform.
// While dragging, the original is hidden (the DragOverlay renders the moving
// preview); keeping it in place preserves the gap and lets siblings animate.
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0 : undefined,
};
return (
<div ref={setNodeRef} style={style}>
<Section
section={section}
sections={sections}
onAddPanel={onAddPanel}
onMovePanel={onMovePanel}
onDeletePanel={onDeletePanel}
dragHandle={{ attributes, listeners, setActivatorNodeRef }}
/>
</div>
);
}
export default SortableSection;

View File

@@ -1,59 +0,0 @@
import { useCallback, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import {
addSectionOp,
newGridLayout,
reorderLayoutsOp,
} from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
interface Params {
layouts: DashboardtypesLayoutDTO[] | undefined | null;
}
interface Result {
addSection: (title: string) => Promise<void>;
isSaving: boolean;
}
/**
* Appends an empty titled section. When the dashboard has no layouts yet, the
* layouts array is created via a `replace` (an `add` to a missing/empty array
* pointer is unreliable); otherwise a new Grid is appended.
*/
export function useAddSection({ layouts }: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const [isSaving, setIsSaving] = useState(false);
const { showErrorModal } = useErrorModal();
const addSection = useCallback(
async (title: string): Promise<void> => {
const trimmed = title.trim();
if (!dashboardId || !trimmed) {
return;
}
const op =
!layouts || layouts.length === 0
? reorderLayoutsOp([newGridLayout(trimmed)])
: addSectionOp(trimmed);
try {
setIsSaving(true);
await patchDashboardV2({ id: dashboardId }, [op]);
refetch();
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
},
[layouts, dashboardId, refetch, showErrorModal],
);
return { addSection, isSaving };
}

View File

@@ -1,51 +0,0 @@
import { useCallback, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { removePanelOp, removeSectionOp } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
interface Params {
section: DashboardSection;
}
interface Result {
deleteSection: () => Promise<void>;
isSaving: boolean;
}
/**
* Deletes a section: removes its Grid layout and deletes every panel it
* contained from `spec.panels` (orphan cleanup), as one atomic patch.
*/
export function useDeleteSection({ section }: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const [isSaving, setIsSaving] = useState(false);
const { showErrorModal } = useErrorModal();
const deleteSection = useCallback(async (): Promise<void> => {
if (!dashboardId) {
return;
}
const ops: DashboardtypesJSONPatchOperationDTO[] = section.items.map((i) =>
removePanelOp(i.id),
);
ops.push(removeSectionOp(section.layoutIndex));
try {
setIsSaving(true);
await patchDashboardV2({ id: dashboardId }, ops);
refetch();
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
}, [section, dashboardId, refetch, showErrorModal]);
return { deleteSection, isSaving };
}

View File

@@ -1,64 +0,0 @@
import { useCallback, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { addSectionOp, titleUntitledSectionOp } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
interface Params {
sections: DashboardSection[];
}
interface Result {
migrate: (newSectionTitle: string) => Promise<void>;
isSaving: boolean;
}
/**
* Converts a free-flowing dashboard into a sectioned one: every existing
* untitled layout that holds panels is titled in place ("Section 1", "Section
* 2", …), then the brand-new section the user asked for is appended — all in one
* atomic patch. Used once the user confirms the migration prompt.
*/
export function useFirstSectionMigration({ sections }: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const [isSaving, setIsSaving] = useState(false);
const { showErrorModal } = useErrorModal();
const migrate = useCallback(
async (newSectionTitle: string): Promise<void> => {
const trimmed = newSectionTitle.trim();
if (!dashboardId || !trimmed) {
return;
}
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
let counter = 1;
sections.forEach((s) => {
if (!s.title && s.items.length > 0) {
ops.push(titleUntitledSectionOp(s.layoutIndex, `Section ${counter}`));
counter += 1;
}
});
ops.push(addSectionOp(trimmed));
try {
setIsSaving(true);
await patchDashboardV2({ id: dashboardId }, ops);
refetch();
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
},
[sections, dashboardId, refetch, showErrorModal],
);
return { migrate, isSaving };
}

View File

@@ -1,97 +0,0 @@
import { useCallback, useState } from 'react';
import type { Layout } from 'react-grid-layout';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { replaceSectionItemsOp } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { GridItem } from '../../../utils';
interface Params {
layoutIndex: number;
items: GridItem[];
}
interface Result {
handleLayoutChange: (rglLayout: Layout[]) => void;
isSaving: boolean;
}
/** Maps an RGL layout back onto the section's grid items, preserving panel refs. */
function mergeRglLayout(rglLayout: Layout[], items: GridItem[]): GridItem[] {
const byId = new Map(items.map((item) => [item.id, item]));
return rglLayout
.map((entry) => {
const existing = byId.get(entry.i);
if (!existing) {
return null;
}
return {
...existing,
x: entry.x,
y: entry.y,
width: entry.w,
height: entry.h,
};
})
.filter((item): item is GridItem => item !== null);
}
function hasGeometryChanged(next: GridItem[], prev: GridItem[]): boolean {
if (next.length !== prev.length) {
return true;
}
const prevById = new Map(prev.map((item) => [item.id, item]));
return next.some((item) => {
const before = prevById.get(item.id);
if (!before) {
return true;
}
return (
before.x !== item.x ||
before.y !== item.y ||
before.width !== item.width ||
before.height !== item.height
);
});
}
/**
* Persists panel geometry within a single section. Call the returned handler
* from RGL's `onDragStop`/`onResizeStop` (stop events only — not continuous
* `onLayoutChange`) to limit network churn.
*/
export function usePersistLayout({ layoutIndex, items }: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const [isSaving, setIsSaving] = useState(false);
const { showErrorModal } = useErrorModal();
const handleLayoutChange = useCallback(
async (rglLayout: Layout[]): Promise<void> => {
if (!dashboardId) {
return;
}
const nextItems = mergeRglLayout(rglLayout, items);
if (!hasGeometryChanged(nextItems, items)) {
return;
}
try {
setIsSaving(true);
await patchDashboardV2({ id: dashboardId }, [
replaceSectionItemsOp(layoutIndex, nextItems),
]);
refetch();
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
},
[dashboardId, items, layoutIndex, refetch, showErrorModal],
);
return { handleLayoutChange, isSaving };
}

View File

@@ -1,50 +0,0 @@
import { useCallback, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { renameSectionOp } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
interface Params {
layoutIndex: number;
}
interface Result {
rename: (title: string) => Promise<boolean>;
isSaving: boolean;
}
/** Renames a section's title via `replace /spec/layouts/<i>/spec/display/title`. */
export function useRenameSection({ layoutIndex }: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const [isSaving, setIsSaving] = useState(false);
const { showErrorModal } = useErrorModal();
const rename = useCallback(
async (title: string): Promise<boolean> => {
const trimmed = title.trim();
if (!dashboardId || !trimmed) {
return false;
}
try {
setIsSaving(true);
await patchDashboardV2({ id: dashboardId }, [
renameSectionOp(layoutIndex, trimmed),
]);
refetch();
return true;
} catch (error) {
showErrorModal(error as APIError);
return false;
} finally {
setIsSaving(false);
}
},
[dashboardId, layoutIndex, refetch, showErrorModal],
);
return { rename, isSaving };
}

View File

@@ -1,125 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
type DragEndEvent,
type DragStartEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { arrayMove, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { reorderLayoutsOp } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
interface Params {
sections: DashboardSection[];
layouts: DashboardtypesLayoutDTO[] | undefined | null;
}
interface Result {
sensors: ReturnType<typeof useSensors>;
/** Display order — optimistically reordered on drop so the UI doesn't wait on refetch. */
orderedSections: DashboardSection[];
/** The section currently being dragged (for the DragOverlay preview), or null. */
activeSection: DashboardSection | null;
onDragStart: (event: DragStartEvent) => void;
onDragEnd: (event: DragEndEvent) => void;
onDragCancel: () => void;
}
/**
* Owns section-reorder drag state. Reorders happen optimistically in local
* state (keyed by stable section id) and persist via a single
* `replace /spec/layouts` patch; the optimistic order is cleared once fresh
* server data arrives. Each section maps 1:1 to a Grid layout via `layoutIndex`,
* so the new layouts array is rebuilt by mapping the reordered sections back.
*/
export function useSectionDragReorder({ sections, layouts }: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const [activeId, setActiveId] = useState<string | null>(null);
const [localOrderIds, setLocalOrderIds] = useState<string[] | null>(null);
const { showErrorModal } = useErrorModal();
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
// Server data is the source of truth — drop optimistic order whenever it changes.
useEffect(() => {
setLocalOrderIds(null);
}, [sections]);
const orderedSections = useMemo<DashboardSection[]>(() => {
if (!localOrderIds) {
return sections;
}
const byId = new Map(sections.map((s) => [s.id, s]));
const ordered = localOrderIds
.map((id) => byId.get(id))
.filter((s): s is DashboardSection => s !== undefined);
return ordered.length === sections.length ? ordered : sections;
}, [sections, localOrderIds]);
const onDragStart = useCallback((event: DragStartEvent): void => {
setActiveId(String(event.active.id));
}, []);
const onDragCancel = useCallback((): void => {
setActiveId(null);
}, []);
const onDragEnd = useCallback(
async (event: DragEndEvent): Promise<void> => {
setActiveId(null);
const { active, over } = event;
if (!over || active.id === over.id || !dashboardId || !layouts) {
return;
}
const oldIndex = orderedSections.findIndex((s) => s.id === active.id);
const newIndex = orderedSections.findIndex((s) => s.id === over.id);
if (oldIndex < 0 || newIndex < 0) {
return;
}
const newOrdered = arrayMove(orderedSections, oldIndex, newIndex);
setLocalOrderIds(newOrdered.map((s) => s.id));
const newLayouts = newOrdered
.map((s) => layouts[s.layoutIndex])
.filter((l): l is DashboardtypesLayoutDTO => l !== undefined);
try {
await patchDashboardV2({ id: dashboardId }, [reorderLayoutsOp(newLayouts)]);
refetch();
} catch (error) {
setLocalOrderIds(null); // revert optimistic order on failure
showErrorModal(error as APIError);
}
},
[orderedSections, layouts, dashboardId, refetch, showErrorModal],
);
const activeSection = useMemo(
() => orderedSections.find((s) => s.id === activeId) ?? null,
[orderedSections, activeId],
);
return {
sensors,
orderedSections,
activeSection,
onDragStart,
onDragEnd,
onDragCancel,
};
}

View File

@@ -1,36 +0,0 @@
import { useCallback } from 'react';
import {
selectIsSectionOpen,
useDashboardStore,
} from '../../../store/useDashboardStore';
interface Params {
sectionId: string;
}
interface Result {
open: boolean;
toggle: () => void;
}
/**
* Owns a section's expand/collapse state. Collapse is a frontend-only, per-user
* preference (not in the dashboard spec): it lives in the persisted zustand
* store, keyed by dashboardId + section id, and survives reloads. Default open.
*/
export function useToggleSectionCollapse({ sectionId }: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const open = useDashboardStore(selectIsSectionOpen(dashboardId, sectionId));
const toggleSectionCollapse = useDashboardStore(
(s) => s.toggleSectionCollapse,
);
const toggle = useCallback((): void => {
if (dashboardId) {
toggleSectionCollapse(dashboardId, sectionId);
}
}, [dashboardId, sectionId, toggleSectionCollapse]);
return { open, toggle };
}

View File

@@ -7,11 +7,8 @@ import type {
DashboardtypesPanelDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useDashboardStore } from '../store/useDashboardStore';
import { layoutsToSections } from '../utils';
import AddSectionControl from './Section/AddSectionControl/AddSectionControl';
import Section from './Section/Section/Section';
import SectionList from './Section/SectionList';
import styles from './PanelsAndSectionsLayout.module.scss';
import 'react-grid-layout/css/styles.css';
@@ -23,8 +20,6 @@ interface Props {
}
function PanelsAndSectionsLayout({ layouts, panels }: Props): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const sections = useMemo(
() => layoutsToSections(layouts, panels),
[layouts, panels],
@@ -33,11 +28,6 @@ function PanelsAndSectionsLayout({ layouts, panels }: Props): JSX.Element {
const isEmpty =
sections.length === 0 || sections.every((s) => s.items.length === 0);
// Sectioned mode = at least one titled layout. Sections then become a
// reorderable list; otherwise the dashboard is a single free-flowing grid
// with no section chrome or reordering.
const isSectioned = useMemo(() => sections.some((s) => !!s.title), [sections]);
const renderContent = (): ReactNode => {
if (isEmpty) {
return (
@@ -52,27 +42,12 @@ function PanelsAndSectionsLayout({ layouts, panels }: Props): JSX.Element {
);
}
if (isSectioned) {
return <SectionList sections={sections} layouts={layouts} />;
}
return sections.map((section) => (
<Section key={section.id} section={section} />
));
};
return (
<div className={styles.body}>
{renderContent()}
{isEditable ? (
<AddSectionControl
sections={sections}
layouts={layouts}
isSectioned={isSectioned}
/>
) : null}
</div>
);
return <div className={styles.body}>{renderContent()}</div>;
}
export default PanelsAndSectionsLayout;

View File

@@ -1,13 +1,10 @@
import { useEffect, useMemo } from 'react';
import { useMemo } from 'react';
import { FullScreen, useFullScreenHandle } from 'react-full-screen';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import useComponentPermission from 'hooks/useComponentPermission';
import { useAppContext } from 'providers/App/App';
import DashboardDescription from './DashboardDescription';
import PanelsAndSectionsLayout from './PanelsAndSectionsLayout';
import { useDashboardStore } from './store/useDashboardStore';
import styles from './DashboardContainer.module.scss';
interface Props {
@@ -18,17 +15,6 @@ interface Props {
function DashboardContainer({ dashboard, refetch }: Props): JSX.Element {
const fullScreenHandle = useFullScreenHandle();
const { user } = useAppContext();
const [editDashboard] = useComponentPermission(['edit_dashboard'], user.role);
const isEditable = !dashboard.locked && editDashboard;
// Publish edit context to the store so hooks/components read it from there
// instead of receiving dashboardId/isEditable/refetch as props down the tree.
const setEditContext = useDashboardStore((s) => s.setEditContext);
useEffect(() => {
setEditContext({ dashboardId: dashboard.id ?? '', isEditable, refetch });
}, [dashboard.id, isEditable, refetch, setEditContext]);
const { spec } = dashboard;
const layouts = useMemo(() => spec?.layouts ?? [], [spec?.layouts]);
const panels = useMemo(() => spec?.panels ?? {}, [spec?.panels]);

View File

@@ -1,177 +0,0 @@
import type {
DashboardGridItemDTO,
DashboardtypesJSONPatchOperationDTO,
DashboardtypesLayoutDTO,
DashboardtypesPanelDTO,
} from 'api/generated/services/sigNoz.schemas';
import { DashboardtypesJSONPatchOperationDTOOp } from 'api/generated/services/sigNoz.schemas';
import type { GridItem } from './utils';
/**
* Pure RFC-6902 JSON-Patch builders for the V2 dashboard spec. These are
* intentionally side-effect-free (no React, no network) so they can be unit
* tested and reused by the layout hooks. JSON pointers target the postable
* shape: `/spec/layouts/...`, `/spec/panels/...` (matches the existing V2
* patches in DashboardSettings/General and DashboardDescription).
*/
const { add, replace, remove } = DashboardtypesJSONPatchOperationDTOOp;
const PANEL_REF_PREFIX = '#/spec/panels/';
export function panelRef(panelId: string): string {
return `${PANEL_REF_PREFIX}${panelId}`;
}
/**
* Builds a minimal, backend-valid panel for a given plugin kind. The spec
* requires exactly one query whose plugin kind is allowed for the panel;
* `signoz/BuilderQuery` is allowed for every panel kind and its contents are not
* validated, so an empty builder query is the safe default. The real query is
* filled in once the panel editor lands.
*/
export function createDefaultPanel(pluginKind: string): DashboardtypesPanelDTO {
// The DTO types plugin/query kinds as large generated enum unions; the kind
// here is chosen dynamically by the user, so we build the structurally-valid
// shape and assert the type.
return {
kind: 'Panel',
spec: {
display: { name: 'New panel' },
plugin: { kind: pluginKind, spec: {} },
queries: [
{
kind: 'TimeSeriesQuery',
spec: { plugin: { kind: 'signoz/BuilderQuery', spec: { name: 'A' } } },
},
],
},
} as unknown as DashboardtypesPanelDTO;
}
/** Converts a UI grid item back into the spec's grid-item DTO shape. */
export function gridItemToDTO(item: GridItem): DashboardGridItemDTO {
return {
x: item.x,
y: item.y,
width: item.width,
height: item.height,
content: { $ref: panelRef(item.id) },
};
}
/** Replace the entire items array of one section (used on panel move/resize). */
export function replaceSectionItemsOp(
layoutIndex: number,
items: GridItem[],
): DashboardtypesJSONPatchOperationDTO {
return {
op: replace,
path: `/spec/layouts/${layoutIndex}/spec/items`,
value: items.map(gridItemToDTO),
};
}
/** Replace the whole layouts array (used on section reorder — avoids move-index ambiguity). */
export function reorderLayoutsOp(
layouts: DashboardtypesLayoutDTO[],
): DashboardtypesJSONPatchOperationDTO {
return { op: replace, path: '/spec/layouts', value: layouts };
}
/** An empty titled Grid layout (one section). */
export function newGridLayout(title: string): DashboardtypesLayoutDTO {
return {
kind: 'Grid' as DashboardtypesLayoutDTO['kind'],
spec: { display: { title }, items: [] },
};
}
/** Append a new, empty titled Grid section. */
export function addSectionOp(
title: string,
): DashboardtypesJSONPatchOperationDTO {
return { op: add, path: '/spec/layouts/-', value: newGridLayout(title) };
}
interface AddPanelToSectionArgs {
panelId: string;
panel: DashboardtypesPanelDTO;
layoutIndex: number;
item: DashboardGridItemDTO;
}
/** Add a panel to `spec.panels` and an item ref into a section, as one atomic patch. */
export function addPanelToSectionOps({
panelId,
panel,
layoutIndex,
item,
}: AddPanelToSectionArgs): DashboardtypesJSONPatchOperationDTO[] {
return [
{ op: add, path: `/spec/panels/${panelId}`, value: panel },
{ op: add, path: `/spec/layouts/${layoutIndex}/spec/items/-`, value: item },
];
}
interface MovePanelArgs {
sourceIndex: number;
sourceItems: GridItem[];
targetIndex: number;
targetItems: GridItem[];
}
/** Move a panel's item ref from one section to another (panel stays in spec.panels). */
export function movePanelBetweenSectionsOps({
sourceIndex,
sourceItems,
targetIndex,
targetItems,
}: MovePanelArgs): DashboardtypesJSONPatchOperationDTO[] {
return [
replaceSectionItemsOp(sourceIndex, sourceItems),
replaceSectionItemsOp(targetIndex, targetItems),
];
}
/** Rename an existing section's title. */
export function renameSectionOp(
layoutIndex: number,
title: string,
): DashboardtypesJSONPatchOperationDTO {
return {
op: replace,
path: `/spec/layouts/${layoutIndex}/spec/display/title`,
value: title,
};
}
/**
* First-section migration: give an existing untitled (free-flowing) layout a
* title, turning it into a section in place while preserving its panels.
*/
export function titleUntitledSectionOp(
layoutIndex: number,
title: string,
): DashboardtypesJSONPatchOperationDTO {
return {
op: add,
path: `/spec/layouts/${layoutIndex}/spec/display`,
value: { title },
};
}
/** Remove a section. Panel cleanup (orphaned refs) is handled by the caller. */
export function removeSectionOp(
layoutIndex: number,
): DashboardtypesJSONPatchOperationDTO {
return { op: remove, path: `/spec/layouts/${layoutIndex}` };
}
/** Remove a panel definition from `spec.panels`. */
export function removePanelOp(
panelId: string,
): DashboardtypesJSONPatchOperationDTO {
return { op: remove, path: `/spec/panels/${panelId}` };
}

View File

@@ -1,35 +0,0 @@
import type { StateCreator } from 'zustand';
import type { DashboardStore } from '../useDashboardStore';
/**
* Section collapse state — frontend-only and persisted to localStorage. Keyed by
* dashboardId → section stable id → open. An absent entry means "open" (the
* default). This is intentionally NOT server state: collapse is a per-user UI
* preference, so it lives here instead of in the dashboard spec.
*/
export interface CollapseSlice {
collapsed: Record<string, Record<string, boolean>>;
toggleSectionCollapse: (dashboardId: string, sectionId: string) => void;
}
export const createCollapseSlice: StateCreator<
DashboardStore,
[['zustand/persist', unknown]],
[],
CollapseSlice
> = (set, get) => ({
collapsed: {},
toggleSectionCollapse: (dashboardId, sectionId): void => {
const { collapsed } = get();
const current = collapsed[dashboardId]?.[sectionId];
// Absent → open by default, so the first toggle closes it.
const next = current === undefined ? false : !current;
set({
collapsed: {
...collapsed,
[dashboardId]: { ...collapsed[dashboardId], [sectionId]: next },
},
});
},
});

View File

@@ -1,38 +0,0 @@
import type { StateCreator } from 'zustand';
import type { DashboardStore } from '../useDashboardStore';
/**
* Edit context shared across the V2 dashboard tree — the dashboard id, whether
* the user can edit, and the react-query refetch. Set once by DashboardContainer
* so hooks/components read it from the store instead of receiving it as props
* through every layer. Not persisted.
*/
export interface EditContextSlice {
dashboardId: string;
isEditable: boolean;
refetch: () => void;
setEditContext: (ctx: {
dashboardId: string;
isEditable: boolean;
refetch: () => void;
}) => void;
}
export const createEditContextSlice: StateCreator<
DashboardStore,
[['zustand/persist', unknown]],
[],
EditContextSlice
> = (set) => ({
dashboardId: '',
isEditable: false,
refetch: (): void => undefined,
setEditContext: (ctx): void => {
set({
dashboardId: ctx.dashboardId,
isEditable: ctx.isEditable,
refetch: ctx.refetch,
});
},
});

View File

@@ -1,44 +0,0 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import {
createEditContextSlice,
type EditContextSlice,
} from './slices/editContextSlice';
import {
createCollapseSlice,
type CollapseSlice,
} from './slices/collapseSlice';
export type DashboardStore = EditContextSlice & CollapseSlice;
/**
* V2 dashboard session store. Holds cross-cutting client state only — never the
* dashboard spec (that stays in react-query via useGetDashboardV2). Two slices:
* - edit-context: dashboardId / isEditable / refetch (set once, not persisted).
* - collapse: per-section open state (frontend-only, persisted to localStorage).
*/
export const useDashboardStore = create<DashboardStore>()(
persist(
(...a) => ({
...createEditContextSlice(...a),
...createCollapseSlice(...a),
}),
{
name: '@signoz/dashboard-v2',
// Persist only the collapse map — context (incl. the refetch fn) is transient.
partialize: (state) => ({ collapsed: state.collapsed }),
},
),
);
/** Selector: is a section open? Absent entry (or no dashboard) → open by default. */
export const selectIsSectionOpen =
(dashboardId: string, sectionId: string) =>
(state: DashboardStore): boolean => {
if (!dashboardId) {
return true;
}
const value = state.collapsed[dashboardId]?.[sectionId];
return value === undefined ? true : value;
};

View File

@@ -72,6 +72,7 @@ export interface DashboardSection {
/** Position of this section's Grid in `spec.layouts`. All JSON-Patch ops target by this. */
layoutIndex: number;
title: string | undefined;
open: boolean;
items: GridItem[];
repeatVariable: string | undefined;
}
@@ -126,11 +127,15 @@ export function layoutsToSections(
.filter((it): it is GridItem => it !== null);
const title = spec?.display?.title;
// `open` defaults to true when no collapse field is set (the section
// is expanded by default).
const open = spec?.display?.collapse?.open !== false;
return {
id: getSectionStableId(items, idx),
layoutIndex: idx,
title,
open,
items,
repeatVariable: spec?.repeatVariable,
};

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><defs><linearGradient id="b27f1ad0-7d11-4247-9da3-91bce6211f32" x1="8.798" y1="8.703" x2="14.683" y2="8.703" gradientUnits="userSpaceOnUse"><stop offset="0.001" stop-color="#773adc"/><stop offset="1" stop-color="#552f99"/></linearGradient><linearGradient id="b2f92112-4ca9-4b17-a019-c9f26c1a4a8f" x1="5.764" y1="3.777" x2="5.764" y2="13.78" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#a67af4"/><stop offset="0.999" stop-color="#773adc"/></linearGradient></defs><g id="b8a0486a-5501-4d92-b540-a766c4b3b548"><g><g><g><path d="M16.932,11.578a8.448,8.448,0,0,1-7.95,5.59,8.15,8.15,0,0,1-2.33-.33,2.133,2.133,0,0,0,.18-.83c.01,0,.03.01.04.01a7.422,7.422,0,0,0,2.11.3,7.646,7.646,0,0,0,6.85-4.28l.01-.01Z" fill="#32bedd"/><path d="M3.582,14.068a2.025,2.025,0,0,0-.64.56,8.6,8.6,0,0,1-1.67-2.44l1.04.23v.26a.6.6,0,0,0,.47.59l.14.03a6.136,6.136,0,0,0,.62.73Z" fill="#32bedd"/><path d="M12.352.958a2.28,2.28,0,0,0-.27.81c-.02-.01-.05-.02-.07-.03a7.479,7.479,0,0,0-3.03-.63,7.643,7.643,0,0,0-5.9,2.8l-.29.06a.6.6,0,0,0-.48.58v.46l-1.02.19A8.454,8.454,0,0,1,8.982.268,8.6,8.6,0,0,1,12.352.958Z" fill="#32bedd"/><path d="M16.872,5.7l-1.09-.38a6.6,6.6,0,0,0-.72-1.16c-.02-.03-.04-.05-.05-.07a2.083,2.083,0,0,0,.72-.45A7.81,7.81,0,0,1,16.872,5.7Z" fill="#32bedd"/><path d="M10.072,11.908l2.54.56L8.672,14.1c-.02,0-.03.01-.05.01a.154.154,0,0,1-.15-.15V3.448a.154.154,0,0,1,.15-.15.09.09,0,0,1,.05.01l4.46,1.56-3.05.57a.565.565,0,0,0-.44.54v5.4A.537.537,0,0,0,10.072,11.908Z" fill="#fff"/><g><g id="e918f286-5032-4942-ad29-ea17e6f1cc90"><path d="M1.1,5.668l1.21-.23v6.55l-1.23-.27-.99-.22a.111.111,0,0,1-.09-.12v-5.4a.12.12,0,0,1,.09-.12Z" fill="#a67af4"/></g><g><g id="a47a99dd-4d47-4c70-8c42-c5ac274ce496"><g><path d="M10.072,11.908l2.54.56L8.672,14.1c-.02,0-.03.01-.05.01a.154.154,0,0,1-.15-.15V3.448a.154.154,0,0,1,.15-.15.09.09,0,0,1,.05.01l4.46,1.56-3.05.57a.565.565,0,0,0-.44.54v5.4A.537.537,0,0,0,10.072,11.908Z" fill="url(#b27f1ad0-7d11-4247-9da3-91bce6211f32)"/><path d="M8.586,3.3,2.878,4.378a.177.177,0,0,0-.14.175V12.68a.177.177,0,0,0,.137.174L8.581,14.1a.176.176,0,0,0,.21-.174V3.478A.175.175,0,0,0,8.619,3.3Z" fill="url(#b2f92112-4ca9-4b17-a019-c9f26c1a4a8f)"/></g></g><polygon points="5.948 4.921 5.948 12.483 7.934 12.814 7.934 4.564 5.948 4.921" fill="#b796f9" opacity="0.5"/><polygon points="3.509 5.329 3.509 11.954 5.238 12.317 5.238 5.031 3.509 5.329" fill="#b796f9" opacity="0.5"/></g></g></g><path d="M16,2.048a1.755,1.755,0,1,1-1.76-1.76A1.756,1.756,0,0,1,16,2.048Z" fill="#32bedd"/><circle cx="4.65" cy="15.973" r="1.759" fill="#32bedd"/></g><path d="M18,6.689v3.844a.222.222,0,0,1-.133.2l-.766.316-3.07,1.268-.011,0a.126.126,0,0,1-.038,0,.1.1,0,0,1-.1-.1V5.234a.1.1,0,0,1,.054-.088l0,0,.019,0a.031.031,0,0,1,.019,0,.055.055,0,0,1,.034.008l.011,0,.012,0L17.05,6.2l.8.282A.213.213,0,0,1,18,6.689Z" fill="#773adc"/><path d="M13.959,5.14l-3.8.715a.118.118,0,0,0-.093.117v5.409a.118.118,0,0,0,.091.116l3.8.831a.115.115,0,0,0,.137-.09.109.109,0,0,0,0-.026V5.256a.117.117,0,0,0-.115-.118A.082.082,0,0,0,13.959,5.14Z" fill="#a67af4"/></g></g></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -0,0 +1,263 @@
{
"id": "containerapp",
"title": "Container App",
"icon": "file://icon.svg",
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": true
},
"dataCollected": {
"metrics": [
{
"name": "azure_rxbytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_rxbytes_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_rxbytes_minimum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_rxbytes_total",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_txbytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_txbytes_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_txbytes_minimum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_txbytes_total",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_restartcount_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_restartcount_maximum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_restartcount_minimum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_restartcount_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_replicas_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_replicas_maximum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_replicas_minimum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_replicas_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_cpupercentage_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_cpupercentage_maximum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_cpupercentage_minimum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_cpupercentage_total",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_memorypercentage_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_memorypercentage_maximum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_memorypercentage_minimum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_memorypercentage_total",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_usagenanocores_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_usagenanocores_maximum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_usagenanocores_minimum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_usagenanocores_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_workingsetbytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_workingsetbytes_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_workingsetbytes_minimum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_workingsetbytes_total",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_coresquotaused_maximum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_coresquotaused_minimum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_totalcoresquotaused_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_totalcoresquotaused_maximum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_totalcoresquotaused_minimum",
"unit": "Count",
"type": "Gauge",
"description": ""
}
],
"logs": [
{
"name": "Resource ID",
"path": "resources.azure.resource.id",
"type": "string"
}
]
},
"telemetryCollectionStrategy": {
"azure": {
"resourceProvider": "Microsoft.App",
"resourceType": "containerApps",
"metrics": {},
"logs": {
"categoryGroups": ["ContainerAppConsoleLogs", "ContainerAppSystemLogs"]
}
}
},
"assets": {
"dashboards": [
{
"id": "overview",
"title": "Container App Overview",
"description": "Overview of Container App metrics",
"definition": "file://assets/dashboards/overview.json"
}
]
}
}

View File

@@ -0,0 +1,7 @@
### Monitor Container Apps with SigNoz
Collect key Container App metrics and view them with an out of the box dashboard.
To collect logs, you need to make sure that you have chosen "Azure Monitor" as the logging option for Container's App Environment.
Note: This integration ingests logs for only "ContainerAppConsoleLogs" and "ContainerAppSystemLogs" diagnostic settings categories.

View File

@@ -27,6 +27,7 @@ var (
// Azure services.
AzureServiceStorageAccountsBlob = ServiceID{valuer.NewString("storageaccountsblob")}
AzureServiceCDNProfile = ServiceID{valuer.NewString("cdnprofile")}
AzureServiceContainerApp = ServiceID{valuer.NewString("containerapp")}
)
func (ServiceID) Enum() []any {
@@ -46,6 +47,7 @@ func (ServiceID) Enum() []any {
AWSServiceSQS,
AzureServiceStorageAccountsBlob,
AzureServiceCDNProfile,
AzureServiceContainerApp,
}
}
@@ -69,6 +71,7 @@ var SupportedServices = map[CloudProviderType][]ServiceID{
CloudProviderTypeAzure: {
AzureServiceStorageAccountsBlob,
AzureServiceCDNProfile,
AzureServiceContainerApp,
},
}