diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md
index 0ff100620..3100b90e2 100644
--- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md
+++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md
@@ -176,6 +176,10 @@ 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-
+adjacent install and fleet surfaces may read those facts, but they must not
+redefine 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
diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md
index dea54bc72..25eae9ec6 100644
--- a/docs/release-control/v6/internal/subsystems/api-contracts.md
+++ b/docs/release-control/v6/internal/subsystems/api-contracts.md
@@ -217,8 +217,11 @@ 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/export` as the canonical VM inventory CSV
-contract. That export is intentionally spreadsheet-shaped rather than comment-
+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.
diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
index 5aaa2c2b4..2d2856b85 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -174,6 +174,11 @@ historical performance reports and current-state VM inventory export.
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.
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
diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md
index 0fcc53952..500c869cc 100644
--- a/docs/release-control/v6/internal/subsystems/storage-recovery.md
+++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md
@@ -222,6 +222,10 @@ 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 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
diff --git a/frontend-modern/src/components/Settings/ReportingPanel.tsx b/frontend-modern/src/components/Settings/ReportingPanel.tsx
index a031e15a6..e5e6e990b 100644
--- a/frontend-modern/src/components/Settings/ReportingPanel.tsx
+++ b/frontend-modern/src/components/Settings/ReportingPanel.tsx
@@ -1,4 +1,4 @@
-import { Show, JSX } from 'solid-js';
+import { For, Show, JSX } 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';
@@ -55,6 +55,9 @@ export function ReportingPanel() {
generating,
handleGenerate,
handleStartTrial,
+ inventoryDefinition,
+ inventoryDefinitionError,
+ inventoryDefinitionLoading,
isLocked,
isReportingEnabled,
metricType,
@@ -210,12 +213,34 @@ export function ReportingPanel() {
VM Inventory Export
- 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.
+ {inventoryDefinition()?.description ??
+ 'Export the current fleet-wide VM inventory as CSV using the canonical runtime model.'}
+
+ Loading export column definition...
+
+
+
+ {inventoryDefinitionError()}
+
+
+
+
+
+ {(column) => (
+
+
+ {column.label}
+
+
{column.description}
+
+ )}
+
+
+
+
{
@@ -12,9 +14,39 @@ 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);
+ const request = buildVMInventoryExportRequest(now, { 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',
+ filenamePrefix: 'vm-inventory',
+ columns: [
+ {
+ key: 'pool',
+ label: 'Pool',
+ description: 'Canonical Proxmox pool membership.',
+ },
+ ],
+ });
+
+ expect(definition.id).toBe('vm_inventory');
+ expect(definition.columns[0]).toEqual({
+ key: 'pool',
+ label: 'Pool',
+ description: 'Canonical Proxmox pool membership.',
+ });
+ });
});
diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts
index ca243e3b4..e378fa47a 100644
--- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts
+++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts
@@ -1093,6 +1093,7 @@ describe('Settings architecture guardrails', () => {
expect(reportingPanelStateSource).toContain('runStartProTrialAction({');
expect(reportingPanelStateSource).not.toContain('startProTrial()');
expect(reportingPanelStateSource).toContain('buildReportingRequest');
+ expect(reportingPanelStateSource).toContain('buildVMInventoryExportDefinitionRequest');
expect(reportingPanelStateSource).toContain('buildVMInventoryExportRequest');
expect(reportingPanelStateSource).toContain('getReportingGenerateSuccessMessage');
expect(reportingPanelStateSource).not.toContain('getTrialAlreadyUsedMessage()');
@@ -1102,6 +1103,12 @@ describe('Settings architecture guardrails', () => {
expect(reportingInventoryExportModelSource).toContain(
'export function buildVMInventoryExportFilename',
);
+ expect(reportingInventoryExportModelSource).toContain(
+ 'export function buildVMInventoryExportDefinitionRequest',
+ );
+ expect(reportingInventoryExportModelSource).toContain(
+ 'export function parseVMInventoryExportDefinition',
+ );
});
it('keeps the shared operations wrapper rooted at SettingsPanel', () => {
diff --git a/frontend-modern/src/components/Settings/reportingInventoryExportModel.ts b/frontend-modern/src/components/Settings/reportingInventoryExportModel.ts
index 6aeb0d22f..dd56469a0 100644
--- a/frontend-modern/src/components/Settings/reportingInventoryExportModel.ts
+++ b/frontend-modern/src/components/Settings/reportingInventoryExportModel.ts
@@ -5,19 +5,88 @@ export interface ReportingInventoryExportRequestDefinition {
};
}
-export function buildVMInventoryExportFilename(now: Date): string {
+export interface ReportingInventoryExportColumnDefinition {
+ key: string;
+ label: string;
+ description: string;
+}
+
+export interface ReportingInventoryExportDefinition {
+ id: string;
+ title: string;
+ description: string;
+ format: 'csv';
+ filenamePrefix: string;
+ columns: ReportingInventoryExportColumnDefinition[];
+}
+
+export function buildVMInventoryExportFilename(now: Date, filenamePrefix = 'vm-inventory'): string {
const date = now.toISOString().split('T')[0];
- return `vm-inventory-${date}.csv`;
+ return `${filenamePrefix}-${date}.csv`;
+}
+
+export function buildVMInventoryExportDefinitionRequest(): { url: string } {
+ return {
+ url: '/api/admin/reports/inventory/vms/definition',
+ };
}
export function buildVMInventoryExportRequest(
now: Date,
+ definition?: Pick | null,
): ReportingInventoryExportRequestDefinition {
const params = new URLSearchParams({ format: 'csv' });
return {
- filename: buildVMInventoryExportFilename(now),
+ filename: buildVMInventoryExportFilename(now, definition?.filenamePrefix ?? 'vm-inventory'),
request: {
url: `/api/admin/reports/inventory/vms/export?${params.toString()}`,
},
};
}
+
+export function parseVMInventoryExportDefinition(
+ input: unknown,
+): ReportingInventoryExportDefinition {
+ if (!input || typeof input !== 'object') {
+ throw new Error('Invalid VM inventory export definition payload');
+ }
+
+ const candidate = input as Partial;
+ if (
+ typeof candidate.id !== 'string' ||
+ typeof candidate.title !== 'string' ||
+ typeof candidate.description !== 'string' ||
+ candidate.format !== 'csv' ||
+ typeof candidate.filenamePrefix !== 'string' ||
+ !Array.isArray(candidate.columns)
+ ) {
+ throw new Error('Invalid VM inventory export definition payload');
+ }
+
+ const columns = candidate.columns.map((column) => {
+ if (
+ !column ||
+ typeof column !== 'object' ||
+ typeof column.key !== 'string' ||
+ typeof column.label !== 'string' ||
+ typeof column.description !== 'string'
+ ) {
+ throw new Error('Invalid VM inventory export definition payload');
+ }
+
+ return {
+ key: column.key,
+ label: column.label,
+ description: column.description,
+ };
+ });
+
+ return {
+ id: candidate.id,
+ title: candidate.title,
+ description: candidate.description,
+ format: 'csv',
+ filenamePrefix: candidate.filenamePrefix,
+ columns,
+ };
+}
diff --git a/frontend-modern/src/components/Settings/useReportingPanelState.ts b/frontend-modern/src/components/Settings/useReportingPanelState.ts
index f39c7d5e3..e75e98e31 100644
--- a/frontend-modern/src/components/Settings/useReportingPanelState.ts
+++ b/frontend-modern/src/components/Settings/useReportingPanelState.ts
@@ -24,7 +24,12 @@ import {
type ReportingFormat,
type ReportingRangeValue,
} from '@/components/Settings/reportingPanelModel';
-import { buildVMInventoryExportRequest } from '@/components/Settings/reportingInventoryExportModel';
+import {
+ buildVMInventoryExportDefinitionRequest,
+ buildVMInventoryExportRequest,
+ parseVMInventoryExportDefinition,
+ type ReportingInventoryExportDefinition,
+} from '@/components/Settings/reportingInventoryExportModel';
export const useReportingPanelState = () => {
const [selectedResources, setSelectedResources] = createSignal([]);
@@ -33,6 +38,11 @@ export const useReportingPanelState = () => {
const [range, setRange] = createSignal('24h');
const [generating, setGenerating] = createSignal(false);
const [exportingInventory, setExportingInventory] = createSignal(false);
+ const [inventoryDefinition, setInventoryDefinition] =
+ createSignal(null);
+ const [inventoryDefinitionLoading, setInventoryDefinitionLoading] = createSignal(false);
+ const [inventoryDefinitionError, setInventoryDefinitionError] = createSignal('');
+ const [inventoryDefinitionRequested, setInventoryDefinitionRequested] = createSignal(false);
const [title, setTitle] = createSignal('');
const [startingTrial, setStartingTrial] = createSignal(false);
@@ -53,6 +63,40 @@ export const useReportingPanelState = () => {
return visible;
}, false);
+ createEffect(() => {
+ if (
+ !isReportingEnabled() ||
+ inventoryDefinition() ||
+ inventoryDefinitionLoading() ||
+ inventoryDefinitionRequested()
+ ) {
+ return;
+ }
+
+ void (async () => {
+ setInventoryDefinitionRequested(true);
+ setInventoryDefinitionLoading(true);
+ setInventoryDefinitionError('');
+ try {
+ const request = buildVMInventoryExportDefinitionRequest();
+ const response = await apiFetch(request.url);
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(text || getReportingInventoryExportErrorMessage());
+ }
+
+ setInventoryDefinition(parseVMInventoryExportDefinition(await response.json()));
+ } catch (error) {
+ console.error('VM inventory export definition error:', error);
+ setInventoryDefinitionError(
+ error instanceof Error ? error.message : getReportingInventoryExportErrorMessage(),
+ );
+ } finally {
+ setInventoryDefinitionLoading(false);
+ }
+ })();
+ });
+
const handleStartTrial = async () => {
if (startingTrial()) return;
setStartingTrial(true);
@@ -121,7 +165,7 @@ export const useReportingPanelState = () => {
setExportingInventory(true);
try {
- const request = buildVMInventoryExportRequest(new Date());
+ const request = buildVMInventoryExportRequest(new Date(), inventoryDefinition());
const response = await apiFetch(request.request.url);
if (!response.ok) {
const text = await response.text();
@@ -148,6 +192,9 @@ export const useReportingPanelState = () => {
generating,
handleGenerate,
handleStartTrial,
+ inventoryDefinition,
+ inventoryDefinitionError,
+ inventoryDefinitionLoading,
isLocked,
isReportingEnabled,
metricType,
diff --git a/internal/api/audit_reporting_scope_test.go b/internal/api/audit_reporting_scope_test.go
index de1f00453..f00c8cf60 100644
--- a/internal/api/audit_reporting_scope_test.go
+++ b/internal/api/audit_reporting_scope_test.go
@@ -49,6 +49,7 @@ func TestReportingEndpointsRequireSettingsReadScope(t *testing.T) {
paths := []string{
"/api/admin/reports/generate",
"/api/admin/reports/generate-multi",
+ "/api/admin/reports/inventory/vms/definition",
"/api/admin/reports/inventory/vms/export",
}
diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go
index 5dd957c64..08a5bc64e 100644
--- a/internal/api/contract_test.go
+++ b/internal/api/contract_test.go
@@ -329,6 +329,93 @@ func TestContract_VMInventoryExportCSVHeaders(t *testing.T) {
}
}
+func TestContract_VMInventoryExportDefinitionJSONSnapshot(t *testing.T) {
+ handler := NewReportingHandlers(nil, nil)
+ req := httptest.NewRequest(http.MethodGet, "/api/admin/reports/inventory/vms/definition", nil)
+ rec := httptest.NewRecorder()
+
+ handler.HandleGetVMInventoryDefinition(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":"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",
+ "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_HostedTenantAISettingsFallbackJSONSnapshot(t *testing.T) {
t.Setenv("PULSE_HOSTED_MODE", "true")
diff --git a/internal/api/enterprise_extension_reporting_admin_test.go b/internal/api/enterprise_extension_reporting_admin_test.go
index f2ea12336..fbd8460ae 100644
--- a/internal/api/enterprise_extension_reporting_admin_test.go
+++ b/internal/api/enterprise_extension_reporting_admin_test.go
@@ -10,6 +10,7 @@ import (
type testReportingAdminEndpoints struct {
generateCalls int
+ definitionCalls int
exportInventoryCalls int
}
@@ -19,6 +20,10 @@ func (t *testReportingAdminEndpoints) HandleGenerateReport(http.ResponseWriter,
func (t *testReportingAdminEndpoints) HandleGenerateMultiReport(http.ResponseWriter, *http.Request) {}
+func (t *testReportingAdminEndpoints) HandleGetVMInventoryDefinition(http.ResponseWriter, *http.Request) {
+ t.definitionCalls++
+}
+
func (t *testReportingAdminEndpoints) HandleExportVMInventory(http.ResponseWriter, *http.Request) {
t.exportInventoryCalls++
}
@@ -79,3 +84,19 @@ func TestResolveReportingAdminEndpoints_UsesDefaultInventoryHandler(t *testing.T
t.Fatalf("expected default VM inventory handler call, got %d", defaults.exportInventoryCalls)
}
}
+
+func TestResolveReportingAdminEndpoints_UsesDefaultInventoryDefinitionHandler(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/inventory/vms/definition", nil)
+ rec := httptest.NewRecorder()
+ resolved.HandleGetVMInventoryDefinition(rec, req)
+ if defaults.definitionCalls != 1 {
+ t.Fatalf("expected default VM inventory definition handler call, got %d", defaults.definitionCalls)
+ }
+}
diff --git a/internal/api/rbac_reporting_auth_test.go b/internal/api/rbac_reporting_auth_test.go
index e8ab8cd32..6a8461169 100644
--- a/internal/api/rbac_reporting_auth_test.go
+++ b/internal/api/rbac_reporting_auth_test.go
@@ -39,6 +39,7 @@ func TestReportingEndpointsRequireAuthInAPIMode(t *testing.T) {
}{
{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: ""},
{method: http.MethodGet, path: "/api/admin/reports/inventory/vms/export", body: ""},
}
diff --git a/internal/api/reporting_handlers_test.go b/internal/api/reporting_handlers_test.go
index 04000cd17..a98f7e036 100644
--- a/internal/api/reporting_handlers_test.go
+++ b/internal/api/reporting_handlers_test.go
@@ -237,6 +237,44 @@ func TestReportingHandlers_ExportVMInventory_MethodNotAllowed(t *testing.T) {
}
}
+func TestReportingHandlers_GetVMInventoryDefinition_MethodNotAllowed(t *testing.T) {
+ handler := NewReportingHandlers(nil, nil)
+ req := httptest.NewRequest(http.MethodPost, "/api/admin/reports/inventory/vms/definition", nil)
+ rr := httptest.NewRecorder()
+
+ handler.HandleGetVMInventoryDefinition(rr, req)
+
+ if rr.Code != http.StatusMethodNotAllowed {
+ t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rr.Code)
+ }
+}
+
+func TestReportingHandlers_GetVMInventoryDefinition_ReturnsCanonicalDefinition(t *testing.T) {
+ handler := NewReportingHandlers(nil, nil)
+ req := httptest.NewRequest(http.MethodGet, "/api/admin/reports/inventory/vms/definition", nil)
+ rr := httptest.NewRecorder()
+
+ handler.HandleGetVMInventoryDefinition(rr, req)
+
+ if rr.Code != http.StatusOK {
+ t.Fatalf("expected status %d, got %d body=%s", http.StatusOK, rr.Code, rr.Body.String())
+ }
+ if contentType := rr.Header().Get("Content-Type"); !strings.Contains(contentType, "application/json") {
+ t.Fatalf("expected JSON content type, got %q", contentType)
+ }
+
+ var payload reporting.VMInventoryExportDefinition
+ if err := json.NewDecoder(rr.Body).Decode(&payload); err != nil {
+ t.Fatalf("decode definition payload: %v", err)
+ }
+ if payload.ID != "vm_inventory" || payload.Format != reporting.FormatCSV {
+ t.Fatalf("unexpected VM inventory definition payload: %+v", payload)
+ }
+ if len(payload.Columns) == 0 || payload.Columns[3].Label != "Pool" {
+ t.Fatalf("expected canonical Pool column in definition payload, got %+v", payload.Columns)
+ }
+}
+
func TestReportingHandlers_ExportVMInventory_InvalidFormat(t *testing.T) {
handler := NewReportingHandlers(nil, nil)
req := httptest.NewRequest(http.MethodGet, "/api/admin/reports/inventory/vms/export?format=pdf", nil)
diff --git a/internal/api/reporting_inventory_handlers.go b/internal/api/reporting_inventory_handlers.go
index 1e8121d7d..b68a9ca9d 100644
--- a/internal/api/reporting_inventory_handlers.go
+++ b/internal/api/reporting_inventory_handlers.go
@@ -1,6 +1,7 @@
package api
import (
+ "encoding/json"
"fmt"
"net/http"
"time"
@@ -9,6 +10,18 @@ import (
"github.com/rcourtman/pulse-go-rewrite/pkg/reporting"
)
+// HandleGetVMInventoryDefinition returns the canonical operator-facing
+// definition for the VM inventory export surface.
+func (h *ReportingHandlers) HandleGetVMInventoryDefinition(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.DescribeVMInventoryExport())
+}
+
// HandleExportVMInventory exports the current VM inventory as spreadsheet-friendly CSV.
func (h *ReportingHandlers) HandleExportVMInventory(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
diff --git a/internal/api/route_inventory_test.go b/internal/api/route_inventory_test.go
index af10d8ef5..741d75bba 100644
--- a/internal/api/route_inventory_test.go
+++ b/internal/api/route_inventory_test.go
@@ -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/inventory/vms/definition",
"/api/admin/reports/inventory/vms/export",
"/api/admin/webhooks/audit",
"/api/security/change-password",
diff --git a/internal/api/router_routes_licensing.go b/internal/api/router_routes_licensing.go
index 098dc798b..68b08616c 100644
--- a/internal/api/router_routes_licensing.go
+++ b/internal/api/router_routes_licensing.go
@@ -189,6 +189,12 @@ func (r *Router) registerOrgLicenseRoutesGroup(orgHandlers *OrgHandlers, rbacHan
}
RequireLicenseFeature(r.licenseHandlers, featureAdvancedReportingValue, RequireScope(config.ScopeSettingsRead, reportingAdminEndpoints.HandleGenerateMultiReport))(w, req)
}))
+ r.mux.HandleFunc("/api/admin/reports/inventory/vms/definition", 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.HandleGetVMInventoryDefinition))(w, req)
+ }))
r.mux.HandleFunc("/api/admin/reports/inventory/vms/export", RequirePermission(r.config, r.authorizer, auth.ActionRead, auth.ResourceNodes, func(w http.ResponseWriter, req *http.Request) {
if !ensureAdminSession(r.config, w, req) {
return
@@ -309,6 +315,14 @@ func (a reportingAdminEndpointAdapter) HandleGenerateMultiReport(w http.Response
a.handlers.HandleGenerateMultiReport(w, req)
}
+func (a reportingAdminEndpointAdapter) HandleGetVMInventoryDefinition(w http.ResponseWriter, req *http.Request) {
+ if a.handlers == nil {
+ writeErrorResponse(w, http.StatusNotImplemented, "reporting_unavailable", "Reporting is not available", nil)
+ return
+ }
+ a.handlers.HandleGetVMInventoryDefinition(w, req)
+}
+
func (a reportingAdminEndpointAdapter) HandleExportVMInventory(w http.ResponseWriter, req *http.Request) {
if a.handlers == nil {
writeErrorResponse(w, http.StatusNotImplemented, "reporting_unavailable", "Reporting is not available", nil)
diff --git a/pkg/extensions/reporting_admin.go b/pkg/extensions/reporting_admin.go
index 896754238..629031791 100644
--- a/pkg/extensions/reporting_admin.go
+++ b/pkg/extensions/reporting_admin.go
@@ -12,6 +12,7 @@ import (
type ReportingAdminEndpoints interface {
HandleGenerateReport(http.ResponseWriter, *http.Request)
HandleGenerateMultiReport(http.ResponseWriter, *http.Request)
+ HandleGetVMInventoryDefinition(http.ResponseWriter, *http.Request)
HandleExportVMInventory(http.ResponseWriter, *http.Request)
}
diff --git a/pkg/reporting/vm_inventory.go b/pkg/reporting/vm_inventory.go
index aef9fd0cf..c24fe152b 100644
--- a/pkg/reporting/vm_inventory.go
+++ b/pkg/reporting/vm_inventory.go
@@ -9,6 +9,25 @@ import (
"time"
)
+// InventoryExportColumnDefinition describes one stable column in a current-state
+// inventory export contract.
+type InventoryExportColumnDefinition struct {
+ Key string `json:"key"`
+ Label string `json:"label"`
+ Description string `json:"description"`
+}
+
+// VMInventoryExportDefinition describes the operator-facing contract for the VM
+// inventory export surface.
+type VMInventoryExportDefinition struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Format ReportFormat `json:"format"`
+ FilenamePrefix string `json:"filenamePrefix"`
+ Columns []InventoryExportColumnDefinition `json:"columns"`
+}
+
// VMInventoryData captures the current-state VM inventory export payload.
type VMInventoryData struct {
GeneratedAt time.Time
@@ -31,19 +50,39 @@ type VMInventoryRow struct {
DiskStatusReason string
}
-var vmInventoryCSVHeaders = []string{
- "Resource ID",
- "Instance",
- "Node",
- "Pool",
- "VMID",
- "VM Name",
- "Status",
- "CPU Cores",
- "Memory Allocated Bytes",
- "Disk Allocated Bytes",
- "Disk Used Bytes",
- "Disk Status Reason",
+// DescribeVMInventoryExport returns the canonical operator-facing definition for
+// the current-state VM inventory CSV surface.
+func DescribeVMInventoryExport() VMInventoryExportDefinition {
+ return VMInventoryExportDefinition{
+ 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: FormatCSV,
+ FilenamePrefix: "vm-inventory",
+ Columns: []InventoryExportColumnDefinition{
+ {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."},
+ },
+ }
+}
+
+func vmInventoryCSVHeaders() []string {
+ definition := DescribeVMInventoryExport()
+ headers := make([]string, 0, len(definition.Columns))
+ for _, column := range definition.Columns {
+ headers = append(headers, column.Label)
+ }
+ return headers
}
// GenerateVMInventoryCSV renders a spreadsheet-friendly CSV export for the
@@ -52,7 +91,7 @@ func GenerateVMInventoryCSV(data VMInventoryData) ([]byte, error) {
var buf bytes.Buffer
w := csv.NewWriter(&buf)
- if err := w.Write(vmInventoryCSVHeaders); err != nil {
+ if err := w.Write(vmInventoryCSVHeaders()); err != nil {
return nil, fmt.Errorf("write VM inventory CSV header: %w", err)
}
diff --git a/pkg/reporting/vm_inventory_test.go b/pkg/reporting/vm_inventory_test.go
index 30164e425..0ad101756 100644
--- a/pkg/reporting/vm_inventory_test.go
+++ b/pkg/reporting/vm_inventory_test.go
@@ -5,6 +5,23 @@ import (
"testing"
)
+func TestDescribeVMInventoryExport_DefinesCanonicalColumns(t *testing.T) {
+ definition := DescribeVMInventoryExport()
+
+ if definition.ID != "vm_inventory" {
+ t.Fatalf("definition ID = %q, want vm_inventory", definition.ID)
+ }
+ if definition.Format != FormatCSV {
+ t.Fatalf("definition format = %q, want %q", definition.Format, FormatCSV)
+ }
+ if got := len(definition.Columns); got != 12 {
+ t.Fatalf("definition columns = %d, want 12", got)
+ }
+ if definition.Columns[3].Key != "pool" || definition.Columns[3].Label != "Pool" {
+ t.Fatalf("expected canonical Pool column at index 3, got %+v", definition.Columns[3])
+ }
+}
+
func TestGenerateVMInventoryCSV_SortsRowsAndWritesHeader(t *testing.T) {
data := VMInventoryData{
Rows: []VMInventoryRow{