Canonicalize reporting settings catalog

This commit is contained in:
rcourtman
2026-03-25 22:27:35 +00:00
parent bff50989cd
commit 7728b352c0
30 changed files with 4649 additions and 242 deletions
File diff suppressed because it is too large Load Diff
@@ -176,10 +176,11 @@ inventory export route for reporting. Fleet and install surfaces may coexist
with that export, but `internal/api/reporting_inventory_handlers.go` and
`internal/api/router_routes_licensing.go` remain API-owned reporting transport,
not lifecycle-owned inventory or install behavior.
That adjacent reporting transport now also includes a VM inventory definition
route that owns export title, column schema, and filename prefix. Lifecycle-
That adjacent reporting transport now also includes a reporting catalog route
plus a VM inventory definition route that own panel copy, performance report
options, export title, column schema, and filename prefixes. Lifecycle-
adjacent install and fleet surfaces may read those facts, but they must not
redefine inventory schema locally.
redefine reporting or inventory schema locally.
That adjacent export contract now also carries canonical Proxmox pool
membership for each VM row. Lifecycle-adjacent install and fleet surfaces may
reuse those current-state facts, but they must still treat the pool column as
@@ -217,14 +217,16 @@ The reporting API contract now also treats current-state fleet inventory as a
first-class surface separate from historical metrics reports.
`internal/api/reporting_inventory_handlers.go`,
`internal/api/router_routes_licensing.go`, and the settings reporting shell now
own `/api/admin/reports/inventory/vms/definition` plus
`/api/admin/reports/inventory/vms/export` as the canonical VM inventory
contract. The definition endpoint owns the operator-facing title, description,
filename prefix, and stable column schema, while the export endpoint remains
the spreadsheet-shaped CSV transport. That export is intentionally not comment-
prefixed like the legacy metrics CSV, and it now carries Proxmox pool
membership from the canonical unified VM runtime model instead of inferring or
reconstructing that field locally inside the frontend or handler.
own `/api/admin/reports/catalog` as the canonical operator-facing reporting
catalog plus `/api/admin/reports/inventory/vms/definition` and
`/api/admin/reports/inventory/vms/export` as the stable VM inventory sub-
contract. The catalog endpoint owns the reporting panel title, description,
historical performance report options, and nested VM inventory definition,
while the export endpoint remains the spreadsheet-shaped CSV transport. That
export is intentionally not comment-prefixed like the legacy metrics CSV, and
it now carries Proxmox pool membership from the canonical unified VM runtime
model instead of inferring or reconstructing that field locally inside the
frontend or handler.
The `/api/resources` serializer now also refreshes canonical identity and
policy metadata through the shared unified-resource helper before it writes
the payload, so backend and frontend contract tests stay aligned on one
@@ -169,16 +169,18 @@ The settings reporting shell now also owns a deliberate split between
historical performance reports and current-state VM inventory export.
`frontend-modern/src/components/Settings/ReportingPanel.tsx`,
`frontend-modern/src/components/Settings/useReportingPanelState.ts`,
`frontend-modern/src/components/Settings/reportingCatalogModel.ts`,
`frontend-modern/src/components/Settings/reportingPanelModel.ts`, and
`frontend-modern/src/components/Settings/reportingInventoryExportModel.ts` must
keep those as separate operator jobs with separate request builders and success
copy, rather than collapsing inventory export back into the metrics-report
controls.
That same settings shell must now also render VM inventory export schema from
the backend-owned definition contract rather than hardcoding column copy in the
panel. The frontend model may validate and present the definition, but the
canonical title, description, filename prefix, and column list belong to the
API reporting contract.
That same settings shell must now also render both historical performance
options and VM inventory schema from the backend-owned reporting catalog rather
than hardcoding panel copy, routes, or range presets in the frontend. The
frontend models may validate and present the catalog, but the canonical panel
title, descriptions, endpoints, filename prefixes, range windows, and column
list belong to the API reporting contract.
The shared updates settings owner also defines the user-facing framing for
rc-tagged builds. `frontend-modern/src/components/Settings/updatesSettingsModel.ts`
and `frontend-modern/src/utils/updatesPresentation.ts` must present that
@@ -222,10 +222,11 @@ reporting surface. Storage and recovery workflows may consume similar current-
state VM facts, but `internal/api/reporting_inventory_handlers.go` and
`internal/api/router_routes_licensing.go` remain API/reporting transport
ownership rather than storage/recovery contract ownership.
That adjacent reporting transport now also includes a VM inventory definition
route that owns export title, stable column schema, and filename prefix.
Storage and recovery flows may read those facts when they need fleet context,
but they must not fork their own inventory column contract.
That adjacent reporting transport now also includes a reporting catalog route
plus a VM inventory definition route that own panel copy, stable column
schema, and filename prefix. Storage and recovery flows may read those facts
when they need fleet context, but they must not fork their own reporting or
inventory column contract.
That adjacent export contract now also includes canonical Proxmox pool
membership for each VM row. Storage and recovery flows may use those current-
state facts when they need fleet context, but they must consume the API-owned
@@ -1,34 +1,28 @@
import { For, Show, JSX } from 'solid-js';
import { For, JSX, Show } from 'solid-js';
import FileText from 'lucide-solid/icons/file-text';
import Download from 'lucide-solid/icons/download';
import BarChart from 'lucide-solid/icons/bar-chart';
import TableProperties from 'lucide-solid/icons/table-properties';
import OperationsPanel from '@/components/Settings/OperationsPanel';
import { CalloutCard } from '@/components/shared/CalloutCard';
import { formField, formLabel, formHelpText, formControl } from '@/components/shared/Form';
import { formControl, formField, formHelpText, formLabel } from '@/components/shared/Form';
import { FilterButtonGroup, type FilterOption } from '@/components/shared/FilterButtonGroup';
import { ResourcePicker } from './ResourcePicker';
import { useReportingPanelState } from '@/components/Settings/useReportingPanelState';
import type { ReportingFormat } from '@/components/Settings/reportingCatalogModel';
import { type ReportingRangeValue } from '@/components/Settings/reportingPanelModel';
import { trackUpgradeClicked } from '@/utils/upgradeMetrics';
import { REPORTING_RANGE_OPTIONS } from '@/utils/reportingPresentation';
import {
getUpgradeActionButtonClass,
UPGRADE_ACTION_LABEL,
UPGRADE_TRIAL_LABEL,
UPGRADE_TRIAL_LINK_CLASS,
} from '@/utils/upgradePresentation';
import { useReportingPanelState } from '@/components/Settings/useReportingPanelState';
import { type ReportingRangeValue } from '@/components/Settings/reportingPanelModel';
import { ResourcePicker } from './ResourcePicker';
const REPORTING_RANGE_FILTER_OPTIONS: FilterOption<ReportingRangeValue>[] =
REPORTING_RANGE_OPTIONS.map((option) => ({
label: option.label,
value: option.value,
}));
const REPORTING_FORMAT_FILTER_OPTIONS: FilterOption<'pdf' | 'csv'>[] = [
{ value: 'pdf', label: 'PDF Report', icon: FileText },
{ value: 'csv', label: 'CSV Data', icon: BarChart },
];
const REPORTING_FORMAT_ICONS: Record<ReportingFormat, typeof FileText> = {
csv: BarChart,
pdf: FileText,
};
interface FormFieldProps {
label: string;
@@ -55,13 +49,13 @@ export function ReportingPanel() {
generating,
handleGenerate,
handleStartTrial,
inventoryDefinition,
inventoryDefinitionError,
inventoryDefinitionLoading,
isLocked,
isReportingEnabled,
metricType,
range,
reportingCatalog,
reportingCatalogError,
reportingCatalogLoading,
selectedResources,
setFormat,
setMetricType,
@@ -73,12 +67,31 @@ export function ReportingPanel() {
upgradeActionUrl,
} = useReportingPanelState();
const performanceReport = () => reportingCatalog()?.performanceReport ?? null;
const inventoryDefinition = () => reportingCatalog()?.vmInventoryExport ?? null;
const rangeFilterOptions = (): FilterOption<ReportingRangeValue>[] =>
(performanceReport()?.ranges ?? []).map((option) => ({
label: option.label,
value: option.key,
}));
const formatFilterOptions = (): FilterOption<ReportingFormat>[] =>
(performanceReport()?.formats ?? []).map((option) => ({
value: option.value,
label: option.label,
icon: REPORTING_FORMAT_ICONS[option.value],
}));
return (
<div class="space-y-6">
<Show when={isLocked()}>
<OperationsPanel
title="Detailed Reporting"
description="Generate performance reports and current-state exports across infrastructure and workloads."
title={reportingCatalog()?.title ?? 'Detailed Reporting'}
description={
reportingCatalog()?.description ??
'Generate performance reports and current-state exports across infrastructure and workloads.'
}
icon={<BarChart class="w-5 h-5" strokeWidth={2} />}
>
<div class="p-4 sm:p-6">
@@ -120,144 +133,155 @@ export function ReportingPanel() {
<Show when={isReportingEnabled()}>
<OperationsPanel
title="Detailed Reporting"
description="Generate performance reports and current-state exports across infrastructure and workloads."
title={reportingCatalog()?.title ?? 'Detailed Reporting'}
description={
reportingCatalog()?.description ??
'Generate performance reports and current-state exports across infrastructure and workloads.'
}
icon={<BarChart class="w-5 h-5" strokeWidth={2} />}
>
<div class="space-y-6 p-4 sm:p-6">
<section class="space-y-6">
<div class="space-y-2">
<h4 class="text-base font-semibold text-base-content">Performance Reports</h4>
<p class="text-sm text-muted">
Generate PDF summaries or CSV metric exports from historical monitoring data for
one or more selected resources.
</p>
</div>
<Show when={reportingCatalogLoading()}>
<p class="text-sm text-muted">Loading reporting surfaces...</p>
</Show>
<FormField label="Resources" helpText="Select the resources to include in the report">
<ResourcePicker
selected={selectedResources}
onSelectionChange={setSelectedResources}
/>
</FormField>
<Show when={reportingCatalogError()}>
<p class="text-sm text-warning">{reportingCatalogError()}</p>
</Show>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField label="Metric Type (Optional)" helpText="Filter by specific metric type">
<input
id="metric-type"
type="text"
class={formControl}
placeholder="e.g. cpu, memory, disk, temperature (leave empty for all)"
value={metricType()}
onInput={(e) => setMetricType(e.currentTarget.value)}
/>
</FormField>
<FormField label="Report Title" helpText="Custom title for the PDF report">
<input
id="report-title"
type="text"
class={formControl}
placeholder="Auto-generated if empty"
value={title()}
onInput={(e) => setTitle(e.currentTarget.value)}
/>
</FormField>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField label="Time Range">
<FilterButtonGroup
class="sm:grid-cols-3"
options={REPORTING_RANGE_FILTER_OPTIONS}
value={range()}
onChange={setRange}
variant="prominent"
/>
</FormField>
<FormField label="Export Format">
<FilterButtonGroup
class="sm:grid-cols-2"
options={REPORTING_FORMAT_FILTER_OPTIONS}
value={format()}
onChange={setFormat}
variant="prominent"
/>
</FormField>
</div>
<div class="flex justify-end">
<button
class={`w-full sm:w-auto flex items-center justify-center gap-2 px-6 py-3 rounded-md font-semibold transition-all ${
generating()
? 'bg-slate-300 text-slate-500 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-700 text-white'
}`}
disabled={generating()}
onClick={handleGenerate}
>
<Show when={generating()} fallback={<Download size={20} />}>
<div class="w-5 h-5 border-2 border-white border-t-white rounded-full animate-spin" />
</Show>
{generating()
? 'Generating...'
: selectedResources().length > 0
? `Generate Report (${selectedResources().length} resource${selectedResources().length !== 1 ? 's' : ''})`
: 'Generate Report'}
</button>
</div>
</section>
<section class="rounded-xl border border-base-300/80 bg-base-200/30 p-4 sm:p-5 space-y-4">
<div class="space-y-2">
<h4 class="text-base font-semibold text-base-content">VM Inventory Export</h4>
<p class="text-sm text-muted">
{inventoryDefinition()?.description ??
'Export the current fleet-wide VM inventory as CSV using the canonical runtime model.'}
</p>
</div>
<Show when={inventoryDefinitionLoading()}>
<p class="text-xs text-muted">Loading export column definition...</p>
</Show>
<Show when={inventoryDefinitionError()}>
<p class="text-xs text-warning">{inventoryDefinitionError()}</p>
</Show>
<Show when={inventoryDefinition()?.columns.length}>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
<For each={inventoryDefinition()?.columns ?? []}>
{(column) => (
<div class="rounded-lg border border-base-300/70 bg-base-100/70 p-3 space-y-1">
<div class="text-xs font-semibold uppercase tracking-wide text-base-content/80">
{column.label}
</div>
<p class="text-xs text-muted leading-relaxed">{column.description}</p>
</div>
)}
</For>
<Show when={performanceReport() && inventoryDefinition()}>
<section class="space-y-6">
<div class="space-y-2">
<h4 class="text-base font-semibold text-base-content">
{performanceReport()?.title}
</h4>
<p class="text-sm text-muted">{performanceReport()?.description}</p>
</div>
</Show>
<div class="flex justify-end">
<button
class={`w-full sm:w-auto flex items-center justify-center gap-2 px-6 py-3 rounded-md font-semibold transition-all ${
exportingInventory()
? 'bg-slate-300 text-slate-500 cursor-not-allowed'
: 'bg-emerald-600 hover:bg-emerald-700 text-white'
}`}
disabled={exportingInventory()}
onClick={handleExportVMInventory}
<FormField
label="Resources"
helpText="Select the resources to include in the report"
>
<Show when={exportingInventory()} fallback={<TableProperties size={20} />}>
<div class="w-5 h-5 border-2 border-white border-t-white rounded-full animate-spin" />
</Show>
{exportingInventory() ? 'Exporting...' : 'Export VM Inventory'}
</button>
</div>
</section>
<ResourcePicker
selected={selectedResources}
onSelectionChange={setSelectedResources}
/>
</FormField>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<FormField
label="Metric Type (Optional)"
helpText="Filter by specific metric type"
>
<input
id="metric-type"
type="text"
class={formControl}
placeholder="e.g. cpu, memory, disk, temperature (leave empty for all)"
value={metricType()}
onInput={(e) => setMetricType(e.currentTarget.value)}
/>
</FormField>
<FormField label="Report Title" helpText="Custom title for the PDF report">
<input
id="report-title"
type="text"
class={formControl}
placeholder="Auto-generated if empty"
value={title()}
onInput={(e) => setTitle(e.currentTarget.value)}
/>
</FormField>
</div>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<FormField label="Time Range">
<FilterButtonGroup
class="sm:grid-cols-3"
options={rangeFilterOptions()}
value={range()}
onChange={setRange}
variant="prominent"
/>
</FormField>
<FormField label="Export Format">
<FilterButtonGroup
class="sm:grid-cols-2"
options={formatFilterOptions()}
value={format()}
onChange={setFormat}
variant="prominent"
/>
</FormField>
</div>
<div class="flex justify-end">
<button
class={`flex w-full items-center justify-center gap-2 rounded-md px-6 py-3 font-semibold transition-all sm:w-auto ${
generating()
? 'cursor-not-allowed bg-slate-300 text-slate-500'
: 'bg-blue-600 text-white hover:bg-blue-700'
}`}
disabled={generating()}
onClick={handleGenerate}
>
<Show when={generating()} fallback={<Download size={20} />}>
<div class="h-5 w-5 animate-spin rounded-full border-2 border-t-white border-white" />
</Show>
{generating()
? 'Generating...'
: selectedResources().length > 0
? `Generate Report (${selectedResources().length} resource${selectedResources().length !== 1 ? 's' : ''})`
: 'Generate Report'}
</button>
</div>
</section>
</Show>
<Show when={inventoryDefinition()}>
<section class="space-y-4 rounded-xl border border-base-300/80 bg-base-200/30 p-4 sm:p-5">
<div class="space-y-2">
<h4 class="text-base font-semibold text-base-content">
{inventoryDefinition()?.title}
</h4>
<p class="text-sm text-muted">{inventoryDefinition()?.description}</p>
</div>
<Show when={inventoryDefinition()?.columns.length}>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
<For each={inventoryDefinition()?.columns ?? []}>
{(column) => (
<div class="space-y-1 rounded-lg border border-base-300/70 bg-base-100/70 p-3">
<div class="text-xs font-semibold uppercase tracking-wide text-base-content/80">
{column.label}
</div>
<p class="text-xs leading-relaxed text-muted">{column.description}</p>
</div>
)}
</For>
</div>
</Show>
<div class="flex justify-end">
<button
class={`flex w-full items-center justify-center gap-2 rounded-md px-6 py-3 font-semibold transition-all sm:w-auto ${
exportingInventory()
? 'cursor-not-allowed bg-slate-300 text-slate-500'
: 'bg-emerald-600 text-white hover:bg-emerald-700'
}`}
disabled={exportingInventory()}
onClick={handleExportVMInventory}
>
<Show when={exportingInventory()} fallback={<TableProperties size={20} />}>
<div class="h-5 w-5 animate-spin rounded-full border-2 border-t-white border-white" />
</Show>
{exportingInventory() ? 'Exporting...' : 'Export VM Inventory'}
</button>
</div>
</section>
</Show>
</div>
</OperationsPanel>
@@ -61,6 +61,7 @@ import aiRuntimeControlsSectionSource from '../AIRuntimeControlsSection.tsx?raw'
import aiSettingsStatusAndActionsSource from '../AISettingsStatusAndActions.tsx?raw';
import aiSettingsStateSource from '../useAISettingsState.ts?raw';
import reportingPanelSource from '../ReportingPanel.tsx?raw';
import reportingCatalogModelSource from '../reportingCatalogModel.ts?raw';
import reportingPanelModelSource from '../reportingPanelModel.ts?raw';
import reportingInventoryExportModelSource from '../reportingInventoryExportModel.ts?raw';
import updatesSettingsPanelSource from '../UpdatesSettingsPanel.tsx?raw';
@@ -598,14 +599,14 @@ describe('monitored-system model guardrails', () => {
expect(agentProfileSuggestionPresentationSource).toContain(
'export function getAgentProfileSuggestionRiskHints',
);
expect(reportingPanelSource).toContain('REPORTING_RANGE_OPTIONS');
expect(reportingPanelSource).toContain('FilterButtonGroup');
expect(reportingPanelSource).toContain('CalloutCard');
expect(reportingPanelSource).toContain('variant="prominent"');
expect(reportingPanelSource).toContain('@/utils/upgradePresentation');
expect(reportingPanelSource).toContain('@/components/Settings/useReportingPanelState');
expect(reportingPanelSource).toContain('@/components/Settings/reportingCatalogModel');
expect(reportingPanelSource).toContain('@/components/Settings/reportingPanelModel');
expect(reportingPanelSource).toContain('VM Inventory Export');
expect(reportingPanelSource).toContain('reportingCatalog');
expect(reportingPanelSource).toContain('getUpgradeActionButtonClass');
expect(reportingPanelSource).toContain('UPGRADE_ACTION_LABEL');
expect(reportingPanelSource).toContain('UPGRADE_TRIAL_LABEL');
@@ -614,17 +615,20 @@ describe('monitored-system model guardrails', () => {
expect(reportingPanelSource).not.toContain("<For each={['24h', '7d', '30d']}>");
expect(reportingPanelSource).not.toContain('window.URL.createObjectURL');
expect(reportingPanelStateSource).toContain('buildReportingRequest');
expect(reportingPanelStateSource).toContain('buildReportingCatalogRequest');
expect(reportingPanelStateSource).toContain('parseReportingCatalog');
expect(reportingPanelStateSource).toContain('getReportingGenerateSelectionRequiredMessage');
expect(reportingPanelStateSource).toContain('getReportingGenerateSuccessMessage');
expect(reportingPanelStateSource).toContain('getReportingGenerateErrorMessage');
expect(reportingPanelStateSource).toContain('buildVMInventoryExportRequest');
expect(reportingPanelStateSource).toContain('getReportingInventoryExportSuccessMessage');
expect(reportingCatalogModelSource).toContain('export function buildReportingCatalogRequest');
expect(reportingCatalogModelSource).toContain('export function parseReportingCatalog');
expect(reportingPanelModelSource).toContain('export function getReportingRangeStart');
expect(reportingPanelModelSource).toContain('export function buildReportingRequest');
expect(reportingInventoryExportModelSource).toContain(
'export function buildVMInventoryExportRequest',
);
expect(reportingPresentationSource).toContain('export const REPORTING_RANGE_OPTIONS');
expect(reportingPresentationSource).toContain(
'export function getReportingGenerateSelectionRequiredMessage',
);
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import {
buildReportingCatalogRequest,
parseReportingCatalog,
} from '../reportingCatalogModel';
describe('reporting catalog model', () => {
it('builds the canonical reporting catalog request', () => {
expect(buildReportingCatalogRequest()).toEqual({
url: '/api/admin/reports/catalog',
});
});
it('parses the canonical reporting catalog payload', () => {
const catalog = parseReportingCatalog({
id: 'advanced_reporting',
title: 'Detailed Reporting',
description: 'Canonical reporting surfaces',
performanceReport: {
id: 'performance_reports',
title: 'Performance Reports',
description: 'Historical performance reporting',
singleResourceEndpoint: '/api/admin/reports/generate',
multiResourceEndpoint: '/api/admin/reports/generate-multi',
singleFilenamePrefix: 'report',
multiFilenamePrefix: 'fleet-report',
formats: [
{ value: 'pdf', label: 'PDF Report' },
{ value: 'csv', label: 'CSV Data' },
],
defaultFormat: 'pdf',
ranges: [
{
key: '24h',
label: 'Last 24 Hours',
description: 'Daily review',
windowHours: 24,
},
],
defaultRange: '24h',
multiResourceMax: 50,
supportsMetricFilter: true,
supportsCustomTitle: true,
},
vmInventoryExport: {
id: 'vm_inventory',
title: 'VM Inventory Export',
description: 'Current-state inventory',
format: 'csv',
exportEndpoint: '/api/admin/reports/inventory/vms/export',
filenamePrefix: 'vm-inventory',
columns: [
{
key: 'pool',
label: 'Pool',
description: 'Canonical Proxmox pool membership.',
},
],
},
});
expect(catalog.performanceReport.defaultFormat).toBe('pdf');
expect(catalog.performanceReport.ranges[0].windowHours).toBe(24);
expect(catalog.vmInventoryExport.exportEndpoint).toBe(
'/api/admin/reports/inventory/vms/export',
);
});
});
@@ -1,6 +1,5 @@
import { describe, expect, it } from 'vitest';
import {
buildVMInventoryExportDefinitionRequest,
buildVMInventoryExportFilename,
buildVMInventoryExportRequest,
parseVMInventoryExportDefinition,
@@ -14,24 +13,22 @@ describe('reporting inventory export model', () => {
it('builds the canonical VM inventory export request', () => {
const now = new Date('2026-03-20T12:34:56.000Z');
const request = buildVMInventoryExportRequest(now, { filenamePrefix: 'vm-inventory' });
const request = buildVMInventoryExportRequest(now, {
exportEndpoint: '/api/admin/reports/inventory/vms/export',
filenamePrefix: 'vm-inventory',
});
expect(request.filename).toBe('vm-inventory-2026-03-20.csv');
expect(request.request.url).toBe('/api/admin/reports/inventory/vms/export?format=csv');
});
it('builds the canonical VM inventory definition request', () => {
expect(buildVMInventoryExportDefinitionRequest()).toEqual({
url: '/api/admin/reports/inventory/vms/definition',
});
});
it('parses the canonical VM inventory export definition payload', () => {
const definition = parseVMInventoryExportDefinition({
id: 'vm_inventory',
title: 'VM Inventory Export',
description: 'Current-state VM inventory',
format: 'csv',
exportEndpoint: '/api/admin/reports/inventory/vms/export',
filenamePrefix: 'vm-inventory',
columns: [
{
@@ -43,6 +40,7 @@ describe('reporting inventory export model', () => {
});
expect(definition.id).toBe('vm_inventory');
expect(definition.exportEndpoint).toBe('/api/admin/reports/inventory/vms/export');
expect(definition.columns[0]).toEqual({
key: 'pool',
label: 'Pool',
@@ -4,6 +4,46 @@ import {
buildReportingRequest,
getReportingRangeStart,
} from '../reportingPanelModel';
import type { ReportingPerformanceReportDefinition } from '../reportingCatalogModel';
const performanceDefinition: ReportingPerformanceReportDefinition = {
id: 'performance_reports',
title: 'Performance Reports',
description: 'Historical performance reporting',
singleResourceEndpoint: '/api/admin/reports/generate',
multiResourceEndpoint: '/api/admin/reports/generate-multi',
singleFilenamePrefix: 'report',
multiFilenamePrefix: 'fleet-report',
formats: [
{ value: 'pdf', label: 'PDF Report' },
{ value: 'csv', label: 'CSV Data' },
],
defaultFormat: 'pdf',
ranges: [
{
key: '24h',
label: 'Last 24 Hours',
description: 'Daily review',
windowHours: 24,
},
{
key: '7d',
label: 'Last 7 Days',
description: 'Weekly review',
windowHours: 168,
},
{
key: '30d',
label: 'Last 30 Days',
description: 'Monthly review',
windowHours: 720,
},
],
defaultRange: '24h',
multiResourceMax: 50,
supportsMetricFilter: true,
supportsCustomTitle: true,
};
describe('reporting panel model', () => {
it('builds a single-resource reporting request and filename', () => {
@@ -24,7 +64,7 @@ describe('reporting panel model', () => {
resources,
start: '2026-03-19T12:34:56.000Z',
title: '',
});
}, performanceDefinition);
expect(request.filename).toBe('report-node-a-2026-03-20.pdf');
expect(request.request.init).toBeUndefined();
@@ -58,7 +98,7 @@ describe('reporting panel model', () => {
resources,
start: '2026-03-19T12:34:56.000Z',
title: '',
});
}, performanceDefinition);
expect(request.filename).toBe('fleet-report-2026-03-20.csv');
expect(request.request.url).toBe('/api/admin/reports/generate-multi');
@@ -84,8 +124,14 @@ describe('reporting panel model', () => {
it('derives canonical range starts from the selected preset', () => {
const now = new Date('2026-03-20T12:00:00.000Z');
expect(getReportingRangeStart('24h', now).toISOString()).toBe('2026-03-19T12:00:00.000Z');
expect(getReportingRangeStart('7d', now).toISOString()).toBe('2026-03-13T12:00:00.000Z');
expect(getReportingRangeStart('30d', now).toISOString()).toBe('2026-02-18T12:00:00.000Z');
expect(getReportingRangeStart('24h', now, performanceDefinition).toISOString()).toBe(
'2026-03-19T12:00:00.000Z',
);
expect(getReportingRangeStart('7d', now, performanceDefinition).toISOString()).toBe(
'2026-03-13T12:00:00.000Z',
);
expect(getReportingRangeStart('30d', now, performanceDefinition).toISOString()).toBe(
'2026-02-18T12:00:00.000Z',
);
});
});
@@ -126,6 +126,7 @@ import ssoProviderPresentationSource from '@/utils/ssoProviderPresentation.ts?ra
import systemSettingsPresentationSource from '@/utils/systemSettingsPresentation.ts?raw';
import updatesPresentationSource from '@/utils/updatesPresentation.ts?raw';
import diagnosticsStateSource from '../useDiagnosticsPanelState.ts?raw';
import reportingCatalogModelSource from '../reportingCatalogModel.ts?raw';
import reportingPanelModelSource from '../reportingPanelModel.ts?raw';
import reportingInventoryExportModelSource from '../reportingInventoryExportModel.ts?raw';
import reportingPanelSource from '../ReportingPanel.tsx?raw';
@@ -1082,8 +1083,9 @@ describe('Settings architecture guardrails', () => {
it('keeps the reporting shell behind extracted runtime and model owners', () => {
expect(reportingPanelSource).toContain('@/components/Settings/OperationsPanel');
expect(reportingPanelSource).toContain('@/components/Settings/useReportingPanelState');
expect(reportingPanelSource).toContain('@/components/Settings/reportingCatalogModel');
expect(reportingPanelSource).toContain('@/components/Settings/reportingPanelModel');
expect(reportingPanelSource).toContain('VM Inventory Export');
expect(reportingPanelSource).toContain('reportingCatalog');
expect(reportingPanelSource).not.toContain('loadLicenseStatus()');
expect(reportingPanelSource).not.toContain('startProTrial()');
expect(reportingPanelSource).not.toContain("apiFetch('/api/admin/reports/generate");
@@ -1093,19 +1095,19 @@ describe('Settings architecture guardrails', () => {
expect(reportingPanelStateSource).toContain('runStartProTrialAction({');
expect(reportingPanelStateSource).not.toContain('startProTrial()');
expect(reportingPanelStateSource).toContain('buildReportingRequest');
expect(reportingPanelStateSource).toContain('buildVMInventoryExportDefinitionRequest');
expect(reportingPanelStateSource).toContain('buildReportingCatalogRequest');
expect(reportingPanelStateSource).toContain('parseReportingCatalog');
expect(reportingPanelStateSource).toContain('buildVMInventoryExportRequest');
expect(reportingPanelStateSource).toContain('getReportingGenerateSuccessMessage');
expect(reportingPanelStateSource).not.toContain('getTrialAlreadyUsedMessage()');
expect(reportingCatalogModelSource).toContain('export function buildReportingCatalogRequest');
expect(reportingCatalogModelSource).toContain('export function parseReportingCatalog');
expect(reportingPanelModelSource).toContain('export function getReportingRangeStart');
expect(reportingPanelModelSource).toContain('export function buildReportingRequest');
expect(reportingPanelModelSource).toContain('export function buildReportingFilename');
expect(reportingInventoryExportModelSource).toContain(
'export function buildVMInventoryExportFilename',
);
expect(reportingInventoryExportModelSource).toContain(
'export function buildVMInventoryExportDefinitionRequest',
);
expect(reportingInventoryExportModelSource).toContain(
'export function parseVMInventoryExportDefinition',
);
@@ -0,0 +1,157 @@
import {
parseVMInventoryExportDefinition,
type ReportingInventoryExportDefinition,
} from '@/components/Settings/reportingInventoryExportModel';
export type ReportingFormat = 'pdf' | 'csv';
export interface ReportingFormatDefinition {
value: ReportingFormat;
label: string;
}
export interface ReportingRangeDefinition {
key: string;
label: string;
description: string;
windowHours: number;
}
export interface ReportingPerformanceReportDefinition {
id: string;
title: string;
description: string;
singleResourceEndpoint: string;
multiResourceEndpoint: string;
singleFilenamePrefix: string;
multiFilenamePrefix: string;
formats: ReportingFormatDefinition[];
defaultFormat: ReportingFormat;
ranges: ReportingRangeDefinition[];
defaultRange: string;
multiResourceMax: number;
supportsMetricFilter: boolean;
supportsCustomTitle: boolean;
}
export interface ReportingCatalog {
id: string;
title: string;
description: string;
performanceReport: ReportingPerformanceReportDefinition;
vmInventoryExport: ReportingInventoryExportDefinition;
}
export function buildReportingCatalogRequest(): { url: string } {
return {
url: '/api/admin/reports/catalog',
};
}
function parseReportingFormatDefinition(input: unknown): ReportingFormatDefinition {
if (!input || typeof input !== 'object') {
throw new Error('Invalid reporting catalog payload');
}
const candidate = input as Partial<ReportingFormatDefinition>;
if (
(candidate.value !== 'pdf' && candidate.value !== 'csv') ||
typeof candidate.label !== 'string'
) {
throw new Error('Invalid reporting catalog payload');
}
return {
value: candidate.value,
label: candidate.label,
};
}
function parseReportingRangeDefinition(input: unknown): ReportingRangeDefinition {
if (!input || typeof input !== 'object') {
throw new Error('Invalid reporting catalog payload');
}
const candidate = input as Partial<ReportingRangeDefinition>;
if (
typeof candidate.key !== 'string' ||
typeof candidate.label !== 'string' ||
typeof candidate.description !== 'string' ||
typeof candidate.windowHours !== 'number' ||
!Number.isFinite(candidate.windowHours) ||
candidate.windowHours <= 0
) {
throw new Error('Invalid reporting catalog payload');
}
return {
key: candidate.key,
label: candidate.label,
description: candidate.description,
windowHours: candidate.windowHours,
};
}
function parseReportingPerformanceReportDefinition(
input: unknown,
): ReportingPerformanceReportDefinition {
if (!input || typeof input !== 'object') {
throw new Error('Invalid reporting catalog payload');
}
const candidate = input as Partial<ReportingPerformanceReportDefinition>;
if (
typeof candidate.id !== 'string' ||
typeof candidate.title !== 'string' ||
typeof candidate.description !== 'string' ||
typeof candidate.singleResourceEndpoint !== 'string' ||
typeof candidate.multiResourceEndpoint !== 'string' ||
typeof candidate.singleFilenamePrefix !== 'string' ||
typeof candidate.multiFilenamePrefix !== 'string' ||
!Array.isArray(candidate.formats) ||
(candidate.defaultFormat !== 'pdf' && candidate.defaultFormat !== 'csv') ||
!Array.isArray(candidate.ranges) ||
typeof candidate.defaultRange !== 'string' ||
typeof candidate.multiResourceMax !== 'number' ||
!Number.isFinite(candidate.multiResourceMax) ||
candidate.multiResourceMax <= 0 ||
typeof candidate.supportsMetricFilter !== 'boolean' ||
typeof candidate.supportsCustomTitle !== 'boolean'
) {
throw new Error('Invalid reporting catalog payload');
}
return {
id: candidate.id,
title: candidate.title,
description: candidate.description,
singleResourceEndpoint: candidate.singleResourceEndpoint,
multiResourceEndpoint: candidate.multiResourceEndpoint,
singleFilenamePrefix: candidate.singleFilenamePrefix,
multiFilenamePrefix: candidate.multiFilenamePrefix,
formats: candidate.formats.map(parseReportingFormatDefinition),
defaultFormat: candidate.defaultFormat,
ranges: candidate.ranges.map(parseReportingRangeDefinition),
defaultRange: candidate.defaultRange,
multiResourceMax: candidate.multiResourceMax,
supportsMetricFilter: candidate.supportsMetricFilter,
supportsCustomTitle: candidate.supportsCustomTitle,
};
}
export function parseReportingCatalog(input: unknown): ReportingCatalog {
if (!input || typeof input !== 'object') {
throw new Error('Invalid reporting catalog payload');
}
const candidate = input as Partial<ReportingCatalog>;
if (
typeof candidate.id !== 'string' ||
typeof candidate.title !== 'string' ||
typeof candidate.description !== 'string'
) {
throw new Error('Invalid reporting catalog payload');
}
return {
id: candidate.id,
title: candidate.title,
description: candidate.description,
performanceReport: parseReportingPerformanceReportDefinition(candidate.performanceReport),
vmInventoryExport: parseVMInventoryExportDefinition(candidate.vmInventoryExport),
};
}
@@ -16,6 +16,7 @@ export interface ReportingInventoryExportDefinition {
title: string;
description: string;
format: 'csv';
exportEndpoint: string;
filenamePrefix: string;
columns: ReportingInventoryExportColumnDefinition[];
}
@@ -25,21 +26,15 @@ export function buildVMInventoryExportFilename(now: Date, filenamePrefix = 'vm-i
return `${filenamePrefix}-${date}.csv`;
}
export function buildVMInventoryExportDefinitionRequest(): { url: string } {
return {
url: '/api/admin/reports/inventory/vms/definition',
};
}
export function buildVMInventoryExportRequest(
now: Date,
definition?: Pick<ReportingInventoryExportDefinition, 'filenamePrefix'> | null,
definition?: Pick<ReportingInventoryExportDefinition, 'exportEndpoint' | 'filenamePrefix'> | null,
): ReportingInventoryExportRequestDefinition {
const params = new URLSearchParams({ format: 'csv' });
return {
filename: buildVMInventoryExportFilename(now, definition?.filenamePrefix ?? 'vm-inventory'),
request: {
url: `/api/admin/reports/inventory/vms/export?${params.toString()}`,
url: `${definition?.exportEndpoint ?? '/api/admin/reports/inventory/vms/export'}?${params.toString()}`,
},
};
}
@@ -57,6 +52,7 @@ export function parseVMInventoryExportDefinition(
typeof candidate.title !== 'string' ||
typeof candidate.description !== 'string' ||
candidate.format !== 'csv' ||
typeof candidate.exportEndpoint !== 'string' ||
typeof candidate.filenamePrefix !== 'string' ||
!Array.isArray(candidate.columns)
) {
@@ -86,6 +82,7 @@ export function parseVMInventoryExportDefinition(
title: candidate.title,
description: candidate.description,
format: 'csv',
exportEndpoint: candidate.exportEndpoint,
filenamePrefix: candidate.filenamePrefix,
columns,
};
@@ -1,9 +1,11 @@
import type { ReportingRangeOption } from '@/utils/reportingPresentation';
import { toReportingResourceType } from '@/utils/reportingResourceTypes';
import type { SelectedResource } from '@/components/Settings/ResourcePicker';
import type {
ReportingFormat,
ReportingPerformanceReportDefinition,
} from '@/components/Settings/reportingCatalogModel';
export type ReportingRangeValue = ReportingRangeOption['value'];
export type ReportingFormat = 'pdf' | 'csv';
export type ReportingRangeValue = string;
export interface ReportingRequestContext {
end: string;
@@ -27,11 +29,17 @@ export interface ReportingRequestDefinition {
};
}
export function getReportingRangeStart(range: ReportingRangeValue, now: Date): Date {
export function getReportingRangeStart(
range: ReportingRangeValue,
now: Date,
definition?: Pick<ReportingPerformanceReportDefinition, 'defaultRange' | 'ranges'> | null,
): Date {
const start = new Date(now);
if (range === '24h') start.setHours(start.getHours() - 24);
else if (range === '7d') start.setDate(start.getDate() - 7);
else if (range === '30d') start.setDate(start.getDate() - 30);
const resolvedRange =
definition?.ranges.find((candidate) => candidate.key === range) ??
definition?.ranges.find((candidate) => candidate.key === definition.defaultRange) ??
null;
start.setHours(start.getHours() - (resolvedRange?.windowHours ?? 24));
return start;
}
@@ -47,15 +55,25 @@ export function buildReportingFilename(
format: ReportingFormat,
resourceName: string | null,
now: Date,
definition?: Pick<
ReportingPerformanceReportDefinition,
'multiFilenamePrefix' | 'singleFilenamePrefix'
> | null,
): string {
const date = now.toISOString().split('T')[0];
if (resourceName) {
return `report-${resourceName}-${date}.${format}`;
return `${definition?.singleFilenamePrefix ?? 'report'}-${resourceName}-${date}.${format}`;
}
return `fleet-report-${date}.${format}`;
return `${definition?.multiFilenamePrefix ?? 'fleet-report'}-${date}.${format}`;
}
export function buildReportingRequest(context: ReportingRequestContext): ReportingRequestDefinition {
export function buildReportingRequest(
context: ReportingRequestContext,
definition?: Pick<
ReportingPerformanceReportDefinition,
'multiFilenamePrefix' | 'multiResourceEndpoint' | 'singleFilenamePrefix' | 'singleResourceEndpoint'
> | null,
): ReportingRequestDefinition {
if (context.resources.length === 1) {
const resource = context.resources[0];
const params = new URLSearchParams({
@@ -72,17 +90,17 @@ export function buildReportingRequest(context: ReportingRequestContext): Reporti
}
return {
filename: buildReportingFilename(context.format, resource.name, context.now),
filename: buildReportingFilename(context.format, resource.name, context.now, definition),
request: {
url: `/api/admin/reports/generate?${params.toString()}`,
url: `${definition?.singleResourceEndpoint ?? '/api/admin/reports/generate'}?${params.toString()}`,
},
};
}
return {
filename: buildReportingFilename(context.format, null, context.now),
filename: buildReportingFilename(context.format, null, context.now, definition),
request: {
url: '/api/admin/reports/generate-multi',
url: definition?.multiResourceEndpoint ?? '/api/admin/reports/generate-multi',
init: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -11,6 +11,7 @@ import {
} from '@/stores/license';
import { trackPaywallViewed } from '@/utils/upgradeMetrics';
import {
getReportingCatalogErrorMessage,
getReportingGenerateErrorMessage,
getReportingGenerateSelectionRequiredMessage,
getReportingGenerateSuccessMessage,
@@ -21,15 +22,17 @@ import { runStartProTrialAction } from '@/utils/trialStartAction';
import {
buildReportingRequest,
getReportingRangeStart,
type ReportingFormat,
type ReportingRangeValue,
} from '@/components/Settings/reportingPanelModel';
import {
buildVMInventoryExportDefinitionRequest,
buildVMInventoryExportRequest,
parseVMInventoryExportDefinition,
type ReportingInventoryExportDefinition,
} from '@/components/Settings/reportingInventoryExportModel';
import {
buildReportingCatalogRequest,
parseReportingCatalog,
type ReportingCatalog,
type ReportingFormat,
} from '@/components/Settings/reportingCatalogModel';
export const useReportingPanelState = () => {
const [selectedResources, setSelectedResources] = createSignal<SelectedResource[]>([]);
@@ -38,11 +41,10 @@ export const useReportingPanelState = () => {
const [range, setRange] = createSignal<ReportingRangeValue>('24h');
const [generating, setGenerating] = createSignal(false);
const [exportingInventory, setExportingInventory] = createSignal(false);
const [inventoryDefinition, setInventoryDefinition] =
createSignal<ReportingInventoryExportDefinition | null>(null);
const [inventoryDefinitionLoading, setInventoryDefinitionLoading] = createSignal(false);
const [inventoryDefinitionError, setInventoryDefinitionError] = createSignal('');
const [inventoryDefinitionRequested, setInventoryDefinitionRequested] = createSignal(false);
const [reportingCatalog, setReportingCatalog] = createSignal<ReportingCatalog | null>(null);
const [reportingCatalogLoading, setReportingCatalogLoading] = createSignal(false);
const [reportingCatalogError, setReportingCatalogError] = createSignal('');
const [reportingCatalogRequested, setReportingCatalogRequested] = createSignal(false);
const [title, setTitle] = createSignal('');
const [startingTrial, setStartingTrial] = createSignal(false);
@@ -66,37 +68,50 @@ export const useReportingPanelState = () => {
createEffect(() => {
if (
!isReportingEnabled() ||
inventoryDefinition() ||
inventoryDefinitionLoading() ||
inventoryDefinitionRequested()
reportingCatalog() ||
reportingCatalogLoading() ||
reportingCatalogRequested()
) {
return;
}
void (async () => {
setInventoryDefinitionRequested(true);
setInventoryDefinitionLoading(true);
setInventoryDefinitionError('');
setReportingCatalogRequested(true);
setReportingCatalogLoading(true);
setReportingCatalogError('');
try {
const request = buildVMInventoryExportDefinitionRequest();
const request = buildReportingCatalogRequest();
const response = await apiFetch(request.url);
if (!response.ok) {
const text = await response.text();
throw new Error(text || getReportingInventoryExportErrorMessage());
throw new Error(text || getReportingCatalogErrorMessage());
}
setInventoryDefinition(parseVMInventoryExportDefinition(await response.json()));
setReportingCatalog(parseReportingCatalog(await response.json()));
} catch (error) {
console.error('VM inventory export definition error:', error);
setInventoryDefinitionError(
error instanceof Error ? error.message : getReportingInventoryExportErrorMessage(),
console.error('Reporting catalog error:', error);
setReportingCatalogError(
error instanceof Error ? error.message : getReportingCatalogErrorMessage(),
);
} finally {
setInventoryDefinitionLoading(false);
setReportingCatalogLoading(false);
}
})();
});
createEffect(() => {
const performanceReport = reportingCatalog()?.performanceReport;
if (!performanceReport) {
return;
}
if (!performanceReport.formats.some((candidate) => candidate.value === format())) {
setFormat(performanceReport.defaultFormat);
}
if (!performanceReport.ranges.some((candidate) => candidate.key === range())) {
setRange(performanceReport.defaultRange);
}
});
const handleStartTrial = async () => {
if (startingTrial()) return;
setStartingTrial(true);
@@ -132,7 +147,11 @@ export const useReportingPanelState = () => {
setGenerating(true);
try {
const now = new Date();
const start = getReportingRangeStart(range(), now);
const performanceReport = reportingCatalog()?.performanceReport;
if (!performanceReport) {
throw new Error(getReportingGenerateErrorMessage());
}
const start = getReportingRangeStart(range(), now, performanceReport);
const request = buildReportingRequest({
end: now.toISOString(),
format: format(),
@@ -141,7 +160,7 @@ export const useReportingPanelState = () => {
resources,
start: start.toISOString(),
title: title(),
});
}, performanceReport);
const response = await apiFetch(request.request.url, request.request.init);
if (!response.ok) {
@@ -165,7 +184,11 @@ export const useReportingPanelState = () => {
setExportingInventory(true);
try {
const request = buildVMInventoryExportRequest(new Date(), inventoryDefinition());
const inventoryDefinition = reportingCatalog()?.vmInventoryExport;
if (!inventoryDefinition) {
throw new Error(getReportingInventoryExportErrorMessage());
}
const request = buildVMInventoryExportRequest(new Date(), inventoryDefinition);
const response = await apiFetch(request.request.url);
if (!response.ok) {
const text = await response.text();
@@ -192,13 +215,13 @@ export const useReportingPanelState = () => {
generating,
handleGenerate,
handleStartTrial,
inventoryDefinition,
inventoryDefinitionError,
inventoryDefinitionLoading,
isLocked,
isReportingEnabled,
metricType,
range,
reportingCatalog,
reportingCatalogError,
reportingCatalogLoading,
selectedResources,
setFormat,
setMetricType,
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import {
getReportingCatalogErrorMessage,
getReportingGenerateErrorMessage,
getReportingGenerateSelectionRequiredMessage,
getReportingGenerateSuccessMessage,
@@ -19,6 +20,7 @@ describe('reportingPresentation', () => {
expect(getReportingGenerateSelectionRequiredMessage()).toBe(
'Please select at least one resource',
);
expect(getReportingCatalogErrorMessage()).toBe('Failed to load reporting surfaces');
expect(getReportingGenerateSuccessMessage()).toBe('Report generated successfully');
expect(getReportingGenerateErrorMessage()).toBe('Failed to generate report');
});
@@ -21,6 +21,10 @@ export function getReportingGenerateErrorMessage(): string {
return 'Failed to generate report';
}
export function getReportingCatalogErrorMessage(): string {
return 'Failed to load reporting surfaces';
}
export function getReportingInventoryExportSuccessMessage(): string {
return 'VM inventory export generated successfully';
}
@@ -47,6 +47,7 @@ func TestReportingEndpointsRequireSettingsReadScope(t *testing.T) {
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
paths := []string{
"/api/admin/reports/catalog",
"/api/admin/reports/generate",
"/api/admin/reports/generate-multi",
"/api/admin/reports/inventory/vms/definition",
+138
View File
@@ -329,6 +329,143 @@ func TestContract_VMInventoryExportCSVHeaders(t *testing.T) {
}
}
func TestContract_ReportingCatalogJSONSnapshot(t *testing.T) {
handler := NewReportingHandlers(nil, nil)
req := httptest.NewRequest(http.MethodGet, "/api/admin/reports/catalog", nil)
rec := httptest.NewRecorder()
handler.HandleGetReportingCatalog(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Content-Type"); got != "application/json" {
t.Fatalf("expected json content type, got %q", got)
}
const want = `{
"id":"advanced_reporting",
"title":"Detailed Reporting",
"description":"Generate performance reports and current-state exports across infrastructure and workloads.",
"performanceReport":{
"id":"performance_reports",
"title":"Performance Reports",
"description":"Generate PDF summaries or CSV metric exports from historical monitoring data for one or more selected resources.",
"singleResourceEndpoint":"/api/admin/reports/generate",
"multiResourceEndpoint":"/api/admin/reports/generate-multi",
"singleFilenamePrefix":"report",
"multiFilenamePrefix":"fleet-report",
"formats":[
{
"value":"pdf",
"label":"PDF Report"
},
{
"value":"csv",
"label":"CSV Data"
}
],
"defaultFormat":"pdf",
"ranges":[
{
"key":"24h",
"label":"Last 24 Hours",
"description":"Current-day operational summary for short-term regressions.",
"windowHours":24
},
{
"key":"7d",
"label":"Last 7 Days",
"description":"Weekly trend window for recent performance changes.",
"windowHours":168
},
{
"key":"30d",
"label":"Last 30 Days",
"description":"Monthly review window for sustained capacity or reliability shifts.",
"windowHours":720
}
],
"defaultRange":"24h",
"multiResourceMax":50,
"supportsMetricFilter":true,
"supportsCustomTitle":true
},
"vmInventoryExport":{
"id":"vm_inventory",
"title":"VM Inventory Export",
"description":"Export the current fleet-wide VM inventory as CSV using the canonical runtime model. Includes VM identity, placement, CPU, memory allocation, disk allocation, and disk usage columns.",
"format":"csv",
"exportEndpoint":"/api/admin/reports/inventory/vms/export",
"filenamePrefix":"vm-inventory",
"columns":[
{
"key":"resource_id",
"label":"Resource ID",
"description":"Canonical Pulse resource ID for the VM."
},
{
"key":"instance",
"label":"Instance",
"description":"Configured Proxmox instance or cluster name."
},
{
"key":"node",
"label":"Node",
"description":"Proxmox node currently hosting the VM."
},
{
"key":"pool",
"label":"Pool",
"description":"Canonical Proxmox pool membership when the platform reports one."
},
{
"key":"vmid",
"label":"VMID",
"description":"Numeric Proxmox VM identifier."
},
{
"key":"vm_name",
"label":"VM Name",
"description":"Current VM display name from the runtime model."
},
{
"key":"status",
"label":"Status",
"description":"Canonical runtime status for the VM."
},
{
"key":"cpu_cores",
"label":"CPU Cores",
"description":"Allocated virtual CPU core count."
},
{
"key":"memory_allocated_bytes",
"label":"Memory Allocated Bytes",
"description":"Configured memory allocation in bytes."
},
{
"key":"disk_allocated_bytes",
"label":"Disk Allocated Bytes",
"description":"Total allocated disk capacity in bytes across the VM."
},
{
"key":"disk_used_bytes",
"label":"Disk Used Bytes",
"description":"Current used disk bytes from the canonical runtime disk view."
},
{
"key":"disk_status_reason",
"label":"Disk Status Reason",
"description":"Reason disk usage is partial or unavailable when the runtime cannot provide a full guest view."
}
]
}
}`
assertJSONSnapshot(t, rec.Body.Bytes(), want)
}
func TestContract_VMInventoryExportDefinitionJSONSnapshot(t *testing.T) {
handler := NewReportingHandlers(nil, nil)
req := httptest.NewRequest(http.MethodGet, "/api/admin/reports/inventory/vms/definition", nil)
@@ -348,6 +485,7 @@ func TestContract_VMInventoryExportDefinitionJSONSnapshot(t *testing.T) {
"title":"VM Inventory Export",
"description":"Export the current fleet-wide VM inventory as CSV using the canonical runtime model. Includes VM identity, placement, CPU, memory allocation, disk allocation, and disk usage columns.",
"format":"csv",
"exportEndpoint":"/api/admin/reports/inventory/vms/export",
"filenamePrefix":"vm-inventory",
"columns":[
{
@@ -9,11 +9,16 @@ import (
)
type testReportingAdminEndpoints struct {
catalogCalls int
generateCalls int
definitionCalls int
exportInventoryCalls int
}
func (t *testReportingAdminEndpoints) HandleGetReportingCatalog(http.ResponseWriter, *http.Request) {
t.catalogCalls++
}
func (t *testReportingAdminEndpoints) HandleGenerateReport(http.ResponseWriter, *http.Request) {
t.generateCalls++
}
@@ -85,6 +90,22 @@ func TestResolveReportingAdminEndpoints_UsesDefaultInventoryHandler(t *testing.T
}
}
func TestResolveReportingAdminEndpoints_UsesDefaultCatalogHandler(t *testing.T) {
SetReportingAdminEndpointsBinder(nil)
t.Cleanup(func() {
SetReportingAdminEndpointsBinder(nil)
})
defaults := &testReportingAdminEndpoints{}
resolved := resolveReportingAdminEndpoints(defaults, extensions.ReportingAdminRuntime{})
req := httptest.NewRequest(http.MethodGet, "/api/admin/reports/catalog", nil)
rec := httptest.NewRecorder()
resolved.HandleGetReportingCatalog(rec, req)
if defaults.catalogCalls != 1 {
t.Fatalf("expected default reporting catalog handler call, got %d", defaults.catalogCalls)
}
}
func TestResolveReportingAdminEndpoints_UsesDefaultInventoryDefinitionHandler(t *testing.T) {
SetReportingAdminEndpointsBinder(nil)
t.Cleanup(func() {
+1
View File
@@ -37,6 +37,7 @@ func TestReportingEndpointsRequireAuthInAPIMode(t *testing.T) {
path string
body string
}{
{method: http.MethodGet, path: "/api/admin/reports/catalog", body: ""},
{method: http.MethodGet, path: "/api/admin/reports/generate", body: ""},
{method: http.MethodPost, path: "/api/admin/reports/generate-multi", body: `{}`},
{method: http.MethodGet, path: "/api/admin/reports/inventory/vms/definition", body: ""},
@@ -0,0 +1,20 @@
package api
import (
"encoding/json"
"net/http"
"github.com/rcourtman/pulse-go-rewrite/pkg/reporting"
)
// HandleGetReportingCatalog returns the canonical operator-facing reporting
// catalog for the admin settings surface.
func (h *ReportingHandlers) HandleGetReportingCatalog(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(reporting.DescribeReportingCatalog())
}
+41
View File
@@ -237,6 +237,47 @@ func TestReportingHandlers_ExportVMInventory_MethodNotAllowed(t *testing.T) {
}
}
func TestReportingHandlers_GetReportingCatalog_MethodNotAllowed(t *testing.T) {
handler := NewReportingHandlers(nil, nil)
req := httptest.NewRequest(http.MethodPost, "/api/admin/reports/catalog", nil)
rr := httptest.NewRecorder()
handler.HandleGetReportingCatalog(rr, req)
if rr.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rr.Code)
}
}
func TestReportingHandlers_GetReportingCatalog_ReturnsCanonicalDefinition(t *testing.T) {
handler := NewReportingHandlers(nil, nil)
req := httptest.NewRequest(http.MethodGet, "/api/admin/reports/catalog", nil)
rr := httptest.NewRecorder()
handler.HandleGetReportingCatalog(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code)
}
if got := rr.Header().Get("Content-Type"); !strings.Contains(got, "application/json") {
t.Fatalf("expected JSON content-type, got %q", got)
}
var payload reporting.ReportingCatalog
if err := json.NewDecoder(rr.Body).Decode(&payload); err != nil {
t.Fatalf("decode catalog response: %v", err)
}
if payload.ID != "advanced_reporting" {
t.Fatalf("expected advanced_reporting id, got %q", payload.ID)
}
if payload.PerformanceReport.ID != "performance_reports" {
t.Fatalf("expected performance report definition, got %#v", payload.PerformanceReport)
}
if payload.VMInventoryExport.ID != "vm_inventory" {
t.Fatalf("expected vm inventory definition, got %#v", payload.VMInventoryExport)
}
}
func TestReportingHandlers_GetVMInventoryDefinition_MethodNotAllowed(t *testing.T) {
handler := NewReportingHandlers(nil, nil)
req := httptest.NewRequest(http.MethodPost, "/api/admin/reports/inventory/vms/definition", nil)
+1
View File
@@ -449,6 +449,7 @@ var allRouteAllowlist = []string{
"POST /api/admin/rbac/reset-admin",
"/api/admin/reports/generate",
"/api/admin/reports/generate-multi",
"/api/admin/reports/catalog",
"/api/admin/reports/inventory/vms/definition",
"/api/admin/reports/inventory/vms/export",
"/api/admin/webhooks/audit",
+14
View File
@@ -177,6 +177,12 @@ func (r *Router) registerOrgLicenseRoutesGroup(orgHandlers *OrgHandlers, rbacHan
}))
// Advanced Reporting routes
r.mux.HandleFunc("/api/admin/reports/catalog", RequirePermission(r.config, r.authorizer, auth.ActionRead, auth.ResourceNodes, func(w http.ResponseWriter, req *http.Request) {
if !ensureAdminSession(r.config, w, req) {
return
}
RequireLicenseFeature(r.licenseHandlers, featureAdvancedReportingValue, RequireScope(config.ScopeSettingsRead, reportingAdminEndpoints.HandleGetReportingCatalog))(w, req)
}))
r.mux.HandleFunc("/api/admin/reports/generate", RequirePermission(r.config, r.authorizer, auth.ActionRead, auth.ResourceNodes, func(w http.ResponseWriter, req *http.Request) {
if !ensureAdminSession(r.config, w, req) {
return
@@ -299,6 +305,14 @@ type reportingAdminEndpointAdapter struct {
var _ extensions.ReportingAdminEndpoints = reportingAdminEndpointAdapter{}
func (a reportingAdminEndpointAdapter) HandleGetReportingCatalog(w http.ResponseWriter, req *http.Request) {
if a.handlers == nil {
writeErrorResponse(w, http.StatusNotImplemented, "reporting_unavailable", "Reporting is not available", nil)
return
}
a.handlers.HandleGetReportingCatalog(w, req)
}
func (a reportingAdminEndpointAdapter) HandleGenerateReport(w http.ResponseWriter, req *http.Request) {
if a.handlers == nil {
writeErrorResponse(w, http.StatusNotImplemented, "reporting_unavailable", "Reporting is not available", nil)
+2
View File
@@ -2900,6 +2900,7 @@ func TestReportingEndpointsRequireLicenseFeature(t *testing.T) {
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
paths := []string{
"/api/admin/reports/catalog",
"/api/admin/reports/generate",
"/api/admin/reports/generate-multi",
}
@@ -3156,6 +3157,7 @@ func TestPermissionProtectedEndpointsDenyWhenAuthorizerBlocks(t *testing.T) {
{method: http.MethodPut, path: "/api/admin/users/alice/roles", body: `{"roleIds":["role-1"]}`},
{method: http.MethodPost, path: "/api/admin/users/alice/roles", body: `{"roleIds":["role-1"]}`},
{method: http.MethodGet, path: "/api/admin/users/alice/permissions", body: ""},
{method: http.MethodGet, path: "/api/admin/reports/catalog", body: ""},
{method: http.MethodGet, path: "/api/admin/reports/generate", body: ""},
{method: http.MethodPost, path: "/api/admin/reports/generate-multi", body: `{}`},
{method: http.MethodGet, path: "/api/admin/webhooks/audit", body: ""},
+1
View File
@@ -10,6 +10,7 @@ import (
// ReportingAdminEndpoints defines the enterprise reporting admin endpoint surface.
type ReportingAdminEndpoints interface {
HandleGetReportingCatalog(http.ResponseWriter, *http.Request)
HandleGenerateReport(http.ResponseWriter, *http.Request)
HandleGenerateMultiReport(http.ResponseWriter, *http.Request)
HandleGetVMInventoryDefinition(http.ResponseWriter, *http.Request)
+86
View File
@@ -0,0 +1,86 @@
package reporting
// ReportingFormatDefinition describes one supported output format for an
// operator-facing reporting surface.
type ReportingFormatDefinition struct {
Value ReportFormat `json:"value"`
Label string `json:"label"`
}
// ReportingRangeDefinition describes one supported time window for historical
// performance reporting.
type ReportingRangeDefinition struct {
Key string `json:"key"`
Label string `json:"label"`
Description string `json:"description"`
WindowHours int `json:"windowHours"`
}
// PerformanceReportDefinition describes the canonical performance reporting
// surface exposed to operators.
type PerformanceReportDefinition struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
SingleResourceEndpoint string `json:"singleResourceEndpoint"`
MultiResourceEndpoint string `json:"multiResourceEndpoint"`
SingleFilenamePrefix string `json:"singleFilenamePrefix"`
MultiFilenamePrefix string `json:"multiFilenamePrefix"`
Formats []ReportingFormatDefinition `json:"formats"`
DefaultFormat ReportFormat `json:"defaultFormat"`
Ranges []ReportingRangeDefinition `json:"ranges"`
DefaultRange string `json:"defaultRange"`
MultiResourceMax int `json:"multiResourceMax"`
SupportsMetricFilter bool `json:"supportsMetricFilter"`
SupportsCustomTitle bool `json:"supportsCustomTitle"`
}
// ReportingCatalog describes the backend-owned admin reporting surface for the
// Pulse settings UI.
type ReportingCatalog struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
PerformanceReport PerformanceReportDefinition `json:"performanceReport"`
VMInventoryExport VMInventoryExportDefinition `json:"vmInventoryExport"`
}
// DescribePerformanceReport returns the canonical definition for Pulse's
// historical performance reporting surface.
func DescribePerformanceReport() PerformanceReportDefinition {
return PerformanceReportDefinition{
ID: "performance_reports",
Title: "Performance Reports",
Description: "Generate PDF summaries or CSV metric exports from historical monitoring data for one or more selected resources.",
SingleResourceEndpoint: "/api/admin/reports/generate",
MultiResourceEndpoint: "/api/admin/reports/generate-multi",
SingleFilenamePrefix: "report",
MultiFilenamePrefix: "fleet-report",
Formats: []ReportingFormatDefinition{
{Value: FormatPDF, Label: "PDF Report"},
{Value: FormatCSV, Label: "CSV Data"},
},
DefaultFormat: FormatPDF,
Ranges: []ReportingRangeDefinition{
{Key: "24h", Label: "Last 24 Hours", Description: "Current-day operational summary for short-term regressions.", WindowHours: 24},
{Key: "7d", Label: "Last 7 Days", Description: "Weekly trend window for recent performance changes.", WindowHours: 168},
{Key: "30d", Label: "Last 30 Days", Description: "Monthly review window for sustained capacity or reliability shifts.", WindowHours: 720},
},
DefaultRange: "24h",
MultiResourceMax: 50,
SupportsMetricFilter: true,
SupportsCustomTitle: true,
}
}
// DescribeReportingCatalog returns the canonical backend-owned settings
// definition for the advanced reporting feature.
func DescribeReportingCatalog() ReportingCatalog {
return ReportingCatalog{
ID: "advanced_reporting",
Title: "Detailed Reporting",
Description: "Generate performance reports and current-state exports across infrastructure and workloads.",
PerformanceReport: DescribePerformanceReport(),
VMInventoryExport: DescribeVMInventoryExport(),
}
}
+23
View File
@@ -0,0 +1,23 @@
package reporting
import "testing"
func TestDescribeReportingCatalog_DefinesCanonicalSurfaces(t *testing.T) {
catalog := DescribeReportingCatalog()
if catalog.ID != "advanced_reporting" {
t.Fatalf("catalog ID = %q, want advanced_reporting", catalog.ID)
}
if catalog.PerformanceReport.ID != "performance_reports" {
t.Fatalf("performance report ID = %q, want performance_reports", catalog.PerformanceReport.ID)
}
if catalog.PerformanceReport.MultiResourceMax != 50 {
t.Fatalf("multi-resource max = %d, want 50", catalog.PerformanceReport.MultiResourceMax)
}
if got := len(catalog.PerformanceReport.Ranges); got != 3 {
t.Fatalf("range count = %d, want 3", got)
}
if catalog.VMInventoryExport.ExportEndpoint != "/api/admin/reports/inventory/vms/export" {
t.Fatalf("vm inventory export endpoint = %q", catalog.VMInventoryExport.ExportEndpoint)
}
}
+2
View File
@@ -24,6 +24,7 @@ type VMInventoryExportDefinition struct {
Title string `json:"title"`
Description string `json:"description"`
Format ReportFormat `json:"format"`
ExportEndpoint string `json:"exportEndpoint"`
FilenamePrefix string `json:"filenamePrefix"`
Columns []InventoryExportColumnDefinition `json:"columns"`
}
@@ -58,6 +59,7 @@ func DescribeVMInventoryExport() VMInventoryExportDefinition {
Title: "VM Inventory Export",
Description: "Export the current fleet-wide VM inventory as CSV using the canonical runtime model. Includes VM identity, placement, CPU, memory allocation, disk allocation, and disk usage columns.",
Format: FormatCSV,
ExportEndpoint: "/api/admin/reports/inventory/vms/export",
FilenamePrefix: "vm-inventory",
Columns: []InventoryExportColumnDefinition{
{Key: "resource_id", Label: "Resource ID", Description: "Canonical Pulse resource ID for the VM."},