mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-25 21:20:00 +00:00
Schedule the existing VM inventory CSV export (#3039)
VM inventory was available only as an on-demand download. It can now be scheduled weekly or monthly as CSV, emailed or saved on the Pulse server. Each attempt exports all current VMs in the organization, including newly discovered VMs, through the existing exporter. Historical performance-report limits and windows remain separate. The schedule uses existing delivery configuration checks, persisted success/failure/uncertain states, bounded retries and deliberate recovery controls. Pricing, feature gates, retention entitlements and existing customer grants are unchanged. Live checks also reproduced two unattended inventory defects: reporting retained a stopped monitor after configuration reload, and canonical snapshot reads relied on dashboard reads to publish polled VM inventory. Reporting now receives reload replacements through an atomic manager reference. Canonical snapshot reads reuse the existing publication boundary, including its freshness fast path, provider/mode fences and nonblocking warm reads. Validation: - 105 focused frontend tests; full type check, lint, production build and bundle budgets. - Race checks for reporting, inventory, reload, publication and organization isolation; clean private runtime build. - Current runtime browser checks on `/settings/support/reporting` and `/alerts/notifications` at 1440×1000, 820×844 and 390×844, with actual pixel review. The first inventory read exports 61 normally polled VMs; real MIME CSV matches the download; a newly discovered 62nd VM enters the next export. - Local SMTP acceptance, 451 retry across a real restart, permanent rejection, uncertain acknowledgement and deliberate recovery; disk delivery, edit/reload, disable, details and delete. - Staged status, registry, contract, completion and exact-content browser guards pass. Regression proof is registered against the specific runtime paths it covers.
This commit is contained in:
@@ -5755,6 +5755,10 @@ whose nested VM inventory definition owns 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 reporting or inventory schema locally.
|
||||
The adjacent `vm_inventory` schedule kind uses this same current-state VM
|
||||
exporter through the reporting scheduler. Agent lifecycle does not own a
|
||||
parallel inventory report or delivery queue, and agent/installation rights
|
||||
are unchanged.
|
||||
That catalog route is intentionally metadata-readable without the
|
||||
`advanced_reporting` feature gate so locked admin reporting shells can stay on
|
||||
the same API-owned definition before upsell; lifecycle-adjacent surfaces must
|
||||
|
||||
@@ -12139,6 +12139,28 @@ Proofs: `internal/api/report_schedule_delivery_test.go`,
|
||||
`internal/config/report_schedule_delivery_test.go`,
|
||||
`internal/notifications/email_delivery_test.go` and the Reports browser receipt.
|
||||
|
||||
The additive `vm_inventory` schedule kind uses the same current-state unified
|
||||
VM projection and CSV generator as the on-demand inventory export. It includes
|
||||
all VMs in the current organization on each attempt, including newly discovered
|
||||
VMs, and has no historical window or performance-report resource cap. Only CSV
|
||||
and an empty resource/tag scope are accepted. Weekly/monthly cadence, file
|
||||
retention, notification preflight, delivery outcomes, bounded retry, deliberate
|
||||
uncertain-delivery recovery and current `advanced_reporting` entitlement checks
|
||||
are shared with other file reports. An unavailable organization snapshot fails
|
||||
instead of exporting the default organization or pretending an empty inventory
|
||||
is a successful read. This changes no prices or existing grants.
|
||||
`internal/api/report_schedule_inventory_test.go` covers export equivalence,
|
||||
inventories above fifty VMs, later discovery, validation, retry after reload and
|
||||
unavailable-organization/current-entitlement failure.
|
||||
The monitor-owned canonical snapshot read refreshes its shared resource
|
||||
projection for unattended consumers. Neither export nor scheduling calls the
|
||||
frontend state endpoint to make inventory appear.
|
||||
`Router.SetMultiTenantMonitor` also replaces reporting's manager after runtime
|
||||
reload. Reporting captures that dependency atomically for each read, so exports,
|
||||
schedule persistence and retention checks do not keep using a stopped monitor.
|
||||
Clearing the dependency fails closed. `reporting_monitor_reload_test.go` covers
|
||||
replacement inventory/persistence and reads overlapping replacement.
|
||||
|
||||
### Explicit historical incident archives
|
||||
|
||||
`internal/api/router.go` pins a read-only legacy archive to each organization's
|
||||
|
||||
@@ -5161,6 +5161,12 @@ 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.
|
||||
The schedule editor also offers `vm_inventory` as a current-state CSV job. It
|
||||
explains the whole-organization scope and new-VM discovery, hides historical
|
||||
format/resource/tag controls and keeps weekly/monthly cadence and email/disk
|
||||
delivery available. Kind switching clears stale performance selections, while
|
||||
saved inventory schedules retain their kind on reload and edit. Existing
|
||||
delivery checks, outcomes and recovery controls apply without a separate UI.
|
||||
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
|
||||
|
||||
@@ -7347,6 +7347,15 @@ collections. Provider callbacks run outside wiring, publication and adapter lock
|
||||
Read refreshes share ingest publication ordering, retain fresh-generation skips
|
||||
and never queue behind a warm ingest; a cold read still builds its first registry.
|
||||
|
||||
Canonical unified snapshot reads also enter this same read-refresh boundary,
|
||||
with a cheap fresh-store check before taking a state copy. Unattended inventory
|
||||
and reporting consumers must not depend on a dashboard `/api/state` request to
|
||||
publish polled VM data. Mock fixture projections and stores without read-refresh
|
||||
support keep their existing read semantics. The refresh retains the existing
|
||||
provider, tenant, wiring and mode fences and warm non-blocking publication gate.
|
||||
`unified_inventory_read_refresh_test.go` covers headless VM publication and a
|
||||
warm read contending with ingest publication.
|
||||
|
||||
`resource_publication_ownership_test.go` supplies deterministic channel barriers,
|
||||
consume-once replacement-store controls, refused and skipped drains, read-provider
|
||||
re-entry, and duplicate/loss assertions, alongside the original initialization,
|
||||
|
||||
@@ -3250,10 +3250,46 @@
|
||||
"test_prefixes": [],
|
||||
"exact_files": [
|
||||
"internal/api/report_schedule_delivery_test.go",
|
||||
"internal/api/report_schedule_inventory_test.go",
|
||||
"internal/api/report_schedules_test.go",
|
||||
"internal/config/report_schedule_delivery_test.go"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "reporting-runtime-reads",
|
||||
"label": "reporting inventory, enrichment and reload proof",
|
||||
"match_prefixes": [],
|
||||
"match_files": [
|
||||
"internal/api/metrics_reporting_handlers.go",
|
||||
"internal/api/reporting_availability.go",
|
||||
"internal/api/reporting_inventory_handlers.go"
|
||||
],
|
||||
"allow_same_subsystem_tests": false,
|
||||
"test_prefixes": [],
|
||||
"exact_files": [
|
||||
"internal/api/report_schedule_inventory_test.go",
|
||||
"internal/api/reporting_availability_test.go",
|
||||
"internal/api/reporting_handlers_test.go",
|
||||
"internal/api/reporting_monitor_reload_test.go",
|
||||
"internal/api/reporting_org_scope_test.go",
|
||||
"internal/api/reporting_runtime_snapshot_test.go"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "router-runtime-wiring",
|
||||
"label": "router dependency replacement proof",
|
||||
"match_prefixes": [],
|
||||
"match_files": [
|
||||
"internal/api/router_helpers.go"
|
||||
],
|
||||
"allow_same_subsystem_tests": false,
|
||||
"test_prefixes": [],
|
||||
"exact_files": [
|
||||
"internal/api/multi_tenant_setters_additional_test.go",
|
||||
"internal/api/reporting_monitor_reload_test.go",
|
||||
"internal/api/router_helpers_more_test.go"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "backend-payload-contracts",
|
||||
"label": "backend API payload proof",
|
||||
@@ -6631,6 +6667,20 @@
|
||||
"internal/monitoring/monitoring_fleet_doctor_helpers_branchcov0716_test.go"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "unified-read-freshness",
|
||||
"label": "canonical resource freshness and unattended read proof",
|
||||
"match_prefixes": [],
|
||||
"match_files": [
|
||||
"internal/monitoring/resource_stale_thresholds.go"
|
||||
],
|
||||
"allow_same_subsystem_tests": false,
|
||||
"test_prefixes": [],
|
||||
"exact_files": [
|
||||
"internal/monitoring/resource_stale_thresholds_test.go",
|
||||
"internal/monitoring/unified_inventory_read_refresh_test.go"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "monitoring-runtime",
|
||||
"label": "monitoring runtime proof",
|
||||
|
||||
@@ -4966,6 +4966,10 @@ whose nested VM inventory definition owns 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.
|
||||
The API-owned `vm_inventory` schedule kind reuses that same exporter and the
|
||||
reporting-owned email/disk delivery and retry path. Current-state inventory
|
||||
scheduling does not add storage history, change retention entitlements or
|
||||
move generated artifacts out of their organization-local reporting storage.
|
||||
That catalog route is intentionally metadata-readable without the
|
||||
`advanced_reporting` feature gate so locked admin reporting shells can stay on
|
||||
the same API-owned definition before upsell; storage- and recovery-adjacent
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "3f511a5270a813ad48bbe7a74f2411fb276180be",
|
||||
"verified_at": "2026-10-17T13:14:54.818Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Settings/ReportScheduleEditor.tsx",
|
||||
"frontend-modern/src/components/Settings/ReportingPanel.tsx",
|
||||
"frontend-modern/src/components/Settings/reportingSchedulesModel.ts",
|
||||
"frontend-modern/src/components/Settings/useReportingPanelState.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Settings/ReportScheduleEditor.tsx": "e3b6eaa12d3545afda642eca4fdd527c299d0f84e2dec68da6dffca184c471ac",
|
||||
"frontend-modern/src/components/Settings/ReportingPanel.tsx": "5af4982de629b41375934efe399fa46ed103c32276ee306ae4743e24c460855c",
|
||||
"frontend-modern/src/components/Settings/reportingSchedulesModel.ts": "1e034dcac867b7a3e1210ba25e1e3a60048ae544ada0bd7b55c96e011a2118d4",
|
||||
"frontend-modern/src/components/Settings/useReportingPanelState.ts": "29ec982d667d5329609c21b5cd946df5fc89fd8b90d629c8017d3bd7baa87751"
|
||||
},
|
||||
"routes": [
|
||||
"/settings/support/reporting",
|
||||
"/alerts/notifications"
|
||||
],
|
||||
"viewports": [
|
||||
{ "width": 1440, "height": 1000 },
|
||||
{ "width": 820, "height": 844 },
|
||||
{ "width": 390, "height": 844 }
|
||||
],
|
||||
"states": [
|
||||
"CSV is the first inventory read after normal infrastructure configuration and runtime reload, without dashboard or resources API priming; all 61 polled VMs appear",
|
||||
"Empty list, focused inventory editor, fixed CSV and organization-wide scope, weekly/monthly cadence, email/disk delivery and disabled recipients",
|
||||
"Missing email configuration refuses enabled schedules; a disabled draft saves and links to Notifications",
|
||||
"Actual accepted MIME attachment matches the existing on-demand exporter byte for byte, including every VM beyond the performance-report cap",
|
||||
"Temporary 451 failure persists across a real process restart and succeeds once on attempt 2; permanent rejection stays failed without automatic retry",
|
||||
"Lost final acknowledgement remains unconfirmed; deliberate retry warns about duplication and recovers",
|
||||
"A newly discovered 62nd VM enters the next disk export without changing scope; disk delivery sends no mail"
|
||||
],
|
||||
"interactions": [
|
||||
"Create, edit, save, cancel, and reload; inspect name focus and switch performance/inventory kinds, weekly/monthly cadence and email/disk delivery at all three widths",
|
||||
"Inspect top and delivery-controls views of the editor, including retention, disabled recipients, attachment state, enabled toggle and reachable Save/Cancel controls",
|
||||
"Follow Email settings to Notifications, configure local SMTP and enable the persisted draft",
|
||||
"Run actual CSV delivery, parse its MIME attachment, restart during a pending retry, and inspect accepted/retrying/rejected/unconfirmed results",
|
||||
"Cancel uncertain-delivery retry without sending, then accept the duplicate warning and recover",
|
||||
"Edit to disk delivery, disable, expand report details, retain expansion through polling, cancel deletion and confirm deletion"
|
||||
],
|
||||
"environment": "Current private runtime built over base_sha, ordinary disposable signed Pro grant, dev/mock modes disabled. Synthetic Proxmox infrastructure and local SMTP only. Pricing and existing grants are unchanged.",
|
||||
"backend_content_sha256": {
|
||||
"internal/api/metrics_reporting_handlers.go": "e261d0bbb847e7512c1f8e26015da49b3f3f3f10bd61ba67bea6e5b5815ac300",
|
||||
"internal/api/report_schedules.go": "6aa8315b9f5f23e885ebc8ade8ffa8caef33c83c963be57848d0dbf615ca8698",
|
||||
"internal/api/reporting_availability.go": "2e23049bf046eab01bfdfccd4f6ce1babf8e7252c35525eac8a08d8e1a4102ee",
|
||||
"internal/api/reporting_inventory_handlers.go": "81e3a460929bf36a02e0af770e2490acc5132131a6b9cc8b52ece771869ebdba",
|
||||
"internal/api/router_helpers.go": "68b2e32beec9753b63c159f76f0ccd2fe4e5958cab80782b2238cf3d9e3ec75d",
|
||||
"internal/config/report_schedules.go": "c7230c33c825a621c892958fbaecf1a530559473afeef96ddf32caa01ea2e225",
|
||||
"internal/monitoring/resource_stale_thresholds.go": "2a9a5f3248a43af7f27eb4dbd75a822241ad3a7decf12bb8193864e5f749c36a"
|
||||
},
|
||||
"evidence": "/opt/pulse-release-worker/task-proofs/vm-inventory-schedules-20261017/browser-evidence",
|
||||
"pixel_review": "Reviewed current desktop, intermediate and mobile screenshots of editor/cadence controls, delivery controls and Save/Cancel, accepted and pending retry states, unconfirmed delivery and expanded disk details. Controls remain readable, scrollable and usable without horizontal overflow."
|
||||
}
|
||||
@@ -51,8 +51,11 @@ export default function ReportScheduleEditor(props: { state: EditorState }) {
|
||||
} = props.state;
|
||||
const performanceReport = () => reportingCatalog()?.performanceReport ?? null;
|
||||
const isDigestForm = () => scheduleForm().kind === 'patrol_digest';
|
||||
const isInventoryForm = () => scheduleForm().kind === 'vm_inventory';
|
||||
// Existing digest schedules stay editable after Patrol is switched off.
|
||||
const showReportTypeSelect = () => patrolDigestAvailable() || isDigestForm();
|
||||
const inventoryAvailable = () => !!reportingCatalog()?.vmInventoryExport || isInventoryForm();
|
||||
const showReportTypeSelect = () =>
|
||||
inventoryAvailable() || patrolDigestAvailable() || isDigestForm();
|
||||
return (
|
||||
<section class="space-y-4 border-y border-base-300 py-4">
|
||||
<Show when={showReportTypeSelect()}>
|
||||
@@ -64,11 +67,18 @@ export default function ReportScheduleEditor(props: { state: EditorState }) {
|
||||
help={
|
||||
isDigestForm()
|
||||
? 'Emails what Patrol checked, found, fixed, and spent over the last seven days. Nothing is saved to disk.'
|
||||
: 'A performance report for the resources you pick, as a PDF or CSV.'
|
||||
: isInventoryForm()
|
||||
? 'Exports the current inventory of all VMs in this organization as CSV each time it runs, including newly discovered VMs.'
|
||||
: 'A performance report for the resources you pick, as a PDF or CSV.'
|
||||
}
|
||||
>
|
||||
<option value="resources">{reportScheduleKindLabel('resources')}</option>
|
||||
<option value="patrol_digest">{reportScheduleKindLabel('patrol_digest')}</option>
|
||||
<Show when={inventoryAvailable()}>
|
||||
<option value="vm_inventory">{reportScheduleKindLabel('vm_inventory')}</option>
|
||||
</Show>
|
||||
<Show when={patrolDigestAvailable() || isDigestForm()}>
|
||||
<option value="patrol_digest">{reportScheduleKindLabel('patrol_digest')}</option>
|
||||
</Show>
|
||||
</FormSelect>
|
||||
</div>
|
||||
</Show>
|
||||
@@ -81,7 +91,13 @@ export default function ReportScheduleEditor(props: { state: EditorState }) {
|
||||
class={formControl}
|
||||
value={scheduleForm().name}
|
||||
onInput={(e) => updateScheduleForm({ name: e.currentTarget.value })}
|
||||
placeholder={isDigestForm() ? 'Patrol weekly summary' : 'Monthly client report'}
|
||||
placeholder={
|
||||
isDigestForm()
|
||||
? 'Patrol weekly summary'
|
||||
: isInventoryForm()
|
||||
? 'Monthly VM inventory'
|
||||
: 'Monthly client report'
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Timezone">
|
||||
@@ -100,7 +116,9 @@ export default function ReportScheduleEditor(props: { state: EditorState }) {
|
||||
class={
|
||||
isDigestForm()
|
||||
? 'grid grid-cols-1 gap-4 md:grid-cols-2'
|
||||
: 'grid grid-cols-1 gap-4 md:grid-cols-4'
|
||||
: isInventoryForm()
|
||||
? 'grid grid-cols-1 gap-4 md:grid-cols-3'
|
||||
: 'grid grid-cols-1 gap-4 md:grid-cols-4'
|
||||
}
|
||||
>
|
||||
<Show when={!isDigestForm()}>
|
||||
@@ -152,7 +170,7 @@ export default function ReportScheduleEditor(props: { state: EditorState }) {
|
||||
onInput={(e) => updateScheduleForm({ time: e.currentTarget.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<Show when={!isDigestForm()}>
|
||||
<Show when={scheduleForm().kind === 'resources'}>
|
||||
<FormSelect
|
||||
label="Format"
|
||||
value={scheduleForm().format}
|
||||
@@ -166,7 +184,7 @@ export default function ReportScheduleEditor(props: { state: EditorState }) {
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={!isDigestForm()}>
|
||||
<Show when={scheduleForm().kind === 'resources'}>
|
||||
<FormField
|
||||
label="Resources"
|
||||
helpText="Use explicit resources, tags, or both. Scheduled reports use the previous reporting boundary."
|
||||
@@ -180,17 +198,21 @@ export default function ReportScheduleEditor(props: { state: EditorState }) {
|
||||
</Show>
|
||||
|
||||
<Show when={!isDigestForm()}>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<FormField label="Tag filter" helpText="Comma-separated tags">
|
||||
<input
|
||||
aria-label="Tag filter"
|
||||
type="text"
|
||||
class={formControl}
|
||||
value={scheduleForm().tagFilter}
|
||||
onInput={(e) => updateScheduleForm({ tagFilter: e.currentTarget.value })}
|
||||
placeholder="production, customer-facing"
|
||||
/>
|
||||
</FormField>
|
||||
<div
|
||||
class={`grid grid-cols-1 gap-4 ${isInventoryForm() ? 'md:grid-cols-2' : 'md:grid-cols-3'}`}
|
||||
>
|
||||
<Show when={!isInventoryForm()}>
|
||||
<FormField label="Tag filter" helpText="Comma-separated tags">
|
||||
<input
|
||||
aria-label="Tag filter"
|
||||
type="text"
|
||||
class={formControl}
|
||||
value={scheduleForm().tagFilter}
|
||||
onInput={(e) => updateScheduleForm({ tagFilter: e.currentTarget.value })}
|
||||
placeholder="production, customer-facing"
|
||||
/>
|
||||
</FormField>
|
||||
</Show>
|
||||
<FormSelect
|
||||
label="Delivery"
|
||||
value={scheduleForm().deliveryMethod}
|
||||
|
||||
@@ -282,8 +282,8 @@ export function ReportingPanel() {
|
||||
<div class="space-y-1">
|
||||
<h4 class="text-base font-semibold text-base-content">Scheduled reports</h4>
|
||||
<p class="text-sm text-muted">
|
||||
Send recurring client performance reports using the same resource scope and
|
||||
branding as generated reports.
|
||||
Schedule performance reports or a CSV of the current VM inventory, delivered by
|
||||
email or saved on this server.
|
||||
<Show when={patrolDigestAvailable()}>
|
||||
{' '}
|
||||
A Patrol weekly summary can also be emailed: what Patrol checked, found,
|
||||
|
||||
@@ -183,7 +183,7 @@ describe('ReportingPanel', () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it('keeps the schedule form free of the report type choice while Patrol cannot run', async () => {
|
||||
it('offers performance and inventory schedules while Patrol cannot run', async () => {
|
||||
useReportingPanelStateMock.mockReturnValue(
|
||||
buildState({ scheduleFormOpen: () => true, patrolDigestAvailable: () => false }),
|
||||
);
|
||||
@@ -191,7 +191,11 @@ describe('ReportingPanel', () => {
|
||||
render(() => <ReportingPanel />);
|
||||
await screen.findByLabelText('Schedule name', { selector: 'input' });
|
||||
|
||||
expect(screen.queryByLabelText('Report type')).not.toBeInTheDocument();
|
||||
const select = screen.getByLabelText('Report type') as HTMLSelectElement;
|
||||
expect(Array.from(select.options).map((option) => option.textContent)).toEqual([
|
||||
'Performance report',
|
||||
'VM inventory CSV',
|
||||
]);
|
||||
expect(screen.getByLabelText('Schedule name', { selector: 'input' })).toHaveFocus();
|
||||
expect(screen.queryByText(/Patrol weekly summary can also be emailed/)).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Cadence')).toBeInTheDocument();
|
||||
@@ -217,6 +221,7 @@ describe('ReportingPanel', () => {
|
||||
expect(select.value).toBe('resources');
|
||||
expect(Array.from(select.options).map((option) => option.textContent)).toEqual([
|
||||
'Performance report',
|
||||
'VM inventory CSV',
|
||||
'Patrol weekly summary',
|
||||
]);
|
||||
expect(
|
||||
@@ -228,6 +233,26 @@ describe('ReportingPanel', () => {
|
||||
expect(setScheduleKind).toHaveBeenCalledWith('patrol_digest');
|
||||
});
|
||||
|
||||
it('keeps inventory CSV scope clear and both cadences available without performance controls', async () => {
|
||||
const state = buildState();
|
||||
useReportingPanelStateMock.mockReturnValue(
|
||||
buildState({
|
||||
scheduleFormOpen: () => true,
|
||||
scheduleForm: () => ({ ...state.scheduleForm(), kind: 'vm_inventory', format: 'csv' }),
|
||||
}),
|
||||
);
|
||||
render(() => <ReportingPanel />);
|
||||
await screen.findByLabelText('Schedule name', { selector: 'input' });
|
||||
expect(screen.getByLabelText('Report type')).toHaveValue('vm_inventory');
|
||||
expect(screen.getByLabelText('Cadence')).toHaveValue('monthly');
|
||||
expect(screen.getByText(/including newly discovered VMs/)).toBeVisible();
|
||||
expect(screen.queryByLabelText('Format')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Tag filter')).not.toBeInTheDocument();
|
||||
expect(screen.getAllByText('Mock Resource Picker')).toHaveLength(1);
|
||||
expect(screen.getByLabelText('Delivery')).toBeVisible();
|
||||
expect(screen.getByRole('spinbutton', { name: 'Retention' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('reduces the form to weekday, time and recipients for a Patrol weekly summary', async () => {
|
||||
const base = buildState();
|
||||
useReportingPanelStateMock.mockReturnValue(
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
applyReportScheduleKind,
|
||||
buildReportSchedulePayload,
|
||||
DEFAULT_REPORT_SCHEDULE_FORM,
|
||||
normalizeReportSchedule,
|
||||
reportScheduleKindLabel,
|
||||
reportScheduleScopeLabel,
|
||||
scheduleToForm,
|
||||
} from '../reportingSchedulesModel';
|
||||
|
||||
describe('scheduled VM inventory CSV', () => {
|
||||
it('removes stale performance scope and PDF settings, preserving cadence and delivery', () => {
|
||||
const form = applyReportScheduleKind(
|
||||
{
|
||||
...DEFAULT_REPORT_SCHEDULE_FORM(),
|
||||
name: 'Inventory',
|
||||
format: 'pdf',
|
||||
tagFilter: 'client',
|
||||
cadenceType: 'weekly',
|
||||
deliveryMethod: 'disk',
|
||||
},
|
||||
'vm_inventory',
|
||||
);
|
||||
const payload = buildReportSchedulePayload(form, [
|
||||
{ id: 'old-vm', type: 'vm', name: 'Old VM' },
|
||||
]);
|
||||
expect(payload).toMatchObject({
|
||||
kind: 'vm_inventory',
|
||||
format: 'csv',
|
||||
cadence: { type: 'weekly' },
|
||||
scope: { resources: [], tags: [] },
|
||||
delivery: { method: 'disk', save_to_disk: true },
|
||||
});
|
||||
expect(reportScheduleScopeLabel(payload)).toBe('All VMs in this organization');
|
||||
expect(reportScheduleKindLabel(payload.kind)).toBe('VM inventory CSV');
|
||||
expect(scheduleToForm(normalizeReportSchedule(payload))).toMatchObject({
|
||||
kind: 'vm_inventory',
|
||||
format: 'csv',
|
||||
});
|
||||
});
|
||||
|
||||
it('restores a performance report after switching from inventory or Patrol', () => {
|
||||
const inventory = applyReportScheduleKind(DEFAULT_REPORT_SCHEDULE_FORM(), 'vm_inventory');
|
||||
expect(applyReportScheduleKind(inventory, 'resources')).toMatchObject({
|
||||
kind: 'resources',
|
||||
format: 'pdf',
|
||||
});
|
||||
const digest = applyReportScheduleKind(inventory, 'patrol_digest');
|
||||
expect(buildReportSchedulePayload(digest, [])).toMatchObject({
|
||||
kind: 'patrol_digest',
|
||||
format: 'email',
|
||||
delivery: { attach: false },
|
||||
});
|
||||
expect(applyReportScheduleKind(digest, 'vm_inventory')).toMatchObject({
|
||||
kind: 'vm_inventory',
|
||||
format: 'csv',
|
||||
attach: true,
|
||||
saveToDisk: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -572,6 +572,36 @@ describe('useReportingPanelState', () => {
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('saves an inventory CSV schedule without leaking old resources or tags', async () => {
|
||||
apiFetchMock.mockImplementation((url: string, init?: RequestInit) => {
|
||||
if (url === '/api/admin/reports/schedules' && init?.method === 'POST') {
|
||||
return Promise.resolve(jsonResponse({ ...JSON.parse(String(init.body)), id: 'inventory' }));
|
||||
}
|
||||
if (url === '/api/admin/reports/schedules')
|
||||
return Promise.resolve(jsonResponse(schedulesPayload));
|
||||
return Promise.resolve(jsonResponse(catalogPayload));
|
||||
});
|
||||
const { hookState, dispose } = mountHook();
|
||||
await settleMountLoads(hookState);
|
||||
hookState.startCreateSchedule();
|
||||
hookState.updateScheduleForm({ name: 'Inventory', tagFilter: 'old-client' });
|
||||
hookState.setScheduleResources([{ id: 'vm-old', name: 'Old VM', type: 'vm' }]);
|
||||
hookState.setScheduleKind('vm_inventory');
|
||||
expect(hookState.scheduleResources()).toEqual([]);
|
||||
await hookState.saveReportSchedule();
|
||||
expect(showWarningMock).not.toHaveBeenCalled();
|
||||
const post = apiFetchMock.mock.calls.find(
|
||||
([url, init]) => url === '/api/admin/reports/schedules' && init?.method === 'POST',
|
||||
);
|
||||
expect(post).toBeDefined();
|
||||
expect(JSON.parse(String(post![1].body))).toMatchObject({
|
||||
kind: 'vm_inventory',
|
||||
format: 'csv',
|
||||
scope: { resources: [], tags: [] },
|
||||
});
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('still requires a scope for a performance report schedule', async () => {
|
||||
const { hookState, dispose } = mountHook();
|
||||
await settleMountLoads(hookState);
|
||||
|
||||
@@ -6,7 +6,7 @@ export type ReportScheduleRunStatus = '' | 'ok' | 'failed' | 'running' | 'retryi
|
||||
export type ReportScheduleDeliveryMethod = 'email' | 'disk';
|
||||
// 'resources' is the multi-resource performance report; 'patrol_digest' emails
|
||||
// the last seven days of Patrol work and has no scope, format, or disk output.
|
||||
export type ReportScheduleKind = 'resources' | 'patrol_digest';
|
||||
export type ReportScheduleKind = 'resources' | 'patrol_digest' | 'vm_inventory';
|
||||
// Digest schedules carry the server-assigned 'email' format: the email body is
|
||||
// the report, so there is no PDF or CSV artifact.
|
||||
export type ReportScheduleFormat = ReportingFormat | 'email';
|
||||
@@ -122,7 +122,7 @@ export function parseReportSchedulesResponse(value: unknown): ReportSchedule[] {
|
||||
}
|
||||
|
||||
export function normalizeReportScheduleKind(kind: unknown): ReportScheduleKind {
|
||||
return kind === 'patrol_digest' ? 'patrol_digest' : 'resources';
|
||||
return kind === 'patrol_digest' || kind === 'vm_inventory' ? kind : 'resources';
|
||||
}
|
||||
|
||||
export function isPatrolDigestSchedule(schedule: Pick<ReportSchedule, 'kind'>): boolean {
|
||||
@@ -211,12 +211,20 @@ export function applyReportScheduleKind(
|
||||
return {
|
||||
...form,
|
||||
kind,
|
||||
format:
|
||||
kind === 'vm_inventory'
|
||||
? 'csv'
|
||||
: form.kind === 'vm_inventory'
|
||||
? defaults.format
|
||||
: form.format,
|
||||
tagFilter: kind === 'vm_inventory' ? '' : form.tagFilter,
|
||||
attach: defaults.attach,
|
||||
saveToDisk: defaults.saveToDisk,
|
||||
};
|
||||
}
|
||||
|
||||
export function reportScheduleKindLabel(kind: unknown): string {
|
||||
if (kind === 'vm_inventory') return 'VM inventory CSV';
|
||||
return normalizeReportScheduleKind(kind) === 'patrol_digest'
|
||||
? 'Patrol weekly summary'
|
||||
: 'Performance report';
|
||||
@@ -252,7 +260,7 @@ export function buildReportSchedulePayload(
|
||||
return {
|
||||
id: form.id,
|
||||
name: form.name.trim(),
|
||||
kind: 'resources',
|
||||
kind: form.kind,
|
||||
enabled: form.enabled,
|
||||
cadence: {
|
||||
type: form.cadenceType,
|
||||
@@ -262,14 +270,14 @@ export function buildReportSchedulePayload(
|
||||
timezone: form.timezone.trim() || 'UTC',
|
||||
},
|
||||
scope: {
|
||||
resources: resources.map((resource) => ({
|
||||
resources: (form.kind === 'vm_inventory' ? [] : resources).map((resource) => ({
|
||||
resourceType: resource.type,
|
||||
resourceId: resource.id,
|
||||
name: resource.name,
|
||||
})),
|
||||
tags: parseCommaList(form.tagFilter),
|
||||
tags: form.kind === 'vm_inventory' ? [] : parseCommaList(form.tagFilter),
|
||||
},
|
||||
format: form.format,
|
||||
format: form.kind === 'vm_inventory' ? 'csv' : form.format,
|
||||
delivery: {
|
||||
method: form.deliveryMethod,
|
||||
to: parseCommaList(form.recipients),
|
||||
@@ -304,6 +312,7 @@ export function reportScheduleCadenceLabel(schedule: ReportSchedule): string {
|
||||
|
||||
export function reportScheduleScopeLabel(schedule: ReportSchedule): string {
|
||||
if (isPatrolDigestSchedule(schedule)) return 'Patrol activity, last 7 days';
|
||||
if (schedule.kind === 'vm_inventory') return 'All VMs in this organization';
|
||||
const resources = schedule.scope.resources?.length ?? 0;
|
||||
const tags = schedule.scope.tags?.length ?? 0;
|
||||
const parts = [];
|
||||
|
||||
@@ -298,7 +298,7 @@ export const useReportingPanelState = () => {
|
||||
|
||||
const setScheduleKind = (kind: ReportScheduleKind) => {
|
||||
setScheduleForm((current) => applyReportScheduleKind(current, kind));
|
||||
if (kind === 'patrol_digest') {
|
||||
if (kind !== 'resources') {
|
||||
setScheduleResources([]);
|
||||
}
|
||||
};
|
||||
@@ -331,7 +331,7 @@ export const useReportingPanelState = () => {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
payload.kind !== 'patrol_digest' &&
|
||||
payload.kind === 'resources' &&
|
||||
(payload.scope.resources?.length ?? 0) === 0 &&
|
||||
(payload.scope.tags?.length ?? 0) === 0
|
||||
) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
|
||||
@@ -98,7 +99,7 @@ func normalizeReportResourceType(raw string) (string, error) {
|
||||
|
||||
// ReportingHandlers handles reporting-related requests
|
||||
type ReportingHandlers struct {
|
||||
mtMonitor *monitoring.MultiTenantMonitor
|
||||
mtMonitor atomic.Pointer[monitoring.MultiTenantMonitor]
|
||||
recoveryManager *recoverymanager.Manager
|
||||
narratorResolver func(ctx context.Context) (reporting.Narrator, reporting.FleetNarrator, reporting.FindingsProvider)
|
||||
existingFindingsResolver func(ctx context.Context) reporting.FindingsProvider
|
||||
@@ -411,19 +412,34 @@ func (s *reportingEnrichmentSnapshot) normalizeCollections() {
|
||||
|
||||
// NewReportingHandlers creates a new ReportingHandlers
|
||||
func NewReportingHandlers(mtMonitor *monitoring.MultiTenantMonitor, recoveryManager *recoverymanager.Manager) *ReportingHandlers {
|
||||
return &ReportingHandlers{
|
||||
mtMonitor: mtMonitor,
|
||||
recoveryManager: recoveryManager,
|
||||
h := &ReportingHandlers{recoveryManager: recoveryManager}
|
||||
h.SetMultiTenantMonitor(mtMonitor)
|
||||
return h
|
||||
}
|
||||
|
||||
// SetMultiTenantMonitor replaces the runtime dependency after a configuration
|
||||
// reload. Scheduled and on-demand reads may overlap the replacement.
|
||||
func (h *ReportingHandlers) SetMultiTenantMonitor(manager *monitoring.MultiTenantMonitor) {
|
||||
if h != nil {
|
||||
h.mtMonitor.Store(manager)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ReportingHandlers) reportMonitorManager() *monitoring.MultiTenantMonitor {
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
return h.mtMonitor.Load()
|
||||
}
|
||||
|
||||
func (h *ReportingHandlers) getReportingEnrichmentSnapshot(ctx context.Context, orgID string) (reportingEnrichmentSnapshot, bool) {
|
||||
_ = ctx
|
||||
if h == nil || h.mtMonitor == nil {
|
||||
manager := h.reportMonitorManager()
|
||||
if manager == nil {
|
||||
return emptyReportingEnrichmentSnapshot(), false
|
||||
}
|
||||
|
||||
monitor, err := h.mtMonitor.GetMonitor(orgID)
|
||||
monitor, err := manager.GetMonitor(orgID)
|
||||
if err != nil || monitor == nil {
|
||||
return emptyReportingEnrichmentSnapshot(), false
|
||||
}
|
||||
@@ -864,8 +880,8 @@ func (h *ReportingHandlers) resolveReportSubject(orgID string, req *reporting.Me
|
||||
// it explicitly, so ask the tenant monitor first and fall back to
|
||||
// the struct field.
|
||||
var target *unifiedresources.MetricsTarget
|
||||
if h != nil && h.mtMonitor != nil {
|
||||
if monitor, err := h.mtMonitor.GetMonitor(orgID); err == nil && monitor != nil {
|
||||
if manager := h.reportMonitorManager(); manager != nil {
|
||||
if monitor, err := manager.GetMonitor(orgID); err == nil && monitor != nil {
|
||||
target = monitor.MetricsTargetForResource(req.ResourceID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ func TestReportScheduleDeliveryRetriesSurviveRestartAndKeepWindow(t *testing.T)
|
||||
window := engine.lastMulti.End
|
||||
require.NotContains(t, first.LastError, "provider-verdict")
|
||||
// A fresh handler reads only the persisted state, with no in-memory retry job.
|
||||
restarted := NewReportingHandlers(h.mtMonitor, nil)
|
||||
restarted := NewReportingHandlers(h.reportMonitorManager(), nil)
|
||||
restarted.runDueReportSchedulesForOrg(ctx, "default", now.Add(30*time.Second))
|
||||
require.EqualValues(t, 1, attempts.Load())
|
||||
restarted.runDueReportSchedulesForOrg(ctx, "default", first.RetryAt.Add(time.Second))
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func inventoryScheduleFixture(t *testing.T) (*ReportingHandlers, *config.ConfigPersistence, context.Context, config.ReportSchedule, func(int)) {
|
||||
t.Helper()
|
||||
// Build the real inventory and persistence without starting poll goroutines:
|
||||
// fixture setup must not replace state underneath a running monitor.
|
||||
state := models.NewState()
|
||||
monitor := newReportingMonitorForTest(t, state, nil)
|
||||
p := config.NewConfigPersistence(t.TempDir())
|
||||
setUnexportedField(t, monitor, "configPersist", p)
|
||||
h := NewReportingHandlers(newReportingMTMForTest(t, monitor), nil)
|
||||
adapter := unifiedresources.NewMonitorAdapter(unifiedresources.NewRegistry(nil))
|
||||
setVMCount := func(count int) {
|
||||
vms := make([]models.VM, count)
|
||||
for i := range vms {
|
||||
vms[i] = models.VM{
|
||||
ID: fmt.Sprintf("fixture-node-%d", 100+i), VMID: 100 + i,
|
||||
Name: fmt.Sprintf("inventory-vm-%03d", i), Instance: "fixture", Node: "node", Pool: "customer",
|
||||
Type: "qemu", Status: "running", CPUs: 4,
|
||||
Memory: models.Memory{Total: 4 << 30}, Disk: models.Disk{Total: 100 << 30, Used: 20 << 30},
|
||||
}
|
||||
}
|
||||
state.UpdateVMs(vms)
|
||||
monitor.SetResourceStore(adapter)
|
||||
}
|
||||
setVMCount(61)
|
||||
s := validReportSchedulePayload()
|
||||
s.ID, s.Kind, s.Format = "delivery", config.ReportScheduleKindVMInventory, "csv"
|
||||
s.Scope = config.ReportScheduleScope{}
|
||||
s.Cadence = config.ReportScheduleCadence{Type: "weekly", Weekday: "monday", Time: "09:00", Timezone: "UTC"}
|
||||
return h, p, context.WithValue(context.Background(), OrgIDContextKey, "default"), s, setVMCount
|
||||
}
|
||||
|
||||
func TestVMInventoryScheduleUsesExistingExportWithoutHistoricalLimit(t *testing.T) {
|
||||
h, p, ctx, s, setVMCount := inventoryScheduleFixture(t)
|
||||
now := time.Date(2026, 10, 19, 9, 0, 1, 0, time.UTC)
|
||||
prepared, err := h.prepareReportSchedule(ctx, s, now, false)
|
||||
require.NoError(t, err)
|
||||
result, finished := h.runReportSchedule(ctx, p, prepared, now, true, "")
|
||||
require.Equal(t, "ok", finished.LastRunStatus, finished.LastError)
|
||||
require.Equal(t, "saved", finished.LastDeliveryStatus)
|
||||
data, err := os.ReadFile(result.path)
|
||||
require.NoError(t, err)
|
||||
download := httptest.NewRecorder()
|
||||
h.HandleExportVMInventory(download, httptest.NewRequest(http.MethodGet, "/api/admin/reports/inventory/vms/export", nil).WithContext(ctx))
|
||||
require.Equal(t, http.StatusOK, download.Code)
|
||||
require.Equal(t, download.Body.Bytes(), data)
|
||||
records, err := csv.NewReader(strings.NewReader(string(data))).ReadAll()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, records, 62) // Header plus every VM, including beyond the performance-report cap.
|
||||
require.Contains(t, string(data), "customer")
|
||||
require.Contains(t, string(data), "inventory-vm-060")
|
||||
require.Contains(t, string(data), "4294967296")
|
||||
setVMCount(62)
|
||||
result, finished = h.runReportSchedule(ctx, p, prepared, now.AddDate(0, 0, 7), false, "")
|
||||
require.Equal(t, "ok", finished.LastRunStatus, finished.LastError)
|
||||
data, err = os.ReadFile(result.path)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(data), "inventory-vm-061")
|
||||
}
|
||||
|
||||
func TestVMInventoryScheduleRejectsMisleadingFormatAndScope(t *testing.T) {
|
||||
h, _, ctx, s, _ := inventoryScheduleFixture(t)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
change func(*config.ReportSchedule)
|
||||
}{
|
||||
{"PDF", func(s *config.ReportSchedule) { s.Format = "pdf" }},
|
||||
{"resources", func(s *config.ReportSchedule) {
|
||||
s.Scope.Resources = []config.ReportScheduleResource{{ResourceType: "vm", ResourceID: "vm-1"}}
|
||||
}},
|
||||
{"tags", func(s *config.ReportSchedule) { s.Scope.Tags = []string{"client"} }},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
incoming := s
|
||||
tc.change(&incoming)
|
||||
_, err := h.prepareReportSchedule(ctx, incoming, time.Now(), false)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
s.Format = ""
|
||||
prepared, err := h.prepareReportSchedule(ctx, s, time.Now(), false)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "csv", prepared.Format)
|
||||
s.Delivery.Method = "email"
|
||||
_, err = h.prepareReportSchedule(ctx, s, time.Now(), false)
|
||||
require.ErrorContains(t, err, "Enable email notifications")
|
||||
s.Enabled = false
|
||||
_, err = h.prepareReportSchedule(ctx, s, time.Now(), false)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestVMInventoryScheduleRetriesThroughPersistedDeliveryState(t *testing.T) {
|
||||
h, p, ctx, s, _ := inventoryScheduleFixture(t)
|
||||
cfg, attempts := reportSMTP(t, []int{451, 250}, nil)
|
||||
require.NoError(t, p.SaveEmailConfig(cfg))
|
||||
s.Delivery = config.ReportScheduleDelivery{Method: "email", Attach: true, SaveToDisk: true}
|
||||
require.NoError(t, p.SaveReportScheduleStore(config.ReportScheduleStore{Schedules: []config.ReportSchedule{s}}))
|
||||
now := time.Date(2026, 10, 19, 9, 0, 1, 0, time.UTC)
|
||||
h.runDueReportSchedulesForOrg(ctx, "default", now)
|
||||
first := storedReport(t, p)
|
||||
require.Equal(t, "retrying", first.LastRunStatus, first.LastError)
|
||||
require.NotNil(t, first.RetryAt)
|
||||
restarted := NewReportingHandlers(h.reportMonitorManager(), nil)
|
||||
restarted.runDueReportSchedulesForOrg(ctx, "default", first.RetryAt.Add(time.Second))
|
||||
finished := storedReport(t, p)
|
||||
require.Equal(t, "ok", finished.LastRunStatus, finished.LastError)
|
||||
require.Equal(t, "vm_inventory", finished.Kind)
|
||||
require.Equal(t, "sent_with_attachment", finished.LastDeliveryStatus)
|
||||
require.Equal(t, 2, finished.RunAttempts)
|
||||
require.Nil(t, finished.RetryAt)
|
||||
require.EqualValues(t, 2, attempts.Load())
|
||||
restarted.runDueReportSchedulesForOrg(ctx, "default", now.Add(time.Hour))
|
||||
require.EqualValues(t, 2, attempts.Load())
|
||||
}
|
||||
|
||||
func TestVMInventoryScheduleUnavailableOrganizationCannotExportDefault(t *testing.T) {
|
||||
h, p, ctx, s, _ := inventoryScheduleFixture(t)
|
||||
_, failed := h.runReportSchedule(context.WithValue(ctx, OrgIDContextKey, "missing-organization"), p, s, time.Now(), true, "")
|
||||
require.Equal(t, "failed", failed.LastRunStatus)
|
||||
require.Contains(t, failed.LastError, "VM inventory is unavailable")
|
||||
require.Empty(t, failed.LastReportPath)
|
||||
h.SetCommercialLicenseResolver(func(context.Context) *licenseService { return newLicenseService() })
|
||||
_, failed = h.runReportSchedule(ctx, p, s, time.Now(), true, "")
|
||||
require.Equal(t, "failed", failed.LastRunStatus)
|
||||
require.Contains(t, failed.LastError, "entitlement required")
|
||||
require.Empty(t, failed.LastReportPath)
|
||||
}
|
||||
@@ -294,14 +294,15 @@ func (h *ReportingHandlers) reportSchedulePersistenceAndStore(ctx context.Contex
|
||||
}
|
||||
|
||||
func (h *ReportingHandlers) reportSchedulePersistence(ctx context.Context) (*config.ConfigPersistence, error) {
|
||||
if h == nil || h.mtMonitor == nil {
|
||||
manager := h.reportMonitorManager()
|
||||
if manager == nil {
|
||||
return nil, fmt.Errorf("multi-tenant monitor is not configured")
|
||||
}
|
||||
orgID := GetOrgID(ctx)
|
||||
if strings.TrimSpace(orgID) == "" {
|
||||
orgID = "default"
|
||||
}
|
||||
monitor, err := h.mtMonitor.GetMonitor(orgID)
|
||||
monitor, err := manager.GetMonitor(orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -433,6 +434,16 @@ func (h *ReportingHandlers) prepareReportSchedule(ctx context.Context, schedule
|
||||
}
|
||||
switch schedule.Kind {
|
||||
case config.ReportScheduleKindResources:
|
||||
case config.ReportScheduleKindVMInventory:
|
||||
if schedule.Format == "" {
|
||||
schedule.Format = string(reporting.DescribeVMInventoryExport().Format)
|
||||
}
|
||||
if !reporting.DescribeVMInventoryExport().SupportsFormat(reporting.ReportFormat(schedule.Format)) {
|
||||
return schedule, reportScheduleValidationError{code: "invalid_format", message: "VM inventory schedules require CSV format"}
|
||||
}
|
||||
if len(schedule.Scope.Resources) > 0 || len(schedule.Scope.Tags) > 0 {
|
||||
return schedule, reportScheduleValidationError{code: "invalid_scope", message: "VM inventory includes all VMs in this organization. Resource and tag filters are not supported."}
|
||||
}
|
||||
case config.ReportScheduleKindPatrolDigest:
|
||||
prepared, err := preparePatrolDigestSchedule(schedule, now)
|
||||
if err != nil {
|
||||
@@ -440,7 +451,7 @@ func (h *ReportingHandlers) prepareReportSchedule(ctx context.Context, schedule
|
||||
}
|
||||
return prepared, h.validateEnabledReportDelivery(ctx, prepared)
|
||||
default:
|
||||
return schedule, reportScheduleValidationError{code: "invalid_kind", message: "Schedule kind must be resources or patrol_digest"}
|
||||
return schedule, reportScheduleValidationError{code: "invalid_kind", message: "Schedule kind must be resources, vm_inventory or patrol_digest"}
|
||||
}
|
||||
if schedule.Format == "" {
|
||||
schedule.Format = config.ReportScheduleFormatPDF
|
||||
@@ -463,8 +474,10 @@ func (h *ReportingHandlers) prepareReportSchedule(ctx context.Context, schedule
|
||||
if err := validateReportScheduleCadence(schedule.Cadence); err != nil {
|
||||
return schedule, err
|
||||
}
|
||||
if err := h.validateReportScheduleScope(ctx, schedule.Scope); err != nil {
|
||||
return schedule, err
|
||||
if schedule.Kind == config.ReportScheduleKindResources {
|
||||
if err := h.validateReportScheduleScope(ctx, schedule.Scope); err != nil {
|
||||
return schedule, err
|
||||
}
|
||||
}
|
||||
next, err := nextReportScheduleRunAt(schedule, now)
|
||||
if err != nil {
|
||||
@@ -633,27 +646,7 @@ func (h *ReportingHandlers) runReportSchedule(ctx context.Context, persistence *
|
||||
}
|
||||
}
|
||||
|
||||
orgID := GetOrgID(ctx)
|
||||
resources, err := h.resolveReportScheduleResources(ctx, orgID, schedule.Scope)
|
||||
if err != nil {
|
||||
return result, markReportScheduleFailed(schedule, now, err)
|
||||
}
|
||||
windowEnd := now
|
||||
if schedule.RunWindowEnd != nil {
|
||||
windowEnd = *schedule.RunWindowEnd
|
||||
}
|
||||
start, end, err := reportScheduleWindow(schedule, windowEnd, manual)
|
||||
if err != nil {
|
||||
return result, markReportScheduleFailed(schedule, now, err)
|
||||
}
|
||||
|
||||
report, err := h.generateMultiReportFromBody(ctx, multiReportRequestBody{
|
||||
Resources: resources,
|
||||
Format: schedule.Format,
|
||||
Start: start.UTC().Format(time.RFC3339),
|
||||
End: end.UTC().Format(time.RFC3339),
|
||||
Title: schedule.Name,
|
||||
}, now)
|
||||
report, err := h.generateScheduledFileReport(ctx, schedule, now, manual)
|
||||
if err != nil {
|
||||
return result, markReportScheduleFailed(schedule, now, err)
|
||||
}
|
||||
@@ -682,6 +675,33 @@ func (h *ReportingHandlers) runReportSchedule(ctx context.Context, persistence *
|
||||
return result, schedule
|
||||
}
|
||||
|
||||
func (h *ReportingHandlers) generateScheduledFileReport(ctx context.Context, schedule config.ReportSchedule, now time.Time, manual bool) (generatedMultiReport, error) {
|
||||
orgID := GetOrgID(ctx)
|
||||
if schedule.Kind == config.ReportScheduleKindVMInventory {
|
||||
snapshot, ok := h.getReportingEnrichmentSnapshot(ctx, orgID)
|
||||
if !ok {
|
||||
return generatedMultiReport{}, fmt.Errorf("VM inventory is unavailable. Check this organization's infrastructure connections before running again.")
|
||||
}
|
||||
return generateVMInventoryReport(snapshot, now)
|
||||
}
|
||||
resources, err := h.resolveReportScheduleResources(ctx, orgID, schedule.Scope)
|
||||
if err != nil {
|
||||
return generatedMultiReport{}, err
|
||||
}
|
||||
windowEnd := now
|
||||
if schedule.RunWindowEnd != nil {
|
||||
windowEnd = *schedule.RunWindowEnd
|
||||
}
|
||||
start, end, err := reportScheduleWindow(schedule, windowEnd, manual)
|
||||
if err != nil {
|
||||
return generatedMultiReport{}, err
|
||||
}
|
||||
return h.generateMultiReportFromBody(ctx, multiReportRequestBody{
|
||||
Resources: resources, Format: schedule.Format,
|
||||
Start: start.UTC().Format(time.RFC3339), End: end.UTC().Format(time.RFC3339), Title: schedule.Name,
|
||||
}, now)
|
||||
}
|
||||
|
||||
func markReportScheduleFailed(schedule config.ReportSchedule, now time.Time, err error) config.ReportSchedule {
|
||||
schedule.LastRunStatus = config.ReportScheduleLastRunFailed
|
||||
schedule.LastError = strings.TrimSpace(err.Error())
|
||||
@@ -1043,10 +1063,11 @@ func (h *ReportingHandlers) RunReportScheduleScheduler(ctx context.Context) {
|
||||
}
|
||||
|
||||
func (h *ReportingHandlers) runDueReportSchedules(ctx context.Context, now time.Time) {
|
||||
if h == nil || h.mtMonitor == nil {
|
||||
manager := h.reportMonitorManager()
|
||||
if manager == nil {
|
||||
return
|
||||
}
|
||||
orgIDs, err := h.mtMonitor.ListOrganizationIDs()
|
||||
orgIDs, err := manager.ListOrganizationIDs()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("report schedules: list organizations")
|
||||
return
|
||||
@@ -1059,10 +1080,11 @@ func (h *ReportingHandlers) runDueReportSchedules(ctx context.Context, now time.
|
||||
}
|
||||
|
||||
func (h *ReportingHandlers) syncCommercialHistoryRetention(ctx context.Context, orgID string, now time.Time) {
|
||||
if h == nil || h.mtMonitor == nil || h.commercialLicenseResolver == nil {
|
||||
manager := h.reportMonitorManager()
|
||||
if manager == nil || h.commercialLicenseResolver == nil {
|
||||
return
|
||||
}
|
||||
monitor, err := h.mtMonitor.GetMonitor(orgID)
|
||||
monitor, err := manager.GetMonitor(orgID)
|
||||
if err != nil || monitor == nil || monitor.GetMetricsStore() == nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -139,7 +139,8 @@ func computeReportAvailability(changes []unifiedresources.ResourceChange, curren
|
||||
// subject. The change timeline is keyed by the canonical unified resource ID
|
||||
// (not the metrics-target ID), so this runs against req.ResourceID.
|
||||
func (h *ReportingHandlers) resolveReportAvailability(orgID string, req *reporting.MetricReportRequest, snapshot reportingEnrichmentSnapshot, start, end time.Time) {
|
||||
if h == nil || req == nil || h.mtMonitor == nil {
|
||||
manager := h.reportMonitorManager()
|
||||
if manager == nil || req == nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -155,7 +156,7 @@ func (h *ReportingHandlers) resolveReportAvailability(orgID string, req *reporti
|
||||
return
|
||||
}
|
||||
|
||||
monitor, err := h.mtMonitor.GetMonitor(orgID)
|
||||
monitor, err := manager.GetMonitor(orgID)
|
||||
if err != nil || monitor == nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -34,19 +34,29 @@ func (h *ReportingHandlers) HandleExportVMInventory(w http.ResponseWriter, r *ht
|
||||
}
|
||||
|
||||
generatedAt := time.Now().UTC()
|
||||
data, err := reporting.GenerateVMInventoryCSV(reporting.VMInventoryData{
|
||||
GeneratedAt: generatedAt,
|
||||
Rows: buildVMInventoryRows(snapshot.Resources),
|
||||
})
|
||||
report, err := generateVMInventoryReport(snapshot, generatedAt)
|
||||
if err != nil {
|
||||
writeErrorResponse(w, http.StatusInternalServerError, "generation_failed", "Failed to generate VM inventory export", nil)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
filename := definition.AttachmentFilename(generatedAt)
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||
_, _ = w.Write(data)
|
||||
w.Header().Set("Content-Type", report.ContentType)
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", report.Filename))
|
||||
_, _ = w.Write(report.Data)
|
||||
}
|
||||
|
||||
// Both downloads and schedules use the same current-state projection and CSV
|
||||
// contract. Inventory does not use historical metric windows or resource caps.
|
||||
func generateVMInventoryReport(snapshot reportingEnrichmentSnapshot, generatedAt time.Time) (generatedMultiReport, error) {
|
||||
definition := reporting.DescribeVMInventoryExport()
|
||||
data, err := reporting.GenerateVMInventoryCSV(reporting.VMInventoryData{
|
||||
GeneratedAt: generatedAt,
|
||||
Rows: buildVMInventoryRows(snapshot.Resources),
|
||||
})
|
||||
return generatedMultiReport{
|
||||
Data: data, Format: definition.Format, ContentType: "text/csv; charset=utf-8",
|
||||
Filename: definition.AttachmentFilename(generatedAt),
|
||||
}, err
|
||||
}
|
||||
|
||||
func buildVMInventoryRows(resources []unifiedresources.Resource) []reporting.VMInventoryRow {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestReportingMonitorReloadUsesReplacementForInventoryAndSchedules(t *testing.T) {
|
||||
original, originalPersistence, ctx, _, _ := inventoryScheduleFixture(t)
|
||||
replacement, replacementPersistence, _, _, setVMCount := inventoryScheduleFixture(t)
|
||||
setVMCount(62)
|
||||
router := &Router{reportingHandlers: original}
|
||||
router.SetMultiTenantMonitor(replacement.reportMonitorManager())
|
||||
|
||||
snapshot, ok := original.getReportingEnrichmentSnapshot(ctx, "default")
|
||||
require.True(t, ok)
|
||||
require.Len(t, buildVMInventoryRows(snapshot.Resources), 62)
|
||||
persistence, err := original.reportSchedulePersistence(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Same(t, replacementPersistence, persistence)
|
||||
require.NotSame(t, originalPersistence, persistence)
|
||||
|
||||
// Clearing the runtime never falls back to the stopped manager.
|
||||
router.SetMultiTenantMonitor(nil)
|
||||
_, ok = original.getReportingEnrichmentSnapshot(ctx, "default")
|
||||
require.False(t, ok)
|
||||
_, err = original.reportSchedulePersistence(ctx)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestReportingMonitorReloadCanOverlapReportingReads(t *testing.T) {
|
||||
h, _, _, _, _ := inventoryScheduleFixture(t)
|
||||
manager := h.reportMonitorManager()
|
||||
var readers sync.WaitGroup
|
||||
readers.Add(1)
|
||||
go func() {
|
||||
defer readers.Done()
|
||||
for range 100 {
|
||||
h.getReportingEnrichmentSnapshot(context.Background(), "default")
|
||||
}
|
||||
}()
|
||||
for range 100 {
|
||||
h.SetMultiTenantMonitor(nil)
|
||||
h.SetMultiTenantMonitor(manager)
|
||||
}
|
||||
readers.Wait()
|
||||
}
|
||||
@@ -219,11 +219,16 @@ func TestReportingEnrichmentSnapshotNormalizesEmptyCollections(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type reportingFixtureRecords []unifiedresources.IngestRecord
|
||||
|
||||
func (records reportingFixtureRecords) SupplementalRecords(*monitoring.Monitor, string) []unifiedresources.IngestRecord {
|
||||
return records
|
||||
}
|
||||
|
||||
func newReportingMonitorForTest(t *testing.T, state *models.State, resources []unifiedresources.Resource) *monitoring.Monitor {
|
||||
t.Helper()
|
||||
|
||||
registry := unifiedresources.NewRegistry(nil)
|
||||
registry.IngestSnapshot(state.GetSnapshot())
|
||||
records := make([]unifiedresources.IngestRecord, 0, len(resources))
|
||||
for i, resource := range resources {
|
||||
sourceID := resource.ID
|
||||
@@ -236,11 +241,14 @@ func newReportingMonitorForTest(t *testing.T, state *models.State, resources []u
|
||||
Identity: resource.Identity,
|
||||
})
|
||||
}
|
||||
registry.IngestRecords(unifiedresources.SourceTrueNAS, records)
|
||||
|
||||
monitor := &monitoring.Monitor{}
|
||||
monitor.SetResourceStore(unifiedresources.NewMonitorAdapter(registry))
|
||||
setUnexportedField(t, monitor, "state", state)
|
||||
// Use the real provider boundary so read-driven publications retain these
|
||||
// supplemental fixtures as well as live snapshot resources.
|
||||
monitor.SetResourceStoreAndProviders(unifiedresources.NewMonitorAdapter(registry),
|
||||
map[unifiedresources.DataSource]monitoring.MonitorSupplementalRecordsProvider{
|
||||
unifiedresources.SourceTrueNAS: reportingFixtureRecords(records),
|
||||
})
|
||||
return monitor
|
||||
}
|
||||
|
||||
|
||||
@@ -124,6 +124,9 @@ func (p *MultiTenantStateProvider) ResourceHealthAlertsForTenant(orgID string) [
|
||||
// Used during reload.
|
||||
func (r *Router) SetMultiTenantMonitor(mtm *monitoring.MultiTenantMonitor) {
|
||||
r.mtMonitor = mtm
|
||||
if r.reportingHandlers != nil {
|
||||
r.reportingHandlers.SetMultiTenantMonitor(mtm)
|
||||
}
|
||||
if r.configHandlers != nil {
|
||||
r.configHandlers.SetMultiTenantMonitor(mtm)
|
||||
r.configHandlers.SetConfig(r.config)
|
||||
|
||||
@@ -40,3 +40,13 @@ func TestLegacyReportScheduleHasNoInventedDeliveryOutcome(t *testing.T) {
|
||||
require.Nil(t, store.Schedules[0].RetryAt)
|
||||
require.Zero(t, store.Schedules[0].RunAttempts)
|
||||
}
|
||||
|
||||
func TestVMInventoryScheduleKindSurvivesEncryptedReload(t *testing.T) {
|
||||
p := NewConfigPersistence(t.TempDir())
|
||||
s := ReportSchedule{ID: "inventory", Kind: ReportScheduleKindVMInventory, Format: "csv"}
|
||||
require.NoError(t, p.SaveReportScheduleStore(ReportScheduleStore{Schedules: []ReportSchedule{s}}))
|
||||
loaded, err := p.LoadReportScheduleStore()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ReportScheduleKindVMInventory, loaded.Schedules[0].Kind)
|
||||
require.Equal(t, "csv", loaded.Schedules[0].Format)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ const (
|
||||
// weekly rollup for the whole workspace.
|
||||
ReportScheduleKindResources = "resources"
|
||||
ReportScheduleKindPatrolDigest = "patrol_digest"
|
||||
ReportScheduleKindVMInventory = "vm_inventory"
|
||||
|
||||
ReportScheduleDeliveryEmail = "email"
|
||||
ReportScheduleDeliveryDisk = "disk"
|
||||
|
||||
@@ -109,10 +109,34 @@ func resourceStaleThresholdsForIntervals(
|
||||
// does, judges by the same thresholds, so its stale pass and presentation
|
||||
// agree with the monitor's views.
|
||||
func (m *Monitor) UnifiedResourceSnapshotWithStaleThresholds() ([]unifiedresources.Resource, time.Time, map[unifiedresources.DataSource]time.Duration) {
|
||||
m.refreshUnifiedResourceRead()
|
||||
view := m.currentUnifiedStateView()
|
||||
return m.applyPersistedMetadataToUnifiedResources(view.resources), view.freshness, view.staleThresholds
|
||||
}
|
||||
|
||||
// Headless readers need the same publication freshness as frontend state reads.
|
||||
// Check the store before copying state, preserving the cheap warm-read path.
|
||||
// The existing refresh boundary owns publication, provider and mode fencing.
|
||||
func (m *Monitor) refreshUnifiedResourceRead() {
|
||||
if m == nil || mock.IsMockEnabled() {
|
||||
return
|
||||
}
|
||||
m.mu.RLock()
|
||||
store := m.resourceStore
|
||||
m.mu.RUnlock()
|
||||
if _, ok := store.(readRefreshResourceStore); !ok {
|
||||
return
|
||||
}
|
||||
if timed, ok := store.(interface{ LastRebuiltAt() time.Time }); ok {
|
||||
last := timed.LastRebuiltAt()
|
||||
if !last.IsZero() && time.Since(last) < readPathRegistryFreshness {
|
||||
return
|
||||
}
|
||||
}
|
||||
state, scope := m.currentStateWithScope()
|
||||
m.updateResourceStoreForRead(state, scope)
|
||||
}
|
||||
|
||||
// unifiedResourceThresholdProjectionLister lists a read state with the stale
|
||||
// thresholds the listed registry generation was judged by (MonitorAdapter,
|
||||
// and any wrapper embedding one).
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package monitoring
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
)
|
||||
|
||||
// A stale store lets the tests exercise read admission without a wall-clock
|
||||
// freshness sleep. Registry publication itself remains the real adapter path.
|
||||
type staleInventoryReadStore struct {
|
||||
*unifiedresources.MonitorAdapter
|
||||
}
|
||||
|
||||
func (*staleInventoryReadStore) LastRebuiltAt() time.Time { return time.Now().Add(-time.Hour) }
|
||||
func (s *staleInventoryReadStore) TryReplaceRegistryForRead(snapshot models.StateSnapshot, _ time.Duration, supply func() map[unifiedresources.DataSource][]unifiedresources.IngestRecord) bool {
|
||||
return s.MonitorAdapter.TryReplaceRegistryForRead(snapshot, 0, supply)
|
||||
}
|
||||
|
||||
func TestUnifiedInventoryReadRefreshesWithoutFrontendStateRead(t *testing.T) {
|
||||
state := models.NewState()
|
||||
adapter := unifiedresources.NewMonitorAdapter(unifiedresources.NewRegistry(nil))
|
||||
adapter.PopulateFromSnapshot(models.StateSnapshot{})
|
||||
m := &Monitor{state: state, resourceStore: &staleInventoryReadStore{adapter}}
|
||||
state.UpdateVMs([]models.VM{{ID: "site-node-100", Instance: "site", Node: "node", VMID: 100, Name: "headless-vm", Type: "qemu", Status: "running"}})
|
||||
resources, _, _ := m.UnifiedResourceSnapshotWithStaleThresholds()
|
||||
if len(resources) != 1 || resources[0].Name != "headless-vm" || resources[0].Type != unifiedresources.ResourceTypeVM {
|
||||
t.Fatalf("unattended snapshot did not publish VM inventory: %#v", resources)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnifiedInventoryWarmReadDoesNotWaitForIngestPublication(t *testing.T) {
|
||||
adapter := unifiedresources.NewMonitorAdapter(unifiedresources.NewRegistry(nil))
|
||||
adapter.PopulateFromSnapshot(models.StateSnapshot{})
|
||||
m := &Monitor{state: models.NewState(), resourceStore: &staleInventoryReadStore{adapter}}
|
||||
m.resourcePublishMu.Lock()
|
||||
defer m.resourcePublishMu.Unlock()
|
||||
done := make(chan struct{})
|
||||
go func() { m.UnifiedResourceSnapshot(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("warm unattended read queued behind an ingest publication")
|
||||
}
|
||||
}
|
||||
@@ -1342,6 +1342,39 @@ class CanonicalCompletionGuardTest(unittest.TestCase):
|
||||
other = infer_impacted_subsystems(["internal/api/access_control_handlers.go"])["api-contracts"]
|
||||
self.assertNotIn(proof, other["verification_requirements"][0]["exact_files"])
|
||||
|
||||
def test_unattended_reporting_proofs_do_not_satisfy_unrelated_runtime_changes(self):
|
||||
cases = [
|
||||
(
|
||||
"api-contracts", "internal/api/metrics_reporting_handlers.go",
|
||||
"internal/api/report_schedule_inventory_test.go",
|
||||
"internal/api/access_control_handlers.go",
|
||||
),
|
||||
(
|
||||
"api-contracts", "internal/api/router_helpers.go",
|
||||
"internal/api/reporting_monitor_reload_test.go",
|
||||
"internal/api/access_control_handlers.go",
|
||||
),
|
||||
(
|
||||
"monitoring", "internal/monitoring/resource_stale_thresholds.go",
|
||||
"internal/monitoring/unified_inventory_read_refresh_test.go",
|
||||
"internal/monitoring/monitor.go",
|
||||
),
|
||||
]
|
||||
rules = {rule["id"]: rule for rule in load_subsystem_rules()}
|
||||
for subsystem, runtime, proof, unrelated in cases:
|
||||
with self.subTest(runtime=runtime):
|
||||
rule = rules[subsystem]
|
||||
requirement = build_verification_requirements(rule, [runtime])[0]
|
||||
self.assertEqual(
|
||||
staged_verification_files_for_requirement(rule, requirement, [proof]),
|
||||
[proof],
|
||||
)
|
||||
other = build_verification_requirements(rule, [unrelated])[0]
|
||||
self.assertEqual(
|
||||
staged_verification_files_for_requirement(rule, other, [proof]),
|
||||
[],
|
||||
)
|
||||
|
||||
def test_organization_rbac_backend_change_requires_organization_settings_and_api_contracts(self):
|
||||
required = infer_impacted_subsystems(["internal/api/access_control_handlers.go"])
|
||||
self.assertEqual(set(required), {"api-contracts", "organization-settings"})
|
||||
|
||||
Reference in New Issue
Block a user