mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-22 03:02:35 +00:00
Define VM inventory export schema contract
This commit is contained in:
parent
968667330f
commit
bb6571fd20
20 changed files with 455 additions and 26 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<div class="space-y-2">
|
||||
<h4 class="text-base font-semibold text-base-content">VM Inventory Export</h4>
|
||||
<p class="text-sm text-muted">
|
||||
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.'}
|
||||
</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>
|
||||
</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 ${
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildVMInventoryExportDefinitionRequest,
|
||||
buildVMInventoryExportFilename,
|
||||
buildVMInventoryExportRequest,
|
||||
parseVMInventoryExportDefinition,
|
||||
} from '../reportingInventoryExportModel';
|
||||
|
||||
describe('reporting inventory export model', () => {
|
||||
|
|
@ -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.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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<ReportingInventoryExportDefinition, 'filenamePrefix'> | 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<ReportingInventoryExportDefinition>;
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SelectedResource[]>([]);
|
||||
|
|
@ -33,6 +38,11 @@ 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 [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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: ""},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue