mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Canonicalize reporting settings catalog
This commit is contained in:
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>
|
||||
|
||||
|
||||
+7
-3
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
+6
-8
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: ""},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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."},
|
||||
|
||||
Reference in New Issue
Block a user