diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index edb7b1cf7..02ba50d0c 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -22,6 +22,7 @@ Pulse uses a split-configuration model to ensure security and flexibility. | `ai_usage_history.json` | AI usage history | ๐Ÿ“ Standard | | `ai_chat_sessions.json` | Legacy AI chat sessions (UI sync) | ๐Ÿ“ Standard | | `license.enc` | Relay/Pro/legacy Pro+/Cloud license key | ๐Ÿ”’ **Encrypted** | +| `report_schedules.json` | Scheduled report definitions, recipients, and last-run metadata | ๐Ÿ”’ **Sensitive** (encrypted when data-dir encryption is enabled) | | `host_metadata.json` | Host notes, tags, and AI command overrides | ๐Ÿ“ Standard | | `docker_metadata.json` | Docker metadata cache | ๐Ÿ“ Standard | | `guest_metadata.json` | Guest notes and metadata | ๐Ÿ“ Standard | diff --git a/docs/MSP.md b/docs/MSP.md index 1d7596650..6745a6326 100644 --- a/docs/MSP.md +++ b/docs/MSP.md @@ -192,6 +192,11 @@ that client's resources: `POST /api/admin/reports/generate-multi` (up to 50 resources per report), returning PDF or CSV. In shared-process mode, scope with `X-Pulse-Org-ID` or an org-bound token. +- **Schedules**: `GET`/`POST /api/admin/reports/schedules`, + `PUT`/`DELETE /api/admin/reports/schedules/{id}`, and + `POST /api/admin/reports/schedules/{id}/run`. Schedules can target explicit + resources and/or comma-separated resource tags, choose weekly or monthly + cadence, and deliver PDF or CSV output by email or to disk. Report branding (logo + display name) supports a provider-wide default via environment (`PULSE_REPORT_PROVIDER_BRAND_DISPLAY_NAME`, @@ -202,9 +207,14 @@ per-client; in shared-process mode the settings override applies instance-wide, so all organizations share one brand (usually yours). Branding requires the `white_label` entitlement on the licence. -Pulse does not yet schedule recurring reports; generate monthly client reports -on demand from the UI, or call the report API from your own scheduler with an -org-bound token. +Scheduled reports are tenant-local. In provider-hosted MSP, each client +runtime stores its own schedules in `report_schedules.json`, writes generated +outputs under `reports/generated/`, and applies its own SMTP settings, +recipients, resource tags, branding, and entitlement checks. If email delivery +is selected before SMTP is configured, Pulse records the run and saves the +report to disk instead of sending it. The Pulse Account portal may show whether +a workspace has an enabled report schedule, but it does not render cross-client +reports or collect report data in the provider control plane. ## Licensing diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 1e6732e72..21957d714 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -178,6 +178,11 @@ particular agent update, profile rollout, host command, registration, or fleet operation succeeded. Lifecycle surfaces must keep reading update readiness and continuity from the updater, installer, connection ledger, and agent runtime state instead of inferring it from outbound usage telemetry. +Scheduled-report route and background-worker wiring in `internal/api/router.go` +and the reporting handlers is API/reporting ownership, not agent lifecycle. +The scheduler may enumerate tenant organization IDs so each workspace can run +its own reports, but that enumeration is not agent enrollment, install, +update, profile rollout, command reachability, or fleet-control authority. 1. `frontend-modern/src/api/agentProfiles.ts` shared with `api-contracts`: the agent profiles frontend client is both an agent lifecycle control surface and a canonical API payload contract boundary. 2. `frontend-modern/src/api/nodes.ts` shared with `api-contracts`: the shared Proxmox node client is both an agent lifecycle setup/install control surface and a canonical API payload contract boundary. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index b099ba3d4..2f5ebf034 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -81,6 +81,9 @@ product API routes free of maintainer commercial analytics. 44. `internal/api/ai_hosted_runtime.go` 45. `internal/api/router_routes_licensing.go` 46. `internal/api/reporting_inventory_handlers.go` + 46a. `internal/api/report_schedules.go` + 46b. `internal/config/report_schedules.go` + 46c. `internal/notifications/email_enhanced.go` 47. `internal/cloudcp/portal/bootstrap.go` 48. `internal/cloudcp/portal/handlers.go` 49. `internal/cloudcp/portal/page.go` @@ -4608,6 +4611,16 @@ The catalog route itself is intentionally metadata-readable without the `advanced_reporting` feature gate so locked admin surfaces can present the same canonical reporting definition before upsell, while report generation and inventory export remain feature-gated execution routes. +Scheduled report management is part of that same reporting contract, not a +parallel scheduler-owned report API. `/api/admin/reports/schedules` CRUD and +manual-run routes must reuse the canonical reporting catalog resource model, +multi-report generation path, strict payload validation, and feature/RBAC gates. +The persisted `report_schedules.json` store is tenant or org-local config that +may be encrypted by `ConfigPersistence`; schedule cadence, tag/resource scope, +retention, email attachments, fallback-to-disk delivery, and last-run status +stay inside the tenant runtime. Pulse Account may read schedule setup facts, +but generated PDFs/CSVs and recipient delivery decisions must not move into the +provider control plane. That metadata route is still a version boundary as well. Current Pulse servers must expose `/api/admin/reports/catalog`, but frontend consumers may treat a `404` from that route as an old-backend compatibility signal and fall back to diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 86388ce1e..c2dbc735a 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -117,8 +117,9 @@ contract, not control-plane report generation. The control plane may accept provider-default report brand environment values and pass them into each tenant container as generic `PULSE_REPORT_PROVIDER_BRAND_*` runtime configuration, but the tenant Pulse runtime owns report rendering, per-workspace override loading, -and the `white_label` entitlement gate. This keeps provider-hosted MSP -Stripe-free and avoids a cloud-control-plane report data path across clients. +scheduled report cadence/delivery, generated output retention, and the +`white_label` entitlement gate. This keeps provider-hosted MSP Stripe-free and +avoids a cloud-control-plane report data path across clients. ## Canonical Files @@ -341,8 +342,9 @@ Stripe-free and avoids a cloud-control-plane report data path across clients. contract: `internal/cloudcp/docker/manager.go` may inject `PULSE_REPORT_PROVIDER_BRAND_DISPLAY_NAME`, logo path/data, and logo format into each tenant container, but it must not collect report data or render - PDFs in the control plane. Tenant-local reporting and tenant-local licensing - decide whether that configured brand appears. + PDFs in the control plane. Tenant-local reporting, tenant-local schedules, + and tenant-local licensing decide whether that configured brand appears and + how recurring report delivery runs. `pulse_hosted_msp` is the Pulse-operated form of the same Stripe-free MSP control-plane family, not the public Pulse-hosted SaaS checkout path. It must share the license-backed MSP plan source, workspace limit policy, @@ -673,6 +675,10 @@ or other self-hosted uncapped continuity plans. runtime; Pulse Account may deep-link to those tenant surfaces but must not mint workspace agent credentials or render cross-client monitoring state in the account portal. + Pulse Account may surface tenant-local active alert rollups as counts and + age labels from read-only setup facts so providers can prioritize the + workspace list, but it must not become an alert console or expose alert + bodies, remediation state, acknowledgements, or cross-client alert streams. Pulse Account also owns the provider-facing setup progression for client workspaces: after workspace creation the portal should select the created workspace, reveal the setup job, and preserve workspace/target context in @@ -686,9 +692,11 @@ or other self-hosted uncapped continuity plans. provider setup templates for MSP accounts, but those templates are guidance rather than configuration. `Ready` requires at least one reporting agent, one enabled alert route, and one enabled report schedule; a failed latest health - check remains `Review` ahead of setup counts. Local MSP onboarding previews - should be scenario-backed portal bootstrap data, not static screenshots, so - they stay grounded in the real portal shape as the bundle changes. + check remains `Review` ahead of setup counts, and critical alert rollups + outrank generic health/setup review in provider attention ordering. Local MSP + onboarding previews should be scenario-backed portal bootstrap data, not + static screenshots, so they stay grounded in the real portal shape as the + bundle changes. Hosted provider workspaces may store agent install tokens in the tenant runtime root token store rather than the org-specific config directory. Portal setup facts must count only root tokens whose `OrgID` or `OrgIDs` diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 33efbd2b2..fd150c97b 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -75,6 +75,7 @@ work extends shared components instead of creating new local variants. 46. `frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx` 47. `frontend-modern/src/components/Settings/ReportingPanel.tsx` 48. `frontend-modern/src/components/Settings/reportingPanelModel.ts` + 48a. `frontend-modern/src/components/Settings/reportingSchedulesModel.ts` 49. `frontend-modern/src/components/Settings/reportingInventoryExportModel.ts` 50. `frontend-modern/src/components/Settings/useReportingPanelState.ts` 51. `frontend-modern/src/utils/reportingPresentation.ts` @@ -4788,6 +4789,13 @@ shared buttons must preserve discriminated disclosure props, toggle and a11y helpers must expose exact event signatures, shared rows must accept typed `data-*` props, and reporting-panel helpers must remain ES2020-safe instead of depending on feature-local casts or newer string helpers. +Settings report scheduling follows the same shell/runtime/model split as the +rest of the Reports panel. `ReportingPanel.tsx` owns layout and shared controls, +`useReportingPanelState.ts` owns API lifecycle and save/run/delete control +flow, and `reportingSchedulesModel.ts` owns schedule payload normalization, +labels, default form state, and cadence formatting. Schedule scope selection +must reuse the shared `ResourcePicker` and reporting catalog types rather than +creating a separate resource selector or browser-local schedule API contract. The settings navigation model now exposes a single `infrastructure-systems` sidebar entry for the infrastructure settings area. The former `infrastructure-connections` and `infrastructure-install` entries have been diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index e43557f3d..21d52b0af 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -65,6 +65,11 @@ unified-resource CPU metrics are 0..100 percentages, while legacy 0..1 ratio fields. Monitoring-owned read-state conversion must divide canonical Proxmox node, VM, and LXC CPU percentages before handing them back to legacy snapshot/current-row paths. +Tenant monitor enumeration is monitoring-owned runtime topology, not a +reporting source of truth. `MultiTenantMonitor.ListOrganizationIDs` may expose +persisted organization IDs to API-owned background workers, but it must not +initialize monitors, start pollers, or reinterpret tenant IDs as monitored +resource health. ## Canonical Files @@ -124,6 +129,7 @@ snapshot/current-row paths. 54. `internal/monitoring/monitor_backups.go` 55. `internal/monitoring/resource_stale_thresholds.go` 56. `internal/monitoring/recovery_ingest.go` +57. `internal/monitoring/multi_tenant_monitor.go` ## Shared Boundaries diff --git a/docs/release-control/v6/internal/subsystems/notifications.md b/docs/release-control/v6/internal/subsystems/notifications.md index fb2b33433..ef3637d3a 100644 --- a/docs/release-control/v6/internal/subsystems/notifications.md +++ b/docs/release-control/v6/internal/subsystems/notifications.md @@ -177,6 +177,11 @@ cannot treat raw config strings as header fragments or `RCPT TO` input. That same SMTP boundary also owns MIME-safe body construction. Text and HTML payloads must be emitted through canonical multipart writers with encoded body parts instead of being concatenated directly into handcrafted message bodies. +Scheduled report delivery uses that same enhanced email boundary. Report +attachments must be emitted as MIME attachment parts by +`internal/notifications/email_enhanced.go`, and oversized-report fallback copy +belongs to the reporting scheduler. SMTP transport, recipient parsing, +headers, body encoding, and attachment encoding remain notification-owned. That same queue ownership also governs persistent queue storage roots. The notifications queue database must normalize its owned data directory and resolve the fixed `notification_queue.db` leaf through the shared storage-path diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 4bbaa3224..fa98af7ba 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -218,6 +218,12 @@ regression protection. restore path must write the URL once with `replace` and must not force row filtering through a separate page-local state channel. 4. Keep shared auth gating in `internal/api/router.go` cheap and local: pre-auth quick-setup and recovery routing may short-circuit on loopback/session/token checks, but they must not trigger chart, metrics, or broad persistence fan-out on the protected request hot path. + Scheduled-report background worker registration is allowed in router startup, + but it must stay outside protected request handling. Due-schedule scans may + enumerate tenant organization IDs and load each workspace schedule store, but + normal API route dispatch must not generate reports, render PDFs, scan + metrics history, or perform SMTP delivery as part of auth gating or router + registration. Reading mutable auth configuration for CSRF bootstrap and login checks must stay a short in-memory snapshot under `config.Mu.RLock()`: local username/password presence, API-token presence, and proxy-auth secret diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index ca06d3307..3906e2f10 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -171,6 +171,13 @@ resource or alert metadata. Those fields are operational usage telemetry only; they must not be expanded into command lines, environment variables, secret material, or unbounded container inspection output at the API boundary. +Scheduled report management under `/api/admin/reports/schedules` is a +settings/reporting control surface, not a new public data export. It must reuse +the existing reporting feature gate and settings read/write scopes, persist +workspace-local schedule metadata only, and never add cross-tenant report +creation, unauthenticated delivery, raw SMTP secret exposure, or bypasses for +the `white_label` branding entitlement. + 1. Change privacy disclosures, usage-data vocabulary, or outbound-data guarantees through `docs/PRIVACY.md`, `frontend-modern/public/docs/PRIVACY.md`, `internal/telemetry/telemetry.go`, and `pkg/server/telemetry_pulse_intelligence.go` together. Pulse Intelligence external-agent/MCP telemetry may expose only content-free adapter-origin usage and capability-class counters for context, event diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 39d39eeca..d0656a3cb 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -160,6 +160,13 @@ storage or recovery product state. `reportBranding` persisted in a tenant runtime's `system.json` should be preserved by the existing tenant data backup/restore path, but it must not create cross-client report storage, restore scope, backup visibility, or recovery authority. +Scheduled report definitions and generated report files are likewise +reporting-owned tenant-local artifacts. The scheduler may save output under +the workspace data directory for operator retrieval and retention, but those +PDF/CSV files are not recovery points, backup artifacts, storage-health +evidence, restore manifests, or cross-client storage inventory. Storage and +recovery flows may preserve them as normal tenant data during backup/restore, +but must not interpret their presence as infrastructure protection. Generated Proxmox setup-script, runtime host-agent setup, and installer auto-registration changes that affect backup visibility permissions are diff --git a/frontend-modern/public/docs/CONFIGURATION.md b/frontend-modern/public/docs/CONFIGURATION.md index edb7b1cf7..02ba50d0c 100644 --- a/frontend-modern/public/docs/CONFIGURATION.md +++ b/frontend-modern/public/docs/CONFIGURATION.md @@ -22,6 +22,7 @@ Pulse uses a split-configuration model to ensure security and flexibility. | `ai_usage_history.json` | AI usage history | ๐Ÿ“ Standard | | `ai_chat_sessions.json` | Legacy AI chat sessions (UI sync) | ๐Ÿ“ Standard | | `license.enc` | Relay/Pro/legacy Pro+/Cloud license key | ๐Ÿ”’ **Encrypted** | +| `report_schedules.json` | Scheduled report definitions, recipients, and last-run metadata | ๐Ÿ”’ **Sensitive** (encrypted when data-dir encryption is enabled) | | `host_metadata.json` | Host notes, tags, and AI command overrides | ๐Ÿ“ Standard | | `docker_metadata.json` | Docker metadata cache | ๐Ÿ“ Standard | | `guest_metadata.json` | Guest notes and metadata | ๐Ÿ“ Standard | diff --git a/frontend-modern/src/components/Settings/ReportingPanel.tsx b/frontend-modern/src/components/Settings/ReportingPanel.tsx index a1c7ddd54..1e096aab0 100644 --- a/frontend-modern/src/components/Settings/ReportingPanel.tsx +++ b/frontend-modern/src/components/Settings/ReportingPanel.tsx @@ -3,6 +3,13 @@ import FileText from 'lucide-solid/icons/file-text'; import Download from 'lucide-solid/icons/download'; import BarChart from 'lucide-solid/icons/bar-chart'; import TableProperties from 'lucide-solid/icons/table-properties'; +import Plus from 'lucide-solid/icons/plus'; +import Pencil from 'lucide-solid/icons/pencil'; +import Play from 'lucide-solid/icons/play'; +import RefreshCw from 'lucide-solid/icons/refresh-cw'; +import Trash2 from 'lucide-solid/icons/trash-2'; +import Save from 'lucide-solid/icons/save'; +import X from 'lucide-solid/icons/x'; import OperationsPanel from '@/components/Settings/OperationsPanel'; import { Button } from '@/components/shared/Button'; import { CalloutCard } from '@/components/shared/CalloutCard'; @@ -12,6 +19,13 @@ import { FeatureGateSection } from '@/components/shared/FeatureGateSection'; import { useReportingPanelState } from '@/components/Settings/useReportingPanelState'; import type { ReportingFormat } from '@/components/Settings/reportingCatalogModel'; import { type ReportingRangeValue } from '@/components/Settings/reportingPanelModel'; +import { + formatReportScheduleTime, + reportScheduleCadenceLabel, + reportScheduleDeliveryLabel, + reportScheduleLastRunLabel, + reportScheduleScopeLabel, +} from '@/components/Settings/reportingSchedulesModel'; import { ResourcePicker } from './ResourcePicker'; const REPORTING_FORMAT_ICONS: Record = { @@ -35,8 +49,13 @@ function FormField(props: FormFieldProps) { ); } +const WEEKDAY_OPTIONS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']; + export function ReportingPanel() { const { + closeScheduleForm, + deleteReportSchedule, + deletingScheduleID, exportingInventory, format, handleExportVMInventory, @@ -46,18 +65,34 @@ export function ReportingPanel() { isReportingEnabled, metricType, range, + reportSchedules, + reportSchedulesError, + reportSchedulesLoading, reportingCatalog, reportingCatalogError, reportingCatalogLoading, reloadReportingCatalog, + reloadReportSchedules, + runReportScheduleNow, + runningScheduleID, + saveReportSchedule, + savingSchedule, + scheduleForm, + scheduleFormOpen, + scheduleResources, selectedResources, setFormat, setMetricType, setRange, + setScheduleResources, setSelectedResources, setTitle, showUpgradePrompts, + startCreateSchedule, + startEditSchedule, title, + toggleReportSchedule, + updateScheduleForm, upgradeDestination, } = useReportingPanelState(); @@ -255,6 +290,336 @@ export function ReportingPanel() { +
+
+
+

Scheduled reports

+

+ Send recurring client performance reports using the same resource scope and branding as generated reports. +

+
+
+ + +
+
+ + +

Loading report schedules...

+
+ +

{reportSchedulesError()}

+
+ + 0} + fallback={ +
+

+ No scheduled reports are configured yet. +

+ +
+ } + > +
+ + + + + + + + + + + + + + + + + + + + + + + + {(schedule) => ( + + + + + + + + + + )} + + +
NameCadenceScopeDeliveryLast runEnabledActions
+ {schedule.name} + + {reportScheduleCadenceLabel(schedule)} + + {reportScheduleScopeLabel(schedule)} + + {reportScheduleDeliveryLabel(schedule)} + +
{reportScheduleLastRunLabel(schedule)}
+ +
+ {formatReportScheduleTime(schedule.last_run_at)} +
+
+
+ + +
+ + + +
+
+
+
+ + +
+
+ + updateScheduleForm({ name: e.currentTarget.value })} + placeholder="Monthly client report" + /> + + + updateScheduleForm({ timezone: e.currentTarget.value })} + placeholder="Europe/London" + /> + +
+ +
+ + + + + + + } + > + + updateScheduleForm({ dayOfMonth: Number(e.currentTarget.value) })} + /> + + + + updateScheduleForm({ time: e.currentTarget.value })} + /> + + + + +
+ + + + + +
+ + updateScheduleForm({ tagFilter: e.currentTarget.value })} + placeholder="production, customer-facing" + /> + + + + + + updateScheduleForm({ retentionCount: Number(e.currentTarget.value) })} + /> + +
+ +
+ + updateScheduleForm({ recipients: e.currentTarget.value })} + placeholder="client@example.com, ops@example.com" + disabled={scheduleForm().deliveryMethod !== 'email'} + /> + +
+ + + +
+
+ +
+ + +
+
+
+
+
diff --git a/frontend-modern/src/components/Settings/__tests__/ReportingPanel.test.tsx b/frontend-modern/src/components/Settings/__tests__/ReportingPanel.test.tsx index 88ff1c28d..eee7f8a17 100644 --- a/frontend-modern/src/components/Settings/__tests__/ReportingPanel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/ReportingPanel.test.tsx @@ -72,6 +72,9 @@ const baseCatalog = { function buildState(overrides: Record = {}) { return { + closeScheduleForm: vi.fn(), + deleteReportSchedule: vi.fn(), + deletingScheduleID: () => '', exportingInventory: () => false, format: () => 'pdf' as const, handleExportVMInventory: vi.fn(), @@ -81,18 +84,50 @@ function buildState(overrides: Record = {}) { isReportingEnabled: () => true, metricType: () => '', range: () => '24h', + reportSchedules: () => [], + reportSchedulesError: () => '', + reportSchedulesLoading: () => false, reportingCatalog: () => baseCatalog, reportingCatalogError: () => '', reportingCatalogLoading: () => false, reloadReportingCatalog: vi.fn(), + reloadReportSchedules: vi.fn(), + runReportScheduleNow: vi.fn(), + runningScheduleID: () => '', + saveReportSchedule: vi.fn(), + savingSchedule: () => false, + scheduleForm: () => ({ + id: '', + name: '', + enabled: true, + cadenceType: 'monthly', + dayOfMonth: 1, + weekday: 'monday', + time: '09:00', + timezone: 'UTC', + format: 'pdf', + deliveryMethod: 'email', + recipients: '', + attach: true, + saveToDisk: true, + tagFilter: '', + retentionCount: 12, + }), + scheduleFormOpen: () => false, + scheduleResources: () => [], selectedResources: () => [], setFormat: vi.fn(), setMetricType: vi.fn(), setRange: vi.fn(), + setScheduleResources: vi.fn(), setSelectedResources: vi.fn(), setTitle: vi.fn(), showUpgradePrompts: () => true, + startCreateSchedule: vi.fn(), + startEditSchedule: vi.fn(), title: () => '', + toggleReportSchedule: vi.fn(), + updateScheduleForm: vi.fn(), upgradeDestination: () => ({ href: getPublicPricingUrl('advanced_reporting'), external: true, @@ -119,6 +154,15 @@ describe('ReportingPanel', () => { expect(screen.getByText('Report Title')).toBeInTheDocument(); }); + it('shows the scheduled reports table surface with an empty state', () => { + useReportingPanelStateMock.mockReturnValue(buildState()); + + render(() => ); + + expect(screen.getByText('Scheduled reports')).toBeInTheDocument(); + expect(screen.getByText('No scheduled reports are configured yet.')).toBeInTheDocument(); + }); + it('hides unsupported optional controls from the reporting surface', () => { useReportingPanelStateMock.mockReturnValue( buildState({ diff --git a/frontend-modern/src/components/Settings/__tests__/reportingCatalogModel.test.ts b/frontend-modern/src/components/Settings/__tests__/reportingCatalogModel.test.ts index e2c72fb62..97add3f9a 100644 --- a/frontend-modern/src/components/Settings/__tests__/reportingCatalogModel.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/reportingCatalogModel.test.ts @@ -91,6 +91,15 @@ describe('reporting catalog model', () => { ); }); + it('accepts a catalog without the optional inventory export surface', () => { + const catalog = parseReportingCatalog({ + ...baseCatalogPayload, + vmInventoryExport: null, + }); + + expect(catalog.vmInventoryExport).toBeNull(); + }); + it('rejects a catalog whose default format is not in the supported formats', () => { expect(() => parseReportingCatalog({ diff --git a/frontend-modern/src/components/Settings/__tests__/reportingPanelModel.test.ts b/frontend-modern/src/components/Settings/__tests__/reportingPanelModel.test.ts index 8f6f351b8..c25f9ef45 100644 --- a/frontend-modern/src/components/Settings/__tests__/reportingPanelModel.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/reportingPanelModel.test.ts @@ -5,6 +5,12 @@ import { getReportingRangeStart, } from '../reportingPanelModel'; import type { ReportingPerformanceReportDefinition } from '../reportingCatalogModel'; +import { + buildReportSchedulePayload, + parseReportSchedulesResponse, + reportScheduleCadenceLabel, + reportScheduleDeliveryLabel, +} from '../reportingSchedulesModel'; const performanceDefinition: ReportingPerformanceReportDefinition = { id: 'performance_reports', @@ -260,4 +266,76 @@ describe('reporting panel model', () => { expect(request.filename).toBe('report-lab-node-quotedvm-20260320.pdf'); }); + + it('builds scheduled report payloads with explicit workspace scope and delivery', () => { + const payload = buildReportSchedulePayload( + { + id: '', + name: ' Acme monthly ', + enabled: true, + cadenceType: 'monthly', + dayOfMonth: 1, + weekday: 'monday', + time: '06:00', + timezone: 'Europe/London', + format: 'pdf', + deliveryMethod: 'email', + recipients: 'ops@example.com, Ops@example.com, noc@example.com', + attach: true, + saveToDisk: true, + tagFilter: 'client:acme, client:acme', + retentionCount: 12, + }, + [ + { + id: 'agent-1', + type: 'agent', + name: 'pve-a', + }, + ], + ); + + expect(payload).toMatchObject({ + id: '', + name: 'Acme monthly', + enabled: true, + cadence: { + type: 'monthly', + day_of_month: 1, + time: '06:00', + timezone: 'Europe/London', + }, + scope: { + resources: [{ resourceType: 'agent', resourceId: 'agent-1', name: 'pve-a' }], + tags: ['client:acme'], + }, + format: 'pdf', + delivery: { + method: 'email', + to: ['ops@example.com', 'noc@example.com'], + attach: true, + save_to_disk: true, + }, + retention_count: 12, + }); + }); + + it('normalizes scheduled report labels from the API response contract', () => { + const [schedule] = parseReportSchedulesResponse({ + schedules: [ + { + id: 'weekly', + name: 'Weekly report', + enabled: true, + cadence: { type: 'weekly', weekday: 'friday', time: '08:15', timezone: 'UTC' }, + scope: { resources: [], tags: ['client:acme'] }, + format: 'csv', + delivery: { method: 'disk', attach: false, save_to_disk: true }, + }, + ], + }); + + expect(reportScheduleCadenceLabel(schedule)).toBe('Friday at 08:15'); + expect(reportScheduleDeliveryLabel(schedule)).toBe('Save to disk'); + }); }); diff --git a/frontend-modern/src/components/Settings/__tests__/useReportingPanelState.test.ts b/frontend-modern/src/components/Settings/__tests__/useReportingPanelState.test.ts index 88490624e..9fe59a7cd 100644 --- a/frontend-modern/src/components/Settings/__tests__/useReportingPanelState.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/useReportingPanelState.test.ts @@ -75,6 +75,21 @@ const catalogPayload = { }, }; +const schedulesPayload = { schedules: [] }; + +function jsonResponse(payload: unknown, status = 200): Response { + return new Response(JSON.stringify(payload), { status }); +} + +function buildCatalogAndSchedulesFetchMock(catalogResponse: Response = jsonResponse(catalogPayload)) { + return vi.fn((url: string) => { + if (url === '/api/admin/reports/schedules') { + return Promise.resolve(jsonResponse(schedulesPayload)); + } + return Promise.resolve(catalogResponse); + }); +} + describe('useReportingPanelState', () => { let useReportingPanelState: UseReportingPanelStateModule['useReportingPanelState']; let apiFetchMock: ReturnType; @@ -85,9 +100,7 @@ describe('useReportingPanelState', () => { beforeEach(async () => { vi.resetModules(); - apiFetchMock = vi - .fn() - .mockResolvedValue(new Response(JSON.stringify(catalogPayload), { status: 200 })); + apiFetchMock = buildCatalogAndSchedulesFetchMock(); hasReportingFeature = true; loadRuntimeLicenseStatusMock = vi.fn(); loadCommercialLicenseStatusMock = vi.fn(); @@ -183,9 +196,7 @@ describe('useReportingPanelState', () => { it('loads the reporting catalog before license readiness settles', async () => { vi.resetModules(); - apiFetchMock = vi - .fn() - .mockResolvedValue(new Response(JSON.stringify(catalogPayload), { status: 200 })); + apiFetchMock = buildCatalogAndSchedulesFetchMock(); hasReportingFeature = false; loadRuntimeLicenseStatusMock = vi.fn(); loadCommercialLicenseStatusMock = vi.fn(); @@ -241,10 +252,17 @@ describe('useReportingPanelState', () => { it('allows retrying the reporting catalog fetch after an initial failure', async () => { vi.resetModules(); - apiFetchMock = vi - .fn() - .mockResolvedValueOnce(new Response('temporary failure', { status: 500 })) - .mockResolvedValueOnce(new Response(JSON.stringify(catalogPayload), { status: 200 })); + let catalogCalls = 0; + apiFetchMock = vi.fn((url: string) => { + if (url === '/api/admin/reports/schedules') { + return Promise.resolve(jsonResponse(schedulesPayload)); + } + catalogCalls += 1; + if (catalogCalls === 1) { + return Promise.resolve(new Response('temporary failure', { status: 500 })); + } + return Promise.resolve(jsonResponse(catalogPayload)); + }); vi.doMock('@/utils/apiClient', async () => { const actual = await vi.importActual('@/utils/apiClient'); @@ -291,8 +309,9 @@ describe('useReportingPanelState', () => { await flushAsync(); await flushAsync(); - expect(apiFetchMock).toHaveBeenCalledTimes(2); + expect(apiFetchMock.mock.calls.filter(([url]) => url === '/api/admin/reports/catalog')).toHaveLength(2); expect(hookState.reportingCatalog()?.title).toBe('Detailed Reporting'); + expect(hookState.reportSchedules()).toEqual([]); dispose(); }); @@ -300,13 +319,11 @@ describe('useReportingPanelState', () => { it('extracts structured API error messages for reporting catalog failures', async () => { vi.resetModules(); - apiFetchMock = vi - .fn() - .mockResolvedValueOnce( - new Response(JSON.stringify({ error: 'Catalog is unavailable right now' }), { - status: 503, - }), - ); + apiFetchMock = buildCatalogAndSchedulesFetchMock( + new Response(JSON.stringify({ error: 'Catalog is unavailable right now' }), { + status: 503, + }), + ); vi.doMock('@/utils/apiClient', async () => { const actual = await vi.importActual('@/utils/apiClient'); @@ -355,9 +372,9 @@ describe('useReportingPanelState', () => { it('falls back to the legacy reporting transport when the catalog route is missing', async () => { vi.resetModules(); - apiFetchMock = vi - .fn() - .mockResolvedValueOnce(new Response('404 page not found', { status: 404 })); + apiFetchMock = buildCatalogAndSchedulesFetchMock( + new Response('404 page not found', { status: 404 }), + ); vi.doMock('@/utils/apiClient', async () => { const actual = await vi.importActual('@/utils/apiClient'); diff --git a/frontend-modern/src/components/Settings/reportingCatalogModel.ts b/frontend-modern/src/components/Settings/reportingCatalogModel.ts index d4aeb7df8..8303b0abe 100644 --- a/frontend-modern/src/components/Settings/reportingCatalogModel.ts +++ b/frontend-modern/src/components/Settings/reportingCatalogModel.ts @@ -274,6 +274,9 @@ export function parseReportingCatalog(input: unknown): ReportingCatalog { lockedState: parseReportingLockedStateDefinition(candidate.lockedState), guidance: parseReportingGuidanceDefinition(candidate.guidance), performanceReport: parseReportingPerformanceReportDefinition(candidate.performanceReport), - vmInventoryExport: parseVMInventoryExportDefinition(candidate.vmInventoryExport), + vmInventoryExport: + candidate.vmInventoryExport === null + ? null + : parseVMInventoryExportDefinition(candidate.vmInventoryExport), }; } diff --git a/frontend-modern/src/components/Settings/reportingSchedulesModel.ts b/frontend-modern/src/components/Settings/reportingSchedulesModel.ts new file mode 100644 index 000000000..b7d29a06a --- /dev/null +++ b/frontend-modern/src/components/Settings/reportingSchedulesModel.ts @@ -0,0 +1,239 @@ +import type { SelectedResource } from '@/components/Settings/ResourcePicker'; +import type { ReportingFormat } from '@/components/Settings/reportingCatalogModel'; + +export type ReportScheduleCadenceType = 'monthly' | 'weekly'; +export type ReportScheduleRunStatus = '' | 'ok' | 'failed'; +export type ReportScheduleDeliveryMethod = 'email' | 'disk'; + +export interface ReportScheduleResource { + resourceType: string; + resourceId: string; + name?: string; +} + +export interface ReportSchedule { + id: string; + name: string; + enabled: boolean; + cadence: { + type: ReportScheduleCadenceType; + day_of_month?: number; + weekday?: string; + time: string; + timezone: string; + }; + scope: { + resources?: ReportScheduleResource[]; + tags?: string[]; + }; + format: ReportingFormat; + delivery: { + method: ReportScheduleDeliveryMethod; + to?: string[]; + attach: boolean; + save_to_disk: boolean; + }; + retention_count?: number; + last_run_at?: string; + last_run_status?: ReportScheduleRunStatus; + last_error?: string; + next_run_at?: string; + created_at?: string; + updated_at?: string; +} + +export interface ReportScheduleFormState { + id: string; + name: string; + enabled: boolean; + cadenceType: ReportScheduleCadenceType; + dayOfMonth: number; + weekday: string; + time: string; + timezone: string; + format: ReportingFormat; + deliveryMethod: ReportScheduleDeliveryMethod; + recipients: string; + attach: boolean; + saveToDisk: boolean; + tagFilter: string; + retentionCount: number; +} + +export interface ReportSchedulesResponse { + schedules?: ReportSchedule[]; +} + +const WEEKDAY_LABELS: Record = { + monday: 'Monday', + tuesday: 'Tuesday', + wednesday: 'Wednesday', + thursday: 'Thursday', + friday: 'Friday', + saturday: 'Saturday', + sunday: 'Sunday', +}; + +export const DEFAULT_REPORT_SCHEDULE_FORM = (): ReportScheduleFormState => ({ + id: '', + name: '', + enabled: true, + cadenceType: 'monthly', + dayOfMonth: 1, + weekday: 'monday', + time: '09:00', + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', + format: 'pdf', + deliveryMethod: 'email', + recipients: '', + attach: true, + saveToDisk: true, + tagFilter: '', + retentionCount: 12, +}); + +export function parseReportSchedulesResponse(value: unknown): ReportSchedule[] { + if (!value || typeof value !== 'object') return []; + const schedules = (value as ReportSchedulesResponse).schedules; + return Array.isArray(schedules) ? schedules.map(normalizeReportSchedule) : []; +} + +export function normalizeReportSchedule(schedule: ReportSchedule): ReportSchedule { + return { + ...schedule, + enabled: schedule.enabled !== false, + cadence: { + type: schedule.cadence?.type === 'weekly' ? 'weekly' : 'monthly', + day_of_month: schedule.cadence?.day_of_month ?? 1, + weekday: schedule.cadence?.weekday || 'monday', + time: schedule.cadence?.time || '09:00', + timezone: schedule.cadence?.timezone || 'UTC', + }, + scope: { + resources: Array.isArray(schedule.scope?.resources) ? schedule.scope.resources : [], + tags: Array.isArray(schedule.scope?.tags) ? schedule.scope.tags : [], + }, + format: schedule.format === 'csv' ? 'csv' : 'pdf', + delivery: { + method: schedule.delivery?.method === 'disk' ? 'disk' : 'email', + to: Array.isArray(schedule.delivery?.to) ? schedule.delivery.to : [], + attach: schedule.delivery?.attach !== false, + save_to_disk: schedule.delivery?.save_to_disk !== false, + }, + retention_count: schedule.retention_count ?? 12, + }; +} + +export function scheduleToForm(schedule: ReportSchedule): ReportScheduleFormState { + const normalized = normalizeReportSchedule(schedule); + return { + id: normalized.id, + name: normalized.name, + enabled: normalized.enabled, + cadenceType: normalized.cadence.type, + dayOfMonth: normalized.cadence.day_of_month ?? 1, + weekday: normalized.cadence.weekday || 'monday', + time: normalized.cadence.time, + timezone: normalized.cadence.timezone, + format: normalized.format, + deliveryMethod: normalized.delivery.method, + recipients: (normalized.delivery.to ?? []).join(', '), + attach: normalized.delivery.attach, + saveToDisk: normalized.delivery.save_to_disk, + tagFilter: (normalized.scope.tags ?? []).join(', '), + retentionCount: normalized.retention_count ?? 12, + }; +} + +export function scheduleToSelectedResources(schedule: ReportSchedule): SelectedResource[] { + return (schedule.scope.resources ?? []).map((resource) => ({ + id: resource.resourceId, + type: resource.resourceType as SelectedResource['type'], + name: resource.name || resource.resourceId, + })); +} + +export function buildReportSchedulePayload( + form: ReportScheduleFormState, + resources: SelectedResource[], +): Omit { + return { + id: form.id, + name: form.name.trim(), + enabled: form.enabled, + cadence: { + type: form.cadenceType, + day_of_month: form.cadenceType === 'monthly' ? form.dayOfMonth : undefined, + weekday: form.cadenceType === 'weekly' ? form.weekday : undefined, + time: form.time, + timezone: form.timezone.trim() || 'UTC', + }, + scope: { + resources: resources.map((resource) => ({ + resourceType: resource.type, + resourceId: resource.id, + name: resource.name, + })), + tags: parseCommaList(form.tagFilter), + }, + format: form.format, + delivery: { + method: form.deliveryMethod, + to: parseCommaList(form.recipients), + attach: form.attach, + save_to_disk: form.saveToDisk, + }, + retention_count: form.retentionCount, + }; +} + +export function parseCommaList(value: string): string[] { + const seen = new Set(); + return value + .split(',') + .map((item) => item.trim()) + .filter((item) => { + if (!item) return false; + const key = item.toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +export function reportScheduleCadenceLabel(schedule: ReportSchedule): string { + const normalized = normalizeReportSchedule(schedule); + if (normalized.cadence.type === 'weekly') { + return `${WEEKDAY_LABELS[normalized.cadence.weekday || 'monday'] ?? normalized.cadence.weekday} at ${normalized.cadence.time}`; + } + return `Monthly on day ${normalized.cadence.day_of_month ?? 1} at ${normalized.cadence.time}`; +} + +export function reportScheduleScopeLabel(schedule: ReportSchedule): string { + const resources = schedule.scope.resources?.length ?? 0; + const tags = schedule.scope.tags?.length ?? 0; + const parts = []; + if (resources > 0) parts.push(`${resources} resource${resources === 1 ? '' : 's'}`); + if (tags > 0) parts.push(`${tags} tag${tags === 1 ? '' : 's'}`); + return parts.length ? parts.join(', ') : 'No scope'; +} + +export function reportScheduleDeliveryLabel(schedule: ReportSchedule): string { + const normalized = normalizeReportSchedule(schedule); + if (normalized.delivery.method === 'disk') return 'Save to disk'; + if ((normalized.delivery.to ?? []).length > 0) return `${normalized.delivery.to!.length} email recipient${normalized.delivery.to!.length === 1 ? '' : 's'}`; + return 'Email config recipients'; +} + +export function reportScheduleLastRunLabel(schedule: ReportSchedule): string { + if (!schedule.last_run_status) return 'Not run yet'; + if (schedule.last_run_status === 'ok') return 'Last run OK'; + return schedule.last_error ? `Failed: ${schedule.last_error}` : 'Last run failed'; +} + +export function formatReportScheduleTime(value?: string): string { + if (!value) return ''; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ''; + return date.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); +} diff --git a/frontend-modern/src/components/Settings/useReportingPanelState.ts b/frontend-modern/src/components/Settings/useReportingPanelState.ts index 9295b2a13..21f5dc4bc 100644 --- a/frontend-modern/src/components/Settings/useReportingPanelState.ts +++ b/frontend-modern/src/components/Settings/useReportingPanelState.ts @@ -28,6 +28,16 @@ import { type ReportingCatalog, type ReportingFormat, } from '@/components/Settings/reportingCatalogModel'; +import { + buildReportSchedulePayload, + DEFAULT_REPORT_SCHEDULE_FORM, + normalizeReportSchedule, + parseReportSchedulesResponse, + scheduleToForm, + scheduleToSelectedResources, + type ReportSchedule, + type ReportScheduleFormState, +} from '@/components/Settings/reportingSchedulesModel'; export const useReportingPanelState = () => { const [selectedResources, setSelectedResources] = createSignal([]); @@ -41,6 +51,18 @@ export const useReportingPanelState = () => { const [reportingCatalogError, setReportingCatalogError] = createSignal(''); const [reportingCatalogAttempted, setReportingCatalogAttempted] = createSignal(false); const [title, setTitle] = createSignal(''); + const [reportSchedules, setReportSchedules] = createSignal([]); + const [reportSchedulesLoading, setReportSchedulesLoading] = createSignal(false); + const [reportSchedulesAttempted, setReportSchedulesAttempted] = createSignal(false); + const [reportSchedulesError, setReportSchedulesError] = createSignal(''); + const [scheduleFormOpen, setScheduleFormOpen] = createSignal(false); + const [scheduleForm, setScheduleForm] = createSignal( + DEFAULT_REPORT_SCHEDULE_FORM(), + ); + const [scheduleResources, setScheduleResources] = createSignal([]); + const [savingSchedule, setSavingSchedule] = createSignal(false); + const [runningScheduleID, setRunningScheduleID] = createSignal(''); + const [deletingScheduleID, setDeletingScheduleID] = createSignal(''); const reportingFeatureId = () => reportingCatalog()?.id ?? ''; const showUpgradePrompts = () => !presentationPolicyHidesUpgradePrompts(); @@ -95,6 +117,32 @@ export const useReportingPanelState = () => { void loadReportingCatalog(); }); + const loadReportSchedules = async () => { + if (reportSchedulesLoading()) return; + setReportSchedulesAttempted(true); + setReportSchedulesLoading(true); + setReportSchedulesError(''); + try { + const response = await apiFetch('/api/admin/reports/schedules'); + if (!response.ok) { + throw await apiErrorFromResponse(response, 'Failed to load report schedules'); + } + setReportSchedules(parseReportSchedulesResponse(await response.json())); + } catch (error) { + console.error('Report schedules error:', error); + setReportSchedulesError(error instanceof Error ? error.message : 'Failed to load report schedules'); + } finally { + setReportSchedulesLoading(false); + } + }; + + createEffect(() => { + if (!isReportingEnabled() || reportSchedulesAttempted() || reportSchedulesLoading()) { + return; + } + void loadReportSchedules(); + }); + createEffect(() => { const performanceReport = reportingCatalog()?.performanceReport; if (!performanceReport) { @@ -202,7 +250,157 @@ export const useReportingPanelState = () => { } }; + const updateScheduleForm = (patch: Partial) => { + setScheduleForm((current) => ({ ...current, ...patch })); + }; + + const startCreateSchedule = () => { + setScheduleForm(DEFAULT_REPORT_SCHEDULE_FORM()); + setScheduleResources([]); + setScheduleFormOpen(true); + }; + + const startEditSchedule = (schedule: ReportSchedule) => { + const normalized = normalizeReportSchedule(schedule); + setScheduleForm(scheduleToForm(normalized)); + setScheduleResources(scheduleToSelectedResources(normalized)); + setScheduleFormOpen(true); + }; + + const closeScheduleForm = () => { + setScheduleFormOpen(false); + setScheduleForm(DEFAULT_REPORT_SCHEDULE_FORM()); + setScheduleResources([]); + }; + + const saveReportSchedule = async () => { + if (savingSchedule()) return; + const form = scheduleForm(); + const payload = buildReportSchedulePayload(form, scheduleResources()); + if (!payload.name) { + showWarning('Schedule name is required'); + return; + } + if ((payload.scope.resources?.length ?? 0) === 0 && (payload.scope.tags?.length ?? 0) === 0) { + showWarning('Select resources or enter at least one tag'); + return; + } + + setSavingSchedule(true); + try { + const isUpdate = form.id !== ''; + const response = await apiFetch( + isUpdate + ? `/api/admin/reports/schedules/${encodeURIComponent(form.id)}` + : '/api/admin/reports/schedules', + { + method: isUpdate ? 'PUT' : 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }, + ); + if (!response.ok) { + throw await apiErrorFromResponse(response, 'Failed to save report schedule'); + } + const saved = normalizeReportSchedule(await response.json()); + setReportSchedules((current) => { + const index = current.findIndex((schedule) => schedule.id === saved.id); + if (index < 0) return [...current, saved]; + const next = current.slice(); + next[index] = saved; + return next; + }); + closeScheduleForm(); + showSuccess('Report schedule saved'); + } catch (error) { + console.error('Save report schedule error:', error); + showWarning(error instanceof Error ? error.message : 'Failed to save report schedule'); + } finally { + setSavingSchedule(false); + } + }; + + const updateReportSchedule = async (schedule: ReportSchedule) => { + const normalized = normalizeReportSchedule(schedule); + const response = await apiFetch(`/api/admin/reports/schedules/${encodeURIComponent(normalized.id)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(normalized), + }); + if (!response.ok) { + throw await apiErrorFromResponse(response, 'Failed to update report schedule'); + } + const saved = normalizeReportSchedule(await response.json()); + setReportSchedules((current) => + current.map((candidate) => (candidate.id === saved.id ? saved : candidate)), + ); + }; + + const toggleReportSchedule = async (schedule: ReportSchedule) => { + try { + await updateReportSchedule({ ...schedule, enabled: !schedule.enabled }); + showSuccess(schedule.enabled ? 'Report schedule disabled' : 'Report schedule enabled'); + } catch (error) { + console.error('Toggle report schedule error:', error); + showWarning(error instanceof Error ? error.message : 'Failed to update report schedule'); + } + }; + + const runReportScheduleNow = async (schedule: ReportSchedule) => { + if (runningScheduleID()) return; + setRunningScheduleID(schedule.id); + try { + const response = await apiFetch( + `/api/admin/reports/schedules/${encodeURIComponent(schedule.id)}/run`, + { method: 'POST' }, + ); + if (!response.ok) { + throw await apiErrorFromResponse(response, 'Failed to run report schedule'); + } + const body = await response.json(); + if (body?.schedule) { + const updated = normalizeReportSchedule(body.schedule); + setReportSchedules((current) => + current.map((candidate) => (candidate.id === updated.id ? updated : candidate)), + ); + } else { + await loadReportSchedules(); + } + showSuccess('Report schedule ran'); + } catch (error) { + console.error('Run report schedule error:', error); + showWarning(error instanceof Error ? error.message : 'Failed to run report schedule'); + await loadReportSchedules(); + } finally { + setRunningScheduleID(''); + } + }; + + const deleteReportSchedule = async (schedule: ReportSchedule) => { + if (deletingScheduleID()) return; + if (!window.confirm(`Delete ${schedule.name}?`)) return; + setDeletingScheduleID(schedule.id); + try { + const response = await apiFetch(`/api/admin/reports/schedules/${encodeURIComponent(schedule.id)}`, { + method: 'DELETE', + }); + if (!response.ok) { + throw await apiErrorFromResponse(response, 'Failed to delete report schedule'); + } + setReportSchedules((current) => current.filter((candidate) => candidate.id !== schedule.id)); + showSuccess('Report schedule deleted'); + } catch (error) { + console.error('Delete report schedule error:', error); + showWarning(error instanceof Error ? error.message : 'Failed to delete report schedule'); + } finally { + setDeletingScheduleID(''); + } + }; + return { + closeScheduleForm, + deleteReportSchedule, + deletingScheduleID, exportingInventory, format, handleExportVMInventory, @@ -212,6 +410,9 @@ export const useReportingPanelState = () => { isReportingEnabled, metricType, range, + reportSchedules, + reportSchedulesError, + reportSchedulesLoading, reportingCatalog, reportingCatalogError, reportingCatalogLoading, @@ -221,14 +422,30 @@ export const useReportingPanelState = () => { } void loadReportingCatalog(); }, + reloadReportSchedules: () => { + if (reportSchedulesLoading()) return; + void loadReportSchedules(); + }, + runReportScheduleNow, + runningScheduleID, + saveReportSchedule, + savingSchedule, + scheduleForm, + scheduleFormOpen, + scheduleResources, selectedResources, setFormat, setMetricType, setRange, + setScheduleResources, setSelectedResources, setTitle, showUpgradePrompts, + startCreateSchedule, + startEditSchedule, title, + toggleReportSchedule, + updateScheduleForm, upgradeDestination, }; }; diff --git a/internal/api/audit_reporting_scope_test.go b/internal/api/audit_reporting_scope_test.go index 1680d738d..022b8eebd 100644 --- a/internal/api/audit_reporting_scope_test.go +++ b/internal/api/audit_reporting_scope_test.go @@ -51,6 +51,7 @@ func TestReportingEndpointsRequireSettingsReadScope(t *testing.T) { "/api/admin/reports/generate", "/api/admin/reports/generate-multi", "/api/admin/reports/inventory/vms/export", + "/api/admin/reports/schedules", } for _, path := range paths { @@ -67,6 +68,39 @@ func TestReportingEndpointsRequireSettingsReadScope(t *testing.T) { } } +func TestReportScheduleMutationEndpointsRequireSettingsWriteScope(t *testing.T) { + t.Setenv("PULSE_DEV", "true") + + rawToken := "reports-write-scope-token-123.12345678" + record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) + cfg := newTestConfigWithTokens(t, record) + router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") + + cases := []struct { + method string + path string + body string + }{ + {method: http.MethodPost, path: "/api/admin/reports/schedules", body: `{}`}, + {method: http.MethodPut, path: "/api/admin/reports/schedules/schedule-1", body: `{}`}, + {method: http.MethodDelete, path: "/api/admin/reports/schedules/schedule-1", body: ""}, + {method: http.MethodPost, path: "/api/admin/reports/schedules/schedule-1/run", body: ""}, + } + + for _, tc := range cases { + req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) + req.Header.Set("X-API-Token", rawToken) + rec := httptest.NewRecorder() + router.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("expected 403 for missing settings:write scope on %s %s, got %d", tc.method, tc.path, rec.Code) + } + if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) { + t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String()) + } + } +} + func TestAuditWebhooksRequireSettingsScopes(t *testing.T) { t.Setenv("PULSE_DEV", "true") diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 0e7537f4c..29d6b0a90 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -133,6 +133,70 @@ func TestContract_AlertDeliveryDiagnosisPayloadShape(t *testing.T) { } } +func TestContract_ReportSchedulePayloadShape(t *testing.T) { + nextRun := time.Date(2026, 7, 8, 6, 0, 0, 0, time.UTC) + schedule := config.ReportSchedule{ + ID: "monthly-acme", + Name: "Acme monthly report", + Enabled: true, + Cadence: config.ReportScheduleCadence{ + Type: config.ReportScheduleCadenceMonthly, + DayOfMonth: 1, + Time: "06:00", + Timezone: "Europe/London", + }, + Scope: config.ReportScheduleScope{ + Resources: []config.ReportScheduleResource{{ + ResourceType: "agent", + ResourceID: "agent-1", + Name: "pve-a", + }}, + Tags: []string{"client:acme"}, + }, + Format: config.ReportScheduleFormatPDF, + Delivery: config.ReportScheduleDelivery{ + Method: config.ReportScheduleDeliveryEmail, + To: []string{"ops@example.com"}, + Attach: true, + SaveToDisk: true, + }, + RetentionCount: 12, + LastRunStatus: config.ReportScheduleLastRunOK, + NextRunAt: &nextRun, + CreatedAt: time.Date(2026, 7, 7, 6, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 7, 7, 6, 30, 0, 0, time.UTC), + } + + payload, err := json.Marshal(config.ReportScheduleStore{Schedules: []config.ReportSchedule{schedule}}) + if err != nil { + t.Fatalf("marshal report schedule contract: %v", err) + } + body := string(payload) + for _, field := range []string{ + `"schedules":[`, + `"id":"monthly-acme"`, + `"enabled":true`, + `"type":"monthly"`, + `"day_of_month":1`, + `"timezone":"Europe/London"`, + `"resourceType":"agent"`, + `"resourceId":"agent-1"`, + `"tags":["client:acme"]`, + `"format":"pdf"`, + `"method":"email"`, + `"to":["ops@example.com"]`, + `"attach":true`, + `"save_to_disk":true`, + `"retention_count":12`, + `"last_run_status":"ok"`, + `"next_run_at":"2026-07-08T06:00:00Z"`, + } { + if !strings.Contains(body, field) { + t.Fatalf("report schedule payload missing %s in %s", field, body) + } + } +} + func TestContract_PMGInstancesEndpointUsesUnifiedReadStatePayload(t *testing.T) { now := time.Now().UTC() source := models.PMGInstance{ diff --git a/internal/api/metrics_reporting_handlers.go b/internal/api/metrics_reporting_handlers.go index 8f73da02b..f371a2260 100644 --- a/internal/api/metrics_reporting_handlers.go +++ b/internal/api/metrics_reporting_handlers.go @@ -10,6 +10,7 @@ import ( "os" "regexp" "strings" + "sync" "time" "github.com/rcourtman/pulse-go-rewrite/internal/config" @@ -96,6 +97,7 @@ type ReportingHandlers struct { recoveryManager *recoverymanager.Manager narratorResolver func(ctx context.Context) (reporting.Narrator, reporting.FleetNarrator, reporting.FindingsProvider) settingsStore reportingSystemSettingsStore + scheduleRunMu sync.Mutex } type reportingSystemSettingsStore interface { @@ -946,39 +948,67 @@ type multiReportRequestBody struct { MetricType string `json:"metricType"` } -// HandleGenerateMultiReport generates a multi-resource report. -func (h *ReportingHandlers) HandleGenerateMultiReport(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } +type generatedMultiReport struct { + Data []byte + ContentType string + Filename string + Format reporting.ReportFormat + Start time.Time + End time.Time + ResourceCount int +} +type reportingRequestError struct { + status int + code string + message string +} + +func (e *reportingRequestError) Error() string { + if e == nil { + return "" + } + return e.message +} + +func writeReportingRequestError(w http.ResponseWriter, err error) bool { + var reqErr *reportingRequestError + if !errors.As(err, &reqErr) { + return false + } + writeErrorResponse(w, reqErr.status, reqErr.code, reqErr.message, nil) + return true +} + +func (h *ReportingHandlers) generateMultiReportFromBody(ctx context.Context, body multiReportRequestBody, now time.Time) (generatedMultiReport, error) { engine := reporting.GetEngine() if engine == nil { - writeErrorResponse(w, http.StatusInternalServerError, "engine_unavailable", "Reporting engine not initialized", nil) - return + return generatedMultiReport{}, &reportingRequestError{ + status: http.StatusInternalServerError, + code: "engine_unavailable", + message: "Reporting engine not initialized", + } } definition := performanceReportDefinition() - body, ok := decodeMultiReportRequestBody(w, r) - if !ok { - return - } - - // Validate resource count if len(body.Resources) == 0 { - writeErrorResponse(w, http.StatusBadRequest, "no_resources", "At least one resource is required", nil) - return + return generatedMultiReport{}, &reportingRequestError{ + status: http.StatusBadRequest, + code: "no_resources", + message: "At least one resource is required", + } } if len(body.Resources) > definition.MultiResourceMax { - writeErrorResponse(w, http.StatusBadRequest, "too_many_resources", fmt.Sprintf("Maximum %d resources allowed", definition.MultiResourceMax), nil) - return + return generatedMultiReport{}, &reportingRequestError{ + status: http.StatusBadRequest, + code: "too_many_resources", + message: fmt.Sprintf("Maximum %d resources allowed", definition.MultiResourceMax), + } } format, err := normalizePerformanceReportFormat(body.Format, definition) if err != nil { - writeErrorResponse(w, http.StatusBadRequest, "invalid_format", err.Error(), nil) - return + return generatedMultiReport{}, &reportingRequestError{status: http.StatusBadRequest, code: "invalid_format", message: err.Error()} } metricType, title, err := normalizePerformanceReportOptionalFields(definition, body.MetricType, body.Title) if err != nil { @@ -987,44 +1017,43 @@ func (h *ReportingHandlers) HandleGenerateMultiReport(w http.ResponseWriter, r * if errors.As(err, &validationErr) && validationErr.code != "" { code = validationErr.code } - writeErrorResponse(w, http.StatusBadRequest, code, err.Error(), nil) - return + return generatedMultiReport{}, &reportingRequestError{status: http.StatusBadRequest, code: code, message: err.Error()} } - start, end, err := normalizePerformanceReportTimeRange(definition, body.Start, body.End, time.Now()) + start, end, err := normalizePerformanceReportTimeRange(definition, body.Start, body.End, now) if err != nil { - writeErrorResponse(w, http.StatusBadRequest, "invalid_time_range", err.Error(), nil) - return + return generatedMultiReport{}, &reportingRequestError{status: http.StatusBadRequest, code: "invalid_time_range", message: err.Error()} } - // Build multi-report request multiReq := reporting.MultiReportRequest{ Format: format, Start: start, End: end, Title: title, MetricType: metricType, - Branding: h.resolveReportBranding(r.Context()), + Branding: h.resolveReportBranding(ctx), } - // Get monitor state for enrichment - orgID := GetOrgID(r.Context()) - snapshot, hasSnapshot := h.getReportingEnrichmentSnapshot(r.Context(), orgID) - - // Validate and build each resource request + orgID := GetOrgID(ctx) + snapshot, hasSnapshot := h.getReportingEnrichmentSnapshot(ctx, orgID) for _, res := range body.Resources { if !validResourceID.MatchString(res.ResourceType) || len(res.ResourceType) > 64 { - writeErrorResponse(w, http.StatusBadRequest, "invalid_resource_type", fmt.Sprintf("Invalid resourceType: %s", res.ResourceType), nil) - return + return generatedMultiReport{}, &reportingRequestError{ + status: http.StatusBadRequest, + code: "invalid_resource_type", + message: fmt.Sprintf("Invalid resourceType: %s", res.ResourceType), + } } if !validResourceID.MatchString(res.ResourceID) || len(res.ResourceID) > 128 { - writeErrorResponse(w, http.StatusBadRequest, "invalid_resource_id", fmt.Sprintf("Invalid resourceId: %s", res.ResourceID), nil) - return + return generatedMultiReport{}, &reportingRequestError{ + status: http.StatusBadRequest, + code: "invalid_resource_id", + message: fmt.Sprintf("Invalid resourceId: %s", res.ResourceID), + } } resourceType, err := normalizeReportResourceType(res.ResourceType) if err != nil { - writeErrorResponse(w, http.StatusBadRequest, "invalid_resource_type", err.Error(), nil) - return + return generatedMultiReport{}, &reportingRequestError{status: http.StatusBadRequest, code: "invalid_resource_type", message: err.Error()} } req := reporting.MetricReportRequest{ @@ -1037,34 +1066,25 @@ func (h *ReportingHandlers) HandleGenerateMultiReport(w http.ResponseWriter, r * Title: title, Branding: multiReq.Branding, } - - // Enrich with resource data if hasSnapshot { - h.enrichReportRequest(r.Context(), orgID, &req, snapshot, start, end) + h.enrichReportRequest(ctx, orgID, &req, snapshot, start, end) } - multiReq.Resources = append(multiReq.Resources, req) } - // Wire the per-tenant fleet narrator and Patrol findings provider - // when configured. The single-resource Narrator is intentionally - // not propagated to the multi path: a fleet PDF would otherwise - // trigger one AI call per resource. The fleet narrator handles - // cross-resource synthesis in a single call instead. - _, fleetNarrator, findings := h.resolveNarrator(r.Context()) + _, fleetNarrator, findings := h.resolveNarrator(ctx) multiReq.FleetNarrator = fleetNarrator multiReq.FindingsProvider = findings data, contentType, err := engine.GenerateMulti(multiReq) if err != nil { - writeErrorResponse(w, http.StatusInternalServerError, "generation_failed", "Failed to generate multi-resource report", nil) - return + return generatedMultiReport{}, &reportingRequestError{ + status: http.StatusInternalServerError, + code: "generation_failed", + message: "Failed to generate multi-resource report", + } } - // Telemetry: same shape as the single-resource path so usage of - // the two report shapes can be compared. resource_count is the - // caller's requested set (engine may skip individual resources on - // query failure; this number is the upper bound). log.Info(). Str("event", "reporting.fleet.generated"). Str("org_id", orgID). @@ -1078,10 +1098,39 @@ func (h *ReportingHandlers) HandleGenerateMultiReport(w http.ResponseWriter, r * Time("window_end", end). Msg("Reporting: fleet report generated") - w.Header().Set("Content-Type", contentType) - filename := definition.MultiAttachmentFilename(time.Now().UTC(), format) - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) - w.Write(data) + return generatedMultiReport{ + Data: data, + ContentType: contentType, + Filename: definition.MultiAttachmentFilename(now.UTC(), format), + Format: format, + Start: start, + End: end, + ResourceCount: len(multiReq.Resources), + }, nil +} + +// HandleGenerateMultiReport generates a multi-resource report. +func (h *ReportingHandlers) HandleGenerateMultiReport(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + body, ok := decodeMultiReportRequestBody(w, r) + if !ok { + return + } + + report, err := h.generateMultiReportFromBody(r.Context(), body, time.Now()) + if err != nil { + if !writeReportingRequestError(w, err) { + writeErrorResponse(w, http.StatusInternalServerError, "generation_failed", "Failed to generate multi-resource report", nil) + } + return + } + + w.Header().Set("Content-Type", report.ContentType) + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", report.Filename)) + w.Write(report.Data) } // enrichContainerReport adds container-specific data to the report request diff --git a/internal/api/rbac_reporting_auth_test.go b/internal/api/rbac_reporting_auth_test.go index 3748621f9..267b2c005 100644 --- a/internal/api/rbac_reporting_auth_test.go +++ b/internal/api/rbac_reporting_auth_test.go @@ -41,6 +41,11 @@ 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/export", body: ""}, + {method: http.MethodGet, path: "/api/admin/reports/schedules", body: ""}, + {method: http.MethodPost, path: "/api/admin/reports/schedules", body: `{}`}, + {method: http.MethodPut, path: "/api/admin/reports/schedules/schedule-1", body: `{}`}, + {method: http.MethodDelete, path: "/api/admin/reports/schedules/schedule-1", body: ""}, + {method: http.MethodPost, path: "/api/admin/reports/schedules/schedule-1/run", body: ""}, } for _, tc := range cases { diff --git a/internal/api/report_schedules.go b/internal/api/report_schedules.go new file mode 100644 index 000000000..4f236749a --- /dev/null +++ b/internal/api/report_schedules.go @@ -0,0 +1,851 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/google/uuid" + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/notifications" + "github.com/rcourtman/pulse-go-rewrite/internal/securityutil" + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" + "github.com/rcourtman/pulse-go-rewrite/pkg/reporting" + "github.com/rs/zerolog/log" +) + +const ( + reportScheduleAttachmentLimitBytes = 15 * 1024 * 1024 + reportScheduleTickInterval = time.Minute + reportScheduleMissedRunGrace = 24 * time.Hour +) + +type reportScheduleListResponse struct { + Schedules []config.ReportSchedule `json:"schedules"` +} + +type reportScheduleRunResponse struct { + Schedule config.ReportSchedule `json:"schedule"` + Status string `json:"status"` + Path string `json:"path,omitempty"` + Email string `json:"email,omitempty"` +} + +func (h *ReportingHandlers) HandleListReportSchedules(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + store, err := h.loadReportScheduleStore(r.Context()) + if err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "schedule_store_unavailable", "Report schedules are unavailable", nil) + return + } + writeReportScheduleJSON(w, http.StatusOK, reportScheduleListResponse{Schedules: store.Schedules}) +} + +func (h *ReportingHandlers) HandleCreateReportSchedule(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + var incoming config.ReportSchedule + if err := decodeReportScheduleBody(w, r, &incoming); err != nil { + return + } + + persistence, store, err := h.reportSchedulePersistenceAndStore(r.Context()) + if err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "schedule_store_unavailable", "Report schedules are unavailable", nil) + return + } + now := time.Now().UTC() + incoming.ID = uuid.NewString() + incoming.CreatedAt = now + incoming.UpdatedAt = now + schedule, err := h.prepareReportSchedule(r.Context(), incoming, now, false) + if err != nil { + writeReportScheduleValidationError(w, err) + return + } + store.Schedules = append(store.Schedules, schedule) + if err := persistence.SaveReportScheduleStore(*store); err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "schedule_save_failed", "Failed to save report schedule", nil) + return + } + writeReportScheduleJSON(w, http.StatusCreated, schedule) +} + +func (h *ReportingHandlers) HandleUpdateReportSchedule(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + scheduleID := strings.TrimSpace(r.PathValue("id")) + if scheduleID == "" { + writeErrorResponse(w, http.StatusBadRequest, "missing_schedule_id", "Schedule ID is required", nil) + return + } + var incoming config.ReportSchedule + if err := decodeReportScheduleBody(w, r, &incoming); err != nil { + return + } + + persistence, store, err := h.reportSchedulePersistenceAndStore(r.Context()) + if err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "schedule_store_unavailable", "Report schedules are unavailable", nil) + return + } + index := findReportScheduleIndex(store.Schedules, scheduleID) + if index < 0 { + writeErrorResponse(w, http.StatusNotFound, "schedule_not_found", "Report schedule not found", nil) + return + } + + now := time.Now().UTC() + existing := store.Schedules[index] + incoming.ID = existing.ID + incoming.CreatedAt = existing.CreatedAt + incoming.UpdatedAt = now + incoming.LastRunAt = existing.LastRunAt + incoming.LastRunStatus = existing.LastRunStatus + incoming.LastError = existing.LastError + incoming.LastOccurrenceKey = existing.LastOccurrenceKey + schedule, err := h.prepareReportSchedule(r.Context(), incoming, now, false) + if err != nil { + writeReportScheduleValidationError(w, err) + return + } + store.Schedules[index] = schedule + if err := persistence.SaveReportScheduleStore(*store); err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "schedule_save_failed", "Failed to save report schedule", nil) + return + } + writeReportScheduleJSON(w, http.StatusOK, schedule) +} + +func (h *ReportingHandlers) HandleDeleteReportSchedule(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + scheduleID := strings.TrimSpace(r.PathValue("id")) + if scheduleID == "" { + writeErrorResponse(w, http.StatusBadRequest, "missing_schedule_id", "Schedule ID is required", nil) + return + } + persistence, store, err := h.reportSchedulePersistenceAndStore(r.Context()) + if err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "schedule_store_unavailable", "Report schedules are unavailable", nil) + return + } + index := findReportScheduleIndex(store.Schedules, scheduleID) + if index < 0 { + writeErrorResponse(w, http.StatusNotFound, "schedule_not_found", "Report schedule not found", nil) + return + } + store.Schedules = append(store.Schedules[:index], store.Schedules[index+1:]...) + if err := persistence.SaveReportScheduleStore(*store); err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "schedule_save_failed", "Failed to save report schedule", nil) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *ReportingHandlers) HandleRunReportSchedule(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + scheduleID := strings.TrimSpace(r.PathValue("id")) + if scheduleID == "" { + writeErrorResponse(w, http.StatusBadRequest, "missing_schedule_id", "Schedule ID is required", nil) + return + } + persistence, store, err := h.reportSchedulePersistenceAndStore(r.Context()) + if err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "schedule_store_unavailable", "Report schedules are unavailable", nil) + return + } + index := findReportScheduleIndex(store.Schedules, scheduleID) + if index < 0 { + writeErrorResponse(w, http.StatusNotFound, "schedule_not_found", "Report schedule not found", nil) + return + } + + result, updated := h.runReportSchedule(r.Context(), persistence, store.Schedules[index], time.Now().UTC(), true, "") + store.Schedules[index] = updated + if err := persistence.SaveReportScheduleStore(*store); err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "schedule_save_failed", "Failed to save report schedule status", nil) + return + } + status := http.StatusOK + if updated.LastRunStatus == config.ReportScheduleLastRunFailed { + status = http.StatusInternalServerError + } + writeReportScheduleJSON(w, status, reportScheduleRunResponse{ + Schedule: updated, + Status: updated.LastRunStatus, + Path: result.path, + Email: result.email, + }) +} + +func decodeReportScheduleBody(w http.ResponseWriter, r *http.Request, target *config.ReportSchedule) error { + r.Body = http.MaxBytesReader(w, r.Body, reportingMultiReportBodyMax) + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + writeErrorResponse(w, http.StatusBadRequest, "invalid_body", "Invalid report schedule body", nil) + return err + } + if err := decoder.Decode(&struct{}{}); err != nil && !errors.Is(err, io.EOF) { + writeErrorResponse(w, http.StatusBadRequest, "invalid_body", "Invalid report schedule body", nil) + return err + } + return nil +} + +func writeReportScheduleJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(payload) +} + +type reportScheduleValidationError struct { + code string + message string +} + +func (e reportScheduleValidationError) Error() string { + return e.message +} + +func writeReportScheduleValidationError(w http.ResponseWriter, err error) { + var validationErr reportScheduleValidationError + if errors.As(err, &validationErr) { + writeErrorResponse(w, http.StatusBadRequest, validationErr.code, validationErr.message, nil) + return + } + writeErrorResponse(w, http.StatusBadRequest, "invalid_schedule", err.Error(), nil) +} + +func (h *ReportingHandlers) reportSchedulePersistenceAndStore(ctx context.Context) (*config.ConfigPersistence, *config.ReportScheduleStore, error) { + persistence, err := h.reportSchedulePersistence(ctx) + if err != nil { + return nil, nil, err + } + store, err := persistence.LoadReportScheduleStore() + if err != nil { + return nil, nil, err + } + if store == nil { + empty := config.EmptyReportScheduleStore() + store = &empty + } + return persistence, store, nil +} + +func (h *ReportingHandlers) loadReportScheduleStore(ctx context.Context) (*config.ReportScheduleStore, error) { + _, store, err := h.reportSchedulePersistenceAndStore(ctx) + return store, err +} + +func (h *ReportingHandlers) reportSchedulePersistence(ctx context.Context) (*config.ConfigPersistence, error) { + if h == nil || h.mtMonitor == 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) + if err != nil { + return nil, err + } + persistence := monitor.GetConfigPersistence() + if persistence == nil { + return nil, fmt.Errorf("config persistence is not configured") + } + return persistence, nil +} + +func findReportScheduleIndex(schedules []config.ReportSchedule, id string) int { + for i := range schedules { + if schedules[i].ID == id { + return i + } + } + return -1 +} + +func (h *ReportingHandlers) prepareReportSchedule(ctx context.Context, schedule config.ReportSchedule, now time.Time, statusOnly bool) (config.ReportSchedule, error) { + schedule = config.NormalizeReportSchedule(schedule) + if schedule.ID == "" { + return schedule, reportScheduleValidationError{code: "invalid_schedule_id", message: "Schedule ID is required"} + } + if statusOnly { + return schedule, nil + } + if schedule.Name == "" || len(schedule.Name) > 80 { + return schedule, reportScheduleValidationError{code: "invalid_name", message: "Schedule name is required and must be 80 characters or fewer"} + } + if schedule.Format == "" { + schedule.Format = config.ReportScheduleFormatPDF + } + if schedule.Format != config.ReportScheduleFormatPDF && schedule.Format != config.ReportScheduleFormatCSV { + return schedule, reportScheduleValidationError{code: "invalid_format", message: "Schedule format must be pdf or csv"} + } + if schedule.Delivery.Method == "" { + schedule.Delivery.Method = config.ReportScheduleDeliveryEmail + } + if schedule.Delivery.Method != config.ReportScheduleDeliveryEmail && schedule.Delivery.Method != config.ReportScheduleDeliveryDisk { + return schedule, reportScheduleValidationError{code: "invalid_delivery", message: "Delivery method must be email or disk"} + } + if !schedule.Delivery.Attach && !schedule.Delivery.SaveToDisk { + schedule.Delivery.Attach = true + schedule.Delivery.SaveToDisk = true + } + if err := validateReportScheduleCadence(schedule.Cadence); err != nil { + return schedule, err + } + if err := h.validateReportScheduleScope(ctx, schedule.Scope); err != nil { + return schedule, err + } + next, err := nextReportScheduleRunAt(schedule, now) + if err != nil { + return schedule, err + } + schedule.NextRunAt = &next + return schedule, nil +} + +func validateReportScheduleCadence(cadence config.ReportScheduleCadence) error { + if cadence.Type != config.ReportScheduleCadenceMonthly && cadence.Type != config.ReportScheduleCadenceWeekly { + return reportScheduleValidationError{code: "invalid_cadence", message: "Cadence must be monthly or weekly"} + } + if cadence.Type == config.ReportScheduleCadenceMonthly && (cadence.DayOfMonth < 1 || cadence.DayOfMonth > 28) { + return reportScheduleValidationError{code: "invalid_day_of_month", message: "Monthly schedules must use a day from 1 to 28"} + } + if cadence.Type == config.ReportScheduleCadenceWeekly { + if _, ok := parseReportScheduleWeekday(cadence.Weekday); !ok { + return reportScheduleValidationError{code: "invalid_weekday", message: "Weekly schedules must include a weekday"} + } + } + if _, err := time.Parse("15:04", cadence.Time); err != nil { + return reportScheduleValidationError{code: "invalid_time", message: "Schedule time must use HH:MM"} + } + if _, err := reportScheduleLocation(cadence.Timezone); err != nil { + return reportScheduleValidationError{code: "invalid_timezone", message: "Schedule timezone must be a valid IANA timezone"} + } + return nil +} + +func (h *ReportingHandlers) validateReportScheduleScope(ctx context.Context, scope config.ReportScheduleScope) error { + if len(scope.Resources) == 0 && len(scope.Tags) == 0 { + return reportScheduleValidationError{code: "invalid_scope", message: "Select at least one resource or tag"} + } + definition := performanceReportDefinition() + if len(scope.Resources) > definition.MultiResourceMax { + return reportScheduleValidationError{code: "too_many_resources", message: fmt.Sprintf("Maximum %d resources allowed", definition.MultiResourceMax)} + } + for _, res := range scope.Resources { + if !validResourceID.MatchString(res.ResourceID) || len(res.ResourceID) > 128 { + return reportScheduleValidationError{code: "invalid_resource_id", message: "Resource IDs must match [a-zA-Z0-9._:-]+ and be 128 characters or fewer"} + } + if _, err := normalizeReportResourceType(res.ResourceType); err != nil { + return reportScheduleValidationError{code: "invalid_resource_type", message: err.Error()} + } + } + for _, tag := range scope.Tags { + if len(tag) > 64 || strings.ContainsAny(tag, "\r\n\t") { + return reportScheduleValidationError{code: "invalid_tag", message: "Tags must be 64 characters or fewer and cannot contain control characters"} + } + } + if len(scope.Tags) > 0 { + if _, err := h.resolveReportScheduleResources(ctx, GetOrgID(ctx), scope); err != nil { + return err + } + } + return nil +} + +func (h *ReportingHandlers) resolveReportScheduleResources(ctx context.Context, orgID string, scope config.ReportScheduleScope) ([]multiReportResourceEntry, error) { + definition := performanceReportDefinition() + entries := make([]multiReportResourceEntry, 0, len(scope.Resources)) + seen := map[string]struct{}{} + appendEntry := func(resourceType, resourceID string) error { + resourceType, err := normalizeReportResourceType(resourceType) + if err != nil { + return err + } + key := resourceType + "\x00" + resourceID + if _, exists := seen[key]; exists { + return nil + } + seen[key] = struct{}{} + entries = append(entries, multiReportResourceEntry{ResourceType: resourceType, ResourceID: resourceID}) + return nil + } + for _, res := range scope.Resources { + if err := appendEntry(res.ResourceType, res.ResourceID); err != nil { + return nil, reportScheduleValidationError{code: "invalid_resource_type", message: err.Error()} + } + } + + if len(scope.Tags) > 0 { + snapshot, ok := h.getReportingEnrichmentSnapshot(ctx, orgID) + if !ok { + return nil, reportScheduleValidationError{code: "scope_unavailable", message: "Tagged schedule scope cannot be resolved until resource inventory is available"} + } + tags := map[string]struct{}{} + for _, tag := range scope.Tags { + tags[strings.ToLower(strings.TrimSpace(tag))] = struct{}{} + } + for _, resource := range snapshot.Resources { + if !resourceHasAnyReportScheduleTag(resource, tags) { + continue + } + resourceType := reporting.CanonicalResourceType(string(resource.Type)) + if resourceType == "" || strings.TrimSpace(resource.ID) == "" { + continue + } + if err := appendEntry(resourceType, resource.ID); err != nil { + continue + } + } + } + if len(entries) == 0 { + return nil, reportScheduleValidationError{code: "empty_scope", message: "Schedule scope did not match any reportable resources"} + } + if len(entries) > definition.MultiResourceMax { + return nil, reportScheduleValidationError{code: "too_many_resources", message: fmt.Sprintf("Maximum %d resources allowed", definition.MultiResourceMax)} + } + return entries, nil +} + +func resourceHasAnyReportScheduleTag(resource unifiedresources.Resource, tags map[string]struct{}) bool { + for _, tag := range resource.Tags { + if _, ok := tags[strings.ToLower(strings.TrimSpace(tag))]; ok { + return true + } + } + return false +} + +type reportScheduleRunResult struct { + path string + email string +} + +func (h *ReportingHandlers) runReportSchedule(ctx context.Context, persistence *config.ConfigPersistence, schedule config.ReportSchedule, now time.Time, manual bool, occurrenceKey string) (reportScheduleRunResult, config.ReportSchedule) { + h.scheduleRunMu.Lock() + defer h.scheduleRunMu.Unlock() + + schedule = config.NormalizeReportSchedule(schedule) + result := reportScheduleRunResult{} + schedule.LastRunAt = &now + schedule.UpdatedAt = now + if occurrenceKey != "" { + schedule.LastOccurrenceKey = occurrenceKey + } + if next, err := nextReportScheduleRunAt(schedule, now); err == nil { + schedule.NextRunAt = &next + } + + orgID := GetOrgID(ctx) + resources, err := h.resolveReportScheduleResources(ctx, orgID, schedule.Scope) + if err != nil { + return result, markReportScheduleFailed(schedule, now, err) + } + start, end, err := reportScheduleWindow(schedule, now, 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) + if err != nil { + return result, markReportScheduleFailed(schedule, now, err) + } + + path, err := saveGeneratedReport(persistence, schedule, report) + if err != nil { + return result, markReportScheduleFailed(schedule, now, err) + } + result.path = path + + if schedule.Delivery.Method == config.ReportScheduleDeliveryEmail { + emailStatus, err := sendScheduledReportEmail(persistence, schedule, report, path) + if err != nil { + return result, markReportScheduleFailed(schedule, now, err) + } + result.email = emailStatus + } + + schedule.LastRunStatus = config.ReportScheduleLastRunOK + schedule.LastError = "" + return result, schedule +} + +func markReportScheduleFailed(schedule config.ReportSchedule, now time.Time, err error) config.ReportSchedule { + schedule.LastRunStatus = config.ReportScheduleLastRunFailed + schedule.LastError = strings.TrimSpace(err.Error()) + schedule.LastRunAt = &now + schedule.UpdatedAt = now + return schedule +} + +func reportScheduleWindow(schedule config.ReportSchedule, now time.Time, manual bool) (time.Time, time.Time, error) { + location, err := reportScheduleLocation(schedule.Cadence.Timezone) + if err != nil { + return time.Time{}, time.Time{}, err + } + end := now.In(location) + if !manual { + occurrence, _, err := lastReportScheduleOccurrenceAt(schedule, now) + if err == nil { + end = occurrence.In(location) + } + } + switch schedule.Cadence.Type { + case config.ReportScheduleCadenceMonthly: + firstThisMonth := time.Date(end.Year(), end.Month(), 1, 0, 0, 0, 0, location) + firstPreviousMonth := firstThisMonth.AddDate(0, -1, 0) + return firstPreviousMonth.UTC(), firstThisMonth.UTC(), nil + case config.ReportScheduleCadenceWeekly: + return end.AddDate(0, 0, -7).UTC(), end.UTC(), nil + default: + return time.Time{}, time.Time{}, fmt.Errorf("unsupported cadence %q", schedule.Cadence.Type) + } +} + +func saveGeneratedReport(persistence *config.ConfigPersistence, schedule config.ReportSchedule, report generatedMultiReport) (string, error) { + baseDir, err := securityutil.JoinStorageLeaf(persistence.DataDir(), "reports") + if err != nil { + return "", err + } + generatedDir, err := securityutil.JoinStorageLeaf(baseDir, "generated") + if err != nil { + return "", err + } + scheduleDir, err := securityutil.JoinStorageLeaf(generatedDir, schedule.ID) + if err != nil { + return "", err + } + if err := os.MkdirAll(scheduleDir, 0o700); err != nil { + return "", fmt.Errorf("create report output directory: %w", err) + } + filename := sanitizeFilename(report.Filename) + if filename == "" { + filename = "report." + string(report.Format) + } + path := filepath.Join(scheduleDir, filename) + if err := os.WriteFile(path, report.Data, 0o600); err != nil { + return "", fmt.Errorf("write generated report: %w", err) + } + pruneGeneratedReports(scheduleDir, schedule.RetentionCount) + return path, nil +} + +func pruneGeneratedReports(dir string, retentionCount int) { + if retentionCount <= 0 { + retentionCount = config.DefaultReportScheduleRetentionCount + } + entries, err := os.ReadDir(dir) + if err != nil { + return + } + type generatedFile struct { + path string + modTime time.Time + } + files := make([]generatedFile, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() { + continue + } + info, err := entry.Info() + if err != nil { + continue + } + files = append(files, generatedFile{path: filepath.Join(dir, entry.Name()), modTime: info.ModTime()}) + } + sort.Slice(files, func(i, j int) bool { + return files[i].modTime.After(files[j].modTime) + }) + for i := retentionCount; i < len(files); i++ { + _ = os.Remove(files[i].path) + } +} + +func sendScheduledReportEmail(persistence *config.ConfigPersistence, schedule config.ReportSchedule, report generatedMultiReport, path string) (string, error) { + emailCfg, err := persistence.LoadEmailConfig() + if err != nil { + return "", fmt.Errorf("load email config: %w", err) + } + if emailCfg == nil || !emailCfg.Enabled { + return "saved_to_disk_email_unconfigured", nil + } + recipients := schedule.Delivery.To + if len(recipients) == 0 { + recipients = emailCfg.To + } + if len(recipients) == 0 && strings.TrimSpace(emailCfg.From) != "" { + recipients = []string{emailCfg.From} + } + if len(recipients) == 0 { + return "saved_to_disk_email_unconfigured", nil + } + + providerConfig := notifications.EmailProviderConfig{ + EmailConfig: notifications.EmailConfig{ + Provider: emailCfg.Provider, + From: emailCfg.From, + To: recipients, + SMTPHost: emailCfg.SMTPHost, + SMTPPort: emailCfg.SMTPPort, + Username: emailCfg.Username, + Password: emailCfg.Password, + TLS: emailCfg.TLS, + StartTLS: emailCfg.StartTLS, + RateLimit: emailCfg.RateLimit, + }, + Provider: emailCfg.Provider, + MaxRetries: 2, + RetryDelay: 3, + RateLimit: emailCfg.RateLimit, + StartTLS: emailCfg.StartTLS, + AuthRequired: emailCfg.Username != "" && emailCfg.Password != "", + } + manager := notifications.NewEnhancedEmailManager(providerConfig) + subject := "Pulse report: " + schedule.Name + htmlBody := "

Your scheduled Pulse report is ready.

" + textBody := "Your scheduled Pulse report is ready." + attachments := []notifications.EmailAttachment{} + if schedule.Delivery.Attach && len(report.Data) <= reportScheduleAttachmentLimitBytes { + attachments = append(attachments, notifications.EmailAttachment{ + Filename: report.Filename, + ContentType: report.ContentType, + Data: report.Data, + }) + } else if schedule.Delivery.Attach && len(report.Data) > reportScheduleAttachmentLimitBytes { + htmlBody = "

Your scheduled Pulse report was generated but is too large to attach.

Saved path: " + htmlEscape(path) + "

" + textBody = "Your scheduled Pulse report was generated but is too large to attach.\nSaved path: " + path + } else { + htmlBody = "

Your scheduled Pulse report was generated and saved to disk.

Saved path: " + htmlEscape(path) + "

" + textBody = "Your scheduled Pulse report was generated and saved to disk.\nSaved path: " + path + } + if err := manager.SendEmailWithAttachments(subject, htmlBody, textBody, attachments); err != nil { + return "", err + } + if len(attachments) > 0 { + return "sent_with_attachment", nil + } + return "sent_saved_path", nil +} + +func htmlEscape(value string) string { + replacer := strings.NewReplacer("&", "&", "<", "<", ">", ">", "\"", """, "'", "'") + return replacer.Replace(value) +} + +func reportScheduleLocation(name string) (*time.Location, error) { + name = strings.TrimSpace(name) + if name == "" || name == "Local" { + name = "UTC" + } + return time.LoadLocation(name) +} + +func parseReportScheduleClock(clock string) (hour, minute int, err error) { + parsed, err := time.Parse("15:04", strings.TrimSpace(clock)) + if err != nil { + return 0, 0, err + } + return parsed.Hour(), parsed.Minute(), nil +} + +func parseReportScheduleWeekday(value string) (time.Weekday, bool) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "sunday", "sun": + return time.Sunday, true + case "monday", "mon": + return time.Monday, true + case "tuesday", "tue": + return time.Tuesday, true + case "wednesday", "wed": + return time.Wednesday, true + case "thursday", "thu": + return time.Thursday, true + case "friday", "fri": + return time.Friday, true + case "saturday", "sat": + return time.Saturday, true + default: + return time.Sunday, false + } +} + +func nextReportScheduleRunAt(schedule config.ReportSchedule, after time.Time) (time.Time, error) { + location, err := reportScheduleLocation(schedule.Cadence.Timezone) + if err != nil { + return time.Time{}, err + } + hour, minute, err := parseReportScheduleClock(schedule.Cadence.Time) + if err != nil { + return time.Time{}, err + } + localAfter := after.In(location) + switch schedule.Cadence.Type { + case config.ReportScheduleCadenceMonthly: + candidate := time.Date(localAfter.Year(), localAfter.Month(), schedule.Cadence.DayOfMonth, hour, minute, 0, 0, location) + if !candidate.After(localAfter) { + candidate = candidate.AddDate(0, 1, 0) + } + return candidate.UTC(), nil + case config.ReportScheduleCadenceWeekly: + weekday, ok := parseReportScheduleWeekday(schedule.Cadence.Weekday) + if !ok { + return time.Time{}, fmt.Errorf("invalid weekday") + } + dayDelta := (int(weekday) - int(localAfter.Weekday()) + 7) % 7 + candidateDate := localAfter.AddDate(0, 0, dayDelta) + candidate := time.Date(candidateDate.Year(), candidateDate.Month(), candidateDate.Day(), hour, minute, 0, 0, location) + if !candidate.After(localAfter) { + candidate = candidate.AddDate(0, 0, 7) + } + return candidate.UTC(), nil + default: + return time.Time{}, fmt.Errorf("unsupported cadence %q", schedule.Cadence.Type) + } +} + +func lastReportScheduleOccurrenceAt(schedule config.ReportSchedule, now time.Time) (time.Time, string, error) { + location, err := reportScheduleLocation(schedule.Cadence.Timezone) + if err != nil { + return time.Time{}, "", err + } + hour, minute, err := parseReportScheduleClock(schedule.Cadence.Time) + if err != nil { + return time.Time{}, "", err + } + localNow := now.In(location) + var occurrence time.Time + switch schedule.Cadence.Type { + case config.ReportScheduleCadenceMonthly: + occurrence = time.Date(localNow.Year(), localNow.Month(), schedule.Cadence.DayOfMonth, hour, minute, 0, 0, location) + if occurrence.After(localNow) { + occurrence = occurrence.AddDate(0, -1, 0) + } + case config.ReportScheduleCadenceWeekly: + weekday, ok := parseReportScheduleWeekday(schedule.Cadence.Weekday) + if !ok { + return time.Time{}, "", fmt.Errorf("invalid weekday") + } + dayDelta := (int(localNow.Weekday()) - int(weekday) + 7) % 7 + occurrenceDate := localNow.AddDate(0, 0, -dayDelta) + occurrence = time.Date(occurrenceDate.Year(), occurrenceDate.Month(), occurrenceDate.Day(), hour, minute, 0, 0, location) + if occurrence.After(localNow) { + occurrence = occurrence.AddDate(0, 0, -7) + } + default: + return time.Time{}, "", fmt.Errorf("unsupported cadence %q", schedule.Cadence.Type) + } + return occurrence.UTC(), occurrenceKey(schedule, occurrence), nil +} + +func occurrenceKey(schedule config.ReportSchedule, occurrence time.Time) string { + timezone := strings.TrimSpace(schedule.Cadence.Timezone) + if timezone == "" { + timezone = "UTC" + } + return schedule.Cadence.Type + ":" + occurrence.UTC().Format("2006-01-02T15:04:05Z07:00") + ":" + timezone +} + +func (h *ReportingHandlers) RunReportScheduleScheduler(ctx context.Context) { + if h == nil { + return + } + h.runDueReportSchedules(ctx, time.Now().UTC()) + ticker := time.NewTicker(reportScheduleTickInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case now := <-ticker.C: + h.runDueReportSchedules(ctx, now.UTC()) + } + } +} + +func (h *ReportingHandlers) runDueReportSchedules(ctx context.Context, now time.Time) { + if h == nil || h.mtMonitor == nil { + return + } + orgIDs, err := h.mtMonitor.ListOrganizationIDs() + if err != nil { + log.Warn().Err(err).Msg("report schedules: list organizations") + return + } + for _, orgID := range orgIDs { + orgCtx := context.WithValue(ctx, OrgIDContextKey, orgID) + h.runDueReportSchedulesForOrg(orgCtx, orgID, now) + } +} + +func (h *ReportingHandlers) runDueReportSchedulesForOrg(ctx context.Context, orgID string, now time.Time) { + persistence, store, err := h.reportSchedulePersistenceAndStore(ctx) + if err != nil { + log.Warn().Err(err).Str("org_id", orgID).Msg("report schedules: load store") + return + } + changed := false + for i := range store.Schedules { + schedule := config.NormalizeReportSchedule(store.Schedules[i]) + if !schedule.Enabled { + continue + } + occurrence, key, err := lastReportScheduleOccurrenceAt(schedule, now) + if err != nil { + store.Schedules[i] = markReportScheduleFailed(schedule, now, err) + changed = true + continue + } + if key == "" || key == schedule.LastOccurrenceKey || occurrence.After(now) { + continue + } + if now.Sub(occurrence) > reportScheduleMissedRunGrace { + schedule.LastOccurrenceKey = key + if next, err := nextReportScheduleRunAt(schedule, now); err == nil { + schedule.NextRunAt = &next + } + schedule.UpdatedAt = now + store.Schedules[i] = schedule + changed = true + continue + } + _, updated := h.runReportSchedule(ctx, persistence, schedule, now, false, key) + store.Schedules[i] = updated + changed = true + } + if changed { + if err := persistence.SaveReportScheduleStore(*store); err != nil { + log.Warn().Err(err).Str("org_id", orgID).Msg("report schedules: save statuses") + } + } +} diff --git a/internal/api/report_schedules_test.go b/internal/api/report_schedules_test.go new file mode 100644 index 000000000..b26391e36 --- /dev/null +++ b/internal/api/report_schedules_test.go @@ -0,0 +1,172 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" + "github.com/rcourtman/pulse-go-rewrite/pkg/reporting" +) + +func newTestReportingScheduleHandlers(t *testing.T) (*ReportingHandlers, *config.ConfigPersistence) { + t.Helper() + baseDir := t.TempDir() + mtp := config.NewMultiTenantPersistence(baseDir) + mtm := monitoring.NewMultiTenantMonitor(&config.Config{DataPath: baseDir}, mtp, nil) + t.Cleanup(mtm.Stop) + monitor, err := mtm.GetMonitor("default") + if err != nil { + t.Fatalf("GetMonitor: %v", err) + } + persistence := monitor.GetConfigPersistence() + if persistence == nil { + t.Fatal("monitor config persistence is nil") + } + return NewReportingHandlers(mtm, nil), persistence +} + +func validReportSchedulePayload() config.ReportSchedule { + return config.ReportSchedule{ + Name: "Monthly client report", + Enabled: true, + Cadence: config.ReportScheduleCadence{ + Type: config.ReportScheduleCadenceMonthly, + DayOfMonth: 1, + Time: "09:00", + Timezone: "UTC", + }, + Scope: config.ReportScheduleScope{ + Resources: []config.ReportScheduleResource{{ResourceType: "vm", ResourceID: "vm-1", Name: "VM 1"}}, + }, + Format: config.ReportScheduleFormatPDF, + Delivery: config.ReportScheduleDelivery{ + Method: config.ReportScheduleDeliveryDisk, + Attach: false, + SaveToDisk: true, + }, + RetentionCount: 12, + } +} + +func encodeScheduleBody(t *testing.T, schedule config.ReportSchedule) *bytes.Reader { + t.Helper() + data, err := json.Marshal(schedule) + if err != nil { + t.Fatalf("marshal schedule: %v", err) + } + return bytes.NewReader(data) +} + +func TestReportScheduleHandlersPersistCRUD(t *testing.T) { + handlers, persistence := newTestReportingScheduleHandlers(t) + ctx := context.WithValue(context.Background(), OrgIDContextKey, "default") + + createReq := httptest.NewRequest(http.MethodPost, "/api/admin/reports/schedules", encodeScheduleBody(t, validReportSchedulePayload())).WithContext(ctx) + createRec := httptest.NewRecorder() + handlers.HandleCreateReportSchedule(createRec, createReq) + if createRec.Code != http.StatusCreated { + t.Fatalf("create status = %d, want %d body=%q", createRec.Code, http.StatusCreated, createRec.Body.String()) + } + var created config.ReportSchedule + if err := json.NewDecoder(createRec.Body).Decode(&created); err != nil { + t.Fatalf("decode create: %v", err) + } + if created.ID == "" { + t.Fatal("created schedule ID is empty") + } + + stored, err := persistence.LoadReportScheduleStore() + if err != nil { + t.Fatalf("LoadReportScheduleStore: %v", err) + } + if len(stored.Schedules) != 1 || stored.Schedules[0].ID != created.ID { + t.Fatalf("stored schedules = %+v, want created schedule", stored.Schedules) + } + + listReq := httptest.NewRequest(http.MethodGet, "/api/admin/reports/schedules", nil).WithContext(ctx) + listRec := httptest.NewRecorder() + handlers.HandleListReportSchedules(listRec, listReq) + if listRec.Code != http.StatusOK { + t.Fatalf("list status = %d, want %d body=%q", listRec.Code, http.StatusOK, listRec.Body.String()) + } + var list reportScheduleListResponse + if err := json.NewDecoder(listRec.Body).Decode(&list); err != nil { + t.Fatalf("decode list: %v", err) + } + if len(list.Schedules) != 1 || list.Schedules[0].ID != created.ID { + t.Fatalf("list schedules = %+v, want created schedule", list.Schedules) + } + + created.Enabled = false + created.Name = "Updated monthly report" + updateReq := httptest.NewRequest(http.MethodPut, "/api/admin/reports/schedules/"+created.ID, encodeScheduleBody(t, created)).WithContext(ctx) + updateReq.SetPathValue("id", created.ID) + updateRec := httptest.NewRecorder() + handlers.HandleUpdateReportSchedule(updateRec, updateReq) + if updateRec.Code != http.StatusOK { + t.Fatalf("update status = %d, want %d body=%q", updateRec.Code, http.StatusOK, updateRec.Body.String()) + } + var updated config.ReportSchedule + if err := json.NewDecoder(updateRec.Body).Decode(&updated); err != nil { + t.Fatalf("decode update: %v", err) + } + if updated.Enabled || updated.Name != "Updated monthly report" { + t.Fatalf("updated schedule = %+v, want disabled updated name", updated) + } + + deleteReq := httptest.NewRequest(http.MethodDelete, "/api/admin/reports/schedules/"+created.ID, nil).WithContext(ctx) + deleteReq.SetPathValue("id", created.ID) + deleteRec := httptest.NewRecorder() + handlers.HandleDeleteReportSchedule(deleteRec, deleteReq) + if deleteRec.Code != http.StatusNoContent { + t.Fatalf("delete status = %d, want %d body=%q", deleteRec.Code, http.StatusNoContent, deleteRec.Body.String()) + } + stored, err = persistence.LoadReportScheduleStore() + if err != nil { + t.Fatalf("LoadReportScheduleStore after delete: %v", err) + } + if len(stored.Schedules) != 0 { + t.Fatalf("stored schedules after delete = %+v, want empty", stored.Schedules) + } +} + +func TestRunReportSchedulePersistsFailureStatusWhenEngineUnavailable(t *testing.T) { + original := reporting.GetEngine() + reporting.SetEngine(nil) + t.Cleanup(func() { reporting.SetEngine(original) }) + + handlers, persistence := newTestReportingScheduleHandlers(t) + ctx := context.WithValue(context.Background(), OrgIDContextKey, "default") + schedule := validReportSchedulePayload() + schedule.ID = "schedule-run-failure" + if err := persistence.SaveReportScheduleStore(config.ReportScheduleStore{Schedules: []config.ReportSchedule{schedule}}); err != nil { + t.Fatalf("SaveReportScheduleStore: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/admin/reports/schedules/schedule-run-failure/run", nil).WithContext(ctx) + req.SetPathValue("id", "schedule-run-failure") + rec := httptest.NewRecorder() + handlers.HandleRunReportSchedule(rec, req) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("run status = %d, want %d body=%q", rec.Code, http.StatusInternalServerError, rec.Body.String()) + } + + stored, err := persistence.LoadReportScheduleStore() + if err != nil { + t.Fatalf("LoadReportScheduleStore: %v", err) + } + if len(stored.Schedules) != 1 { + t.Fatalf("stored schedule len = %d, want 1", len(stored.Schedules)) + } + if stored.Schedules[0].LastRunStatus != config.ReportScheduleLastRunFailed { + t.Fatalf("last_run_status = %q, want failed", stored.Schedules[0].LastRunStatus) + } + if stored.Schedules[0].LastRunAt == nil || stored.Schedules[0].LastError == "" { + t.Fatalf("run status fields = lastRunAt %v lastError %q, want populated", stored.Schedules[0].LastRunAt, stored.Schedules[0].LastError) + } +} diff --git a/internal/api/route_inventory_test.go b/internal/api/route_inventory_test.go index d74454d95..5bd343a3e 100644 --- a/internal/api/route_inventory_test.go +++ b/internal/api/route_inventory_test.go @@ -543,6 +543,11 @@ var allRouteAllowlist = []string{ "/api/admin/reports/generate-multi", "/api/admin/reports/catalog", "/api/admin/reports/inventory/vms/export", + "GET /api/admin/reports/schedules", + "POST /api/admin/reports/schedules", + "PUT /api/admin/reports/schedules/{id}", + "DELETE /api/admin/reports/schedules/{id}", + "POST /api/admin/reports/schedules/{id}/run", "/api/admin/webhooks/audit", "/api/security/change-password", "/api/security/dev/reset-first-run", diff --git a/internal/api/router.go b/internal/api/router.go index 42bfc4110..8a0dc7add 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -1261,6 +1261,11 @@ func (r *Router) StartBackgroundWorkers() { r.startLifecycleWorker(func() { r.backgroundUpdateChecker(r.lifecycleCtx) }) + if r.reportingHandlers != nil { + r.startLifecycleWorker(func() { + r.reportingHandlers.RunReportScheduleScheduler(r.lifecycleCtx) + }) + } }) } diff --git a/internal/api/router_routes_licensing.go b/internal/api/router_routes_licensing.go index 3970884ae..ddfa57a16 100644 --- a/internal/api/router_routes_licensing.go +++ b/internal/api/router_routes_licensing.go @@ -131,6 +131,10 @@ func (r *Router) registerOrgLicenseRoutesGroup(orgHandlers *OrgHandlers, rbacHan reportingAdminEndpointAdapter{handlers: r.reportingHandlers}, newReportingAdminRuntime(r.reportingHandlers), ) + var reportingScheduleEndpoints extensions.ReportingScheduleAdminEndpoints = reportingAdminEndpointAdapter{handlers: r.reportingHandlers} + if scheduleEndpoints, ok := reportingAdminEndpoints.(extensions.ReportingScheduleAdminEndpoints); ok { + reportingScheduleEndpoints = scheduleEndpoints + } // RBAC admin operations (Enterprise feature) r.mux.HandleFunc("GET /api/admin/rbac/integrity", RequirePermission(r.config, r.authorizer, auth.ActionAdmin, auth.ResourceUsers, func(w http.ResponseWriter, req *http.Request) { if !ensureAdminSession(r.config, w, req) { @@ -170,6 +174,36 @@ func (r *Router) registerOrgLicenseRoutesGroup(orgHandlers *OrgHandlers, rbacHan } RequireLicenseFeature(r.licenseHandlers, featureAdvancedReportingValue, RequireScope(config.ScopeSettingsRead, reportingAdminEndpoints.HandleExportVMInventory))(w, req) })) + r.mux.HandleFunc("GET /api/admin/reports/schedules", 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, reportingScheduleEndpoints.HandleListReportSchedules))(w, req) + })) + r.mux.HandleFunc("POST /api/admin/reports/schedules", RequirePermission(r.config, r.authorizer, auth.ActionAdmin, auth.ResourceNodes, func(w http.ResponseWriter, req *http.Request) { + if !ensureAdminSession(r.config, w, req) { + return + } + RequireLicenseFeature(r.licenseHandlers, featureAdvancedReportingValue, RequireScope(config.ScopeSettingsWrite, reportingScheduleEndpoints.HandleCreateReportSchedule))(w, req) + })) + r.mux.HandleFunc("PUT /api/admin/reports/schedules/{id}", RequirePermission(r.config, r.authorizer, auth.ActionAdmin, auth.ResourceNodes, func(w http.ResponseWriter, req *http.Request) { + if !ensureAdminSession(r.config, w, req) { + return + } + RequireLicenseFeature(r.licenseHandlers, featureAdvancedReportingValue, RequireScope(config.ScopeSettingsWrite, reportingScheduleEndpoints.HandleUpdateReportSchedule))(w, req) + })) + r.mux.HandleFunc("DELETE /api/admin/reports/schedules/{id}", RequirePermission(r.config, r.authorizer, auth.ActionAdmin, auth.ResourceNodes, func(w http.ResponseWriter, req *http.Request) { + if !ensureAdminSession(r.config, w, req) { + return + } + RequireLicenseFeature(r.licenseHandlers, featureAdvancedReportingValue, RequireScope(config.ScopeSettingsWrite, reportingScheduleEndpoints.HandleDeleteReportSchedule))(w, req) + })) + r.mux.HandleFunc("POST /api/admin/reports/schedules/{id}/run", RequirePermission(r.config, r.authorizer, auth.ActionAdmin, auth.ResourceNodes, func(w http.ResponseWriter, req *http.Request) { + if !ensureAdminSession(r.config, w, req) { + return + } + RequireLicenseFeature(r.licenseHandlers, featureAdvancedReportingValue, RequireScope(config.ScopeSettingsWrite, reportingScheduleEndpoints.HandleRunReportSchedule))(w, req) + })) // Audit Webhook routes r.mux.HandleFunc("/api/admin/webhooks/audit", RequirePermission(r.config, r.authorizer, auth.ActionAdmin, auth.ResourceAuditLogs, func(w http.ResponseWriter, req *http.Request) { @@ -267,6 +301,7 @@ type reportingAdminEndpointAdapter struct { } var _ extensions.ReportingAdminEndpoints = reportingAdminEndpointAdapter{} +var _ extensions.ReportingScheduleAdminEndpoints = reportingAdminEndpointAdapter{} func (a reportingAdminEndpointAdapter) HandleGetReportingCatalog(w http.ResponseWriter, req *http.Request) { if a.handlers == nil { @@ -300,6 +335,46 @@ func (a reportingAdminEndpointAdapter) HandleExportVMInventory(w http.ResponseWr a.handlers.HandleExportVMInventory(w, req) } +func (a reportingAdminEndpointAdapter) HandleListReportSchedules(w http.ResponseWriter, req *http.Request) { + if a.handlers == nil { + writeErrorResponse(w, http.StatusNotImplemented, "reporting_unavailable", "Reporting is not available", nil) + return + } + a.handlers.HandleListReportSchedules(w, req) +} + +func (a reportingAdminEndpointAdapter) HandleCreateReportSchedule(w http.ResponseWriter, req *http.Request) { + if a.handlers == nil { + writeErrorResponse(w, http.StatusNotImplemented, "reporting_unavailable", "Reporting is not available", nil) + return + } + a.handlers.HandleCreateReportSchedule(w, req) +} + +func (a reportingAdminEndpointAdapter) HandleUpdateReportSchedule(w http.ResponseWriter, req *http.Request) { + if a.handlers == nil { + writeErrorResponse(w, http.StatusNotImplemented, "reporting_unavailable", "Reporting is not available", nil) + return + } + a.handlers.HandleUpdateReportSchedule(w, req) +} + +func (a reportingAdminEndpointAdapter) HandleDeleteReportSchedule(w http.ResponseWriter, req *http.Request) { + if a.handlers == nil { + writeErrorResponse(w, http.StatusNotImplemented, "reporting_unavailable", "Reporting is not available", nil) + return + } + a.handlers.HandleDeleteReportSchedule(w, req) +} + +func (a reportingAdminEndpointAdapter) HandleRunReportSchedule(w http.ResponseWriter, req *http.Request) { + if a.handlers == nil { + writeErrorResponse(w, http.StatusNotImplemented, "reporting_unavailable", "Reporting is not available", nil) + return + } + a.handlers.HandleRunReportSchedule(w, req) +} + func newRBACAdminRuntime(handlers *RBACHandlers) extensions.RBACAdminRuntime { return extensions.RBACAdminRuntime{ GetRequestOrgID: GetOrgID, diff --git a/internal/api/security_regression_test.go b/internal/api/security_regression_test.go index 020f40141..68804af96 100644 --- a/internal/api/security_regression_test.go +++ b/internal/api/security_regression_test.go @@ -3337,22 +3337,28 @@ func TestReportingExecutionEndpointsRequireLicenseFeature(t *testing.T) { cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") - paths := []string{ - "/api/admin/reports/generate", - "/api/admin/reports/generate-multi", - "/api/admin/reports/inventory/vms/export", + cases := []struct { + method string + path string + body string + }{ + {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/export", body: ""}, + {method: http.MethodGet, path: "/api/admin/reports/schedules", body: ""}, + {method: http.MethodPost, path: "/api/admin/reports/schedules", body: `{}`}, + {method: http.MethodPut, path: "/api/admin/reports/schedules/schedule-1", body: `{}`}, + {method: http.MethodDelete, path: "/api/admin/reports/schedules/schedule-1", body: ""}, + {method: http.MethodPost, path: "/api/admin/reports/schedules/schedule-1/run", body: ""}, } - for _, path := range paths { - req := httptest.NewRequest(http.MethodGet, path, nil) - if path == "/api/admin/reports/generate-multi" { - req = httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`)) - } + for _, tc := range cases { + req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusPaymentRequired { - t.Fatalf("expected 402 for missing reporting license on %s, got %d", path, rec.Code) + t.Fatalf("expected 402 for missing reporting license on %s %s, got %d", tc.method, tc.path, rec.Code) } } } @@ -3619,6 +3625,11 @@ func TestPermissionProtectedEndpointsDenyWhenAuthorizerBlocks(t *testing.T) { {method: http.MethodGet, path: "/api/admin/reports/catalog", body: ""}, {method: http.MethodGet, path: "/api/admin/reports/generate", body: ""}, {method: http.MethodPost, path: "/api/admin/reports/generate-multi", body: `{}`}, + {method: http.MethodGet, path: "/api/admin/reports/schedules", body: ""}, + {method: http.MethodPost, path: "/api/admin/reports/schedules", body: `{}`}, + {method: http.MethodPut, path: "/api/admin/reports/schedules/schedule-1", body: `{}`}, + {method: http.MethodDelete, path: "/api/admin/reports/schedules/schedule-1", body: ""}, + {method: http.MethodPost, path: "/api/admin/reports/schedules/schedule-1/run", body: ""}, {method: http.MethodGet, path: "/api/admin/webhooks/audit", body: ""}, {method: http.MethodPost, path: "/api/security/tokens/relay-mobile", body: `{}`}, {method: http.MethodGet, path: "/api/security/tokens", body: ""}, diff --git a/internal/cloudcp/portal/bootstrap.go b/internal/cloudcp/portal/bootstrap.go index c9d1b6fd6..4c45380ad 100644 --- a/internal/cloudcp/portal/bootstrap.go +++ b/internal/cloudcp/portal/bootstrap.go @@ -21,6 +21,9 @@ type BootstrapWorkspace struct { LastAgentSeenAt string `json:"last_agent_seen_at,omitempty"` AlertRouteCount *int `json:"alert_route_count,omitempty"` DisabledAlertRouteCount *int `json:"disabled_alert_route_count,omitempty"` + ActiveCriticalAlertCount *int `json:"active_critical_alert_count,omitempty"` + ActiveWarningAlertCount *int `json:"active_warning_alert_count,omitempty"` + ActiveAlertsUpdatedAt string `json:"active_alerts_updated_at,omitempty"` ReportScheduleCount *int `json:"report_schedule_count,omitempty"` DisabledReportScheduleCount *int `json:"disabled_report_schedule_count,omitempty"` LastHealthCheck string `json:"last_health_check,omitempty"` @@ -97,6 +100,10 @@ func BuildBootstrapDataWithSignupPath(authenticated bool, email string, accounts if workspace.LastAgentSeenAt != nil { lastAgentSeenAt = workspace.LastAgentSeenAt.UTC().Format(time.RFC3339) } + activeAlertsUpdatedAt := "" + if workspace.ActiveAlertsUpdatedAt != nil { + activeAlertsUpdatedAt = workspace.ActiveAlertsUpdatedAt.UTC().Format(time.RFC3339) + } workspaces = append(workspaces, BootstrapWorkspace{ ID: workspace.ID, DisplayName: workspace.DisplayName, @@ -110,6 +117,9 @@ func BuildBootstrapDataWithSignupPath(authenticated bool, email string, accounts LastAgentSeenAt: lastAgentSeenAt, AlertRouteCount: workspace.AlertRouteCount, DisabledAlertRouteCount: workspace.DisabledAlertRouteCount, + ActiveCriticalAlertCount: workspace.ActiveCriticalAlertCount, + ActiveWarningAlertCount: workspace.ActiveWarningAlertCount, + ActiveAlertsUpdatedAt: activeAlertsUpdatedAt, ReportScheduleCount: workspace.ReportScheduleCount, DisabledReportScheduleCount: workspace.DisabledReportScheduleCount, LastHealthCheck: lastHealthCheck, diff --git a/internal/cloudcp/portal/dist/build_manifest.json b/internal/cloudcp/portal/dist/build_manifest.json index 64be24e66..99b191b4b 100644 --- a/internal/cloudcp/portal/dist/build_manifest.json +++ b/internal/cloudcp/portal/dist/build_manifest.json @@ -1,5 +1,5 @@ { - "source_hash": "f5a44a4680080b9767af0ee2c44469b71199f0c46f7b4d2da16f21de97dba024", + "source_hash": "ba206fd32b57fa162bf26df7e590c49f43a953f096c908858a2ddd79c8909396", "build_inputs": [ "package.json", "tsconfig.json", diff --git a/internal/cloudcp/portal/dist/portal_app.css b/internal/cloudcp/portal/dist/portal_app.css index ae85fd3df..887769925 100644 --- a/internal/cloudcp/portal/dist/portal_app.css +++ b/internal/cloudcp/portal/dist/portal_app.css @@ -346,7 +346,7 @@ header .logout-btn:hover, } .workspace-list-head { display: grid; - grid-template-columns: minmax(12rem, 1fr) 112px 88px minmax(300px, auto); + grid-template-columns: minmax(12rem, 1fr) 112px 128px 88px minmax(300px, auto); gap: 8px; padding: 6px 16px; background: var(--bg-subtle); @@ -361,7 +361,7 @@ header .logout-btn:hover, } .workspace-row { display: grid; - grid-template-columns: minmax(12rem, 1fr) 112px 88px minmax(300px, auto); + grid-template-columns: minmax(12rem, 1fr) 112px 128px 88px minmax(300px, auto); gap: 8px; align-items: center; padding: 10px 16px; @@ -498,6 +498,26 @@ header .logout-btn:hover, background: var(--danger-subtle); color: var(--danger); } +.badge-alert-critical { + border-color: #f5b3b3; + background: var(--danger-subtle); + color: var(--danger); +} +.badge-alert-warning { + border-color: #e3c97a; + background: var(--warn-subtle); + color: var(--warn); +} +.badge-alert-quiet { + border-color: #aedcb5; + background: var(--success-subtle); + color: var(--success); +} +.badge-alert-unknown { + border-color: var(--border); + background: var(--bg-subtle); + color: var(--ink-muted); +} .workspace-operations-shell { display: flex; flex-direction: column; diff --git a/internal/cloudcp/portal/dist/portal_app.js b/internal/cloudcp/portal/dist/portal_app.js index 145b297e7..68149971d 100644 --- a/internal/cloudcp/portal/dist/portal_app.js +++ b/internal/cloudcp/portal/dist/portal_app.js @@ -88,6 +88,33 @@ function hasNumber(value) { return typeof value === "number" && Number.isFinite(value); } + function workspaceActiveAlertState(workspace) { + if (!hasNumber(workspace.active_critical_alert_count) || !hasNumber(workspace.active_warning_alert_count)) { + return "unknown"; + } + if (Number(workspace.active_critical_alert_count) > 0) return "critical"; + if (Number(workspace.active_warning_alert_count) > 0) return "warning"; + return "quiet"; + } + function workspaceActiveAlertLabel(workspace) { + var state = workspaceActiveAlertState(workspace); + if (state === "unknown") return "No alert data yet"; + var critical = Number(workspace.active_critical_alert_count || 0); + var warning = Number(workspace.active_warning_alert_count || 0); + if (state === "quiet") return "0 active alerts"; + var parts = []; + if (critical > 0) parts.push(String(critical) + " critical"); + if (warning > 0) parts.push(String(warning) + " warning"); + return parts.join(", "); + } + function workspaceActiveAlertsUpdatedLabel(workspace, now = /* @__PURE__ */ new Date()) { + if (!workspace.active_alerts_updated_at) return "no alert data yet"; + var updated = new Date(workspace.active_alerts_updated_at); + if (Number.isNaN(updated.getTime())) return "no alert data yet"; + var minutes = Math.max(0, Math.round((now.getTime() - updated.getTime()) / 6e4)); + if (minutes <= 1) return "as of 1 min ago"; + return "as of " + String(minutes) + " min ago"; + } function positiveCount(value) { return hasNumber(value) && Number(value) > 0; } @@ -2607,6 +2634,9 @@ function setupNeededWorkspaceChipLabel(count, clientLanguage = false) { return count === 1 ? "1 " + workspaceEntityName(clientLanguage) + " in setup" : String(count) + " " + workspaceEntityName(clientLanguage, true) + " in setup"; } + function criticalWorkspaceChipLabel(count, clientLanguage = false) { + return count === 1 ? "1 " + workspaceEntityName(clientLanguage) + " with critical alerts" : String(count) + " " + workspaceEntityName(clientLanguage, true) + " with critical alerts"; + } function supportRunbookPathCopy(hasHostedAccounts2, hostedViewOnly, showSelfHostedCommercial, hasHostedBilling, clientLanguage = false) { var primarySection = clientLanguage ? "Clients" : "Workspaces"; if (!hasHostedAccounts2) return "Billing, licenses, refunds, or privacy."; @@ -2721,6 +2751,15 @@ var setup = workspaceSetupState(workspace); return '' + escapeHTML(workspaceSetupLabel(workspace)) + ""; } + function alertBadgeHTML(workspace) { + var state = workspaceActiveAlertState(workspace); + var label = state === "unknown" ? "-" : workspaceActiveAlertLabel(workspace); + var title = workspaceActiveAlertLabel(workspace); + if (state !== "unknown") { + title += " \xB7 " + workspaceActiveAlertsUpdatedLabel(workspace); + } + return '' + escapeHTML(label) + ""; + } function renderBillingActionRow(id, title, actionLabel, description, panelID, focusID, highlights) { var meta = escapeHTML(highlights.join(" \u2022 ")); return '

' + title + "

" + description + '

' + meta + '
"; @@ -2738,12 +2777,15 @@ var results = []; for (var i = 0; i < workspaces.length; i += 1) { var status = workspaceHealthState(workspaces[i]); - if (status === "unhealthy" || status === "checking") { + if (workspaceActiveAlertState(workspaces[i]) === "critical" || status === "unhealthy" || status === "checking") { results.push(workspaces[i]); } } return results; } + function hasCriticalAlerts(workspace) { + return workspaceActiveAlertState(workspace) === "critical"; + } function renderIdentityBar(accounts, showSelfHostedCommercial) { if (accounts.length === 1) { var account = accounts[0]; @@ -2820,7 +2862,7 @@ if (account.can_manage && (state === "active" || state === "suspended" || state === "failed")) { manageAction = '"; } - return '

' + escapeHTML(workspace.display_name) + "

" + (metaParts.length ? '
' + metaParts.join("") + "
" : "") + '
' + setupBadgeHTML(workspace) + '
' + healthBadgeHTML(workspace) + '
' + openAction + installAction + manageAction + "
"; + return '

' + escapeHTML(workspace.display_name) + "

" + (metaParts.length ? '
' + metaParts.join("") + "
" : "") + '
' + setupBadgeHTML(workspace) + '
' + alertBadgeHTML(workspace) + '
' + healthBadgeHTML(workspace) + '
' + openAction + installAction + manageAction + "
"; } function renderWorkspaceHandoffForm(accountID, workspaceID, accountAPIBasePath, label, buttonClassName = "btn-secondary btn-compact") { if (!accountAPIBasePath) { @@ -2844,7 +2886,16 @@ var results = []; for (var i = 0; i < entries.length; i += 1) { var status = workspaceHealthState(entries[i].workspace); - if (status === "unhealthy" || status === "checking") { + if (hasCriticalAlerts(entries[i].workspace) || status === "unhealthy" || status === "checking") { + results.push(entries[i]); + } + } + return results; + } + function criticalAlertWorkspaceEntries(entries) { + var results = []; + for (var i = 0; i < entries.length; i += 1) { + if (hasCriticalAlerts(entries[i].workspace)) { results.push(entries[i]); } } @@ -2911,6 +2962,7 @@ } function renderWorkspaceSummaryDecision(accounts, entries, accountAPIBasePath, showSelfHostedCommercial) { var clientLanguage = accountsUseClientLanguage(accounts); + var critical = criticalAlertWorkspaceEntries(entries); var attention = attentionWorkspaceEntries(entries); var suspended = suspendedWorkspaceEntries(entries); var setupNeeded = setupNeededWorkspaceEntries(entries); @@ -2927,7 +2979,22 @@ return account.can_manage; }) || null; var hostedViewOnly = accounts.length > 0 && !accessAccount; - if (attention.length) { + if (critical.length) { + var criticalEntry = critical[0]; + title = "Review " + criticalEntry.workspace.display_name; + description = workspaceSummaryContext(criticalEntry, accounts.length > 1, workspaceActiveAlertLabel(criticalEntry.workspace)); + primaryAction = renderWorkspaceAnchorAction( + workspaceRowAnchorID(criticalEntry.account.id, criticalEntry.workspace.id), + clientLanguage ? "Review client" : "Review workspace", + "btn-primary btn-compact workspace-summary-link" + ); + secondaryAction = renderWorkspaceHandoffForm( + criticalEntry.account.id, + criticalEntry.workspace.id, + accountAPIBasePath, + clientLanguage ? "Open client" : "Open workspace" + ); + } else if (attention.length) { var attentionEntry = attention[0]; title = "Review " + attentionEntry.workspace.display_name; description = workspaceSummaryContext(attentionEntry, accounts.length > 1, workspaceSummaryStatusCopy(attentionEntry.workspace, clientLanguage)); @@ -3026,6 +3093,7 @@ workspaceCountLabel(entries.length, clientLanguage), readyWorkspaceChipLabel(readyWorkspaceEntries(entries).length, clientLanguage), setupNeededWorkspaceChipLabel(setupNeededWorkspaceEntries(entries).length, clientLanguage), + criticalWorkspaceChipLabel(criticalAlertWorkspaceEntries(entries).length, clientLanguage), reviewWorkspaceChipLabel(attentionWorkspaceEntries(entries).length, clientLanguage), suspendedWorkspaceChipLabel(suspendedWorkspaceEntries(entries).length, clientLanguage) ]; @@ -3125,7 +3193,7 @@ } workspaceManagement = '"; } - var workspaceHTML = workspaces.length ? '
' + (workspaceHeaderActions ? '
' + workspaceHeaderActions + "
" : "") + '
' + escapeHTML(clientLanguage ? "Client" : "Workspace") + 'SetupHealthActions
' + workspaces.map(function(workspace) { + var workspaceHTML = workspaces.length ? '
' + (workspaceHeaderActions ? '
' + workspaceHeaderActions + "
" : "") + '
' + escapeHTML(clientLanguage ? "Client" : "Workspace") + 'SetupAlertsHealthActions
' + workspaces.map(function(workspace) { return renderWorkspaceCard(account, workspace, accountAPIBasePath); }).join("") + "
" : '

' + escapeHTML( account.can_manage ? clientLanguage ? "No clients yet. Add one to get started." : "No hosted workspaces yet. Create one to get started." : clientLanguage ? "No clients are attached yet. An owner or admin must add the first one." : "No hosted workspaces are attached yet. An owner or admin must create the first one." diff --git a/internal/cloudcp/portal/frontend/src/shell_view.ts b/internal/cloudcp/portal/frontend/src/shell_view.ts index 5cce90ad6..c27142f52 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.ts @@ -9,6 +9,9 @@ import type { import { portalRoleLabel } from './account_roles'; import { preferredPortalShellSection } from './shell_section'; import { + workspaceActiveAlertLabel, + workspaceActiveAlertsUpdatedLabel, + workspaceActiveAlertState, workspaceHealthLabel, workspaceHealthState, workspaceRowNote, @@ -132,6 +135,12 @@ function setupNeededWorkspaceChipLabel(count: number, clientLanguage = false): s : String(count) + ' ' + workspaceEntityName(clientLanguage, true) + ' in setup'; } +function criticalWorkspaceChipLabel(count: number, clientLanguage = false): string { + return count === 1 + ? '1 ' + workspaceEntityName(clientLanguage) + ' with critical alerts' + : String(count) + ' ' + workspaceEntityName(clientLanguage, true) + ' with critical alerts'; +} + function supportRunbookPathCopy(hasHostedAccounts: boolean, hostedViewOnly: boolean, showSelfHostedCommercial: boolean, hasHostedBilling: boolean, clientLanguage = false): string { @@ -285,6 +294,16 @@ function setupBadgeHTML(workspace: PortalWorkspaceSummary): string { return '' + escapeHTML(workspaceSetupLabel(workspace)) + ''; } +function alertBadgeHTML(workspace: PortalWorkspaceSummary): string { + var state = workspaceActiveAlertState(workspace); + var label = state === 'unknown' ? '-' : workspaceActiveAlertLabel(workspace); + var title = workspaceActiveAlertLabel(workspace); + if (state !== 'unknown') { + title += ' ยท ' + workspaceActiveAlertsUpdatedLabel(workspace); + } + return '' + escapeHTML(label) + ''; +} + function renderBillingActionRow( id: string, title: string, @@ -330,13 +349,17 @@ function attentionWorkspaces(workspaces: PortalWorkspaceSummary[]): PortalWorksp var results: PortalWorkspaceSummary[] = []; for (var i = 0; i < workspaces.length; i += 1) { var status = workspaceHealthState(workspaces[i]); - if (status === 'unhealthy' || status === 'checking') { + if (workspaceActiveAlertState(workspaces[i]) === 'critical' || status === 'unhealthy' || status === 'checking') { results.push(workspaces[i]); } } return results; } +function hasCriticalAlerts(workspace: PortalWorkspaceSummary): boolean { + return workspaceActiveAlertState(workspace) === 'critical'; +} + function accountContextRoleMeta(account: PortalAccountSummary): string { return portalRoleLabel(account.role) + ' role'; } @@ -497,6 +520,9 @@ function renderWorkspaceCard(account: PortalAccountSummary, workspace: PortalWor '

' + setupBadgeHTML(workspace) + '
' + + '
' + + alertBadgeHTML(workspace) + + '
' + '
' + healthBadgeHTML(workspace) + '
' + @@ -562,7 +588,17 @@ function attentionWorkspaceEntries(entries: WorkspaceSummaryEntry[]): WorkspaceS var results: WorkspaceSummaryEntry[] = []; for (var i = 0; i < entries.length; i += 1) { var status = workspaceHealthState(entries[i].workspace); - if (status === 'unhealthy' || status === 'checking') { + if (hasCriticalAlerts(entries[i].workspace) || status === 'unhealthy' || status === 'checking') { + results.push(entries[i]); + } + } + return results; +} + +function criticalAlertWorkspaceEntries(entries: WorkspaceSummaryEntry[]): WorkspaceSummaryEntry[] { + var results: WorkspaceSummaryEntry[] = []; + for (var i = 0; i < entries.length; i += 1) { + if (hasCriticalAlerts(entries[i].workspace)) { results.push(entries[i]); } } @@ -651,6 +687,7 @@ function renderWorkspaceSummaryDecision( showSelfHostedCommercial: boolean ): WorkspaceSummaryDecision { var clientLanguage = accountsUseClientLanguage(accounts); + var critical = criticalAlertWorkspaceEntries(entries); var attention = attentionWorkspaceEntries(entries); var suspended = suspendedWorkspaceEntries(entries); var setupNeeded = setupNeededWorkspaceEntries(entries); @@ -668,7 +705,22 @@ function renderWorkspaceSummaryDecision( }) || null; var hostedViewOnly = accounts.length > 0 && !accessAccount; - if (attention.length) { + if (critical.length) { + var criticalEntry = critical[0]; + title = 'Review ' + criticalEntry.workspace.display_name; + description = workspaceSummaryContext(criticalEntry, accounts.length > 1, workspaceActiveAlertLabel(criticalEntry.workspace)); + primaryAction = renderWorkspaceAnchorAction( + workspaceRowAnchorID(criticalEntry.account.id, criticalEntry.workspace.id), + clientLanguage ? 'Review client' : 'Review workspace', + 'btn-primary btn-compact workspace-summary-link', + ); + secondaryAction = renderWorkspaceHandoffForm( + criticalEntry.account.id, + criticalEntry.workspace.id, + accountAPIBasePath, + clientLanguage ? 'Open client' : 'Open workspace', + ); + } else if (attention.length) { var attentionEntry = attention[0]; title = 'Review ' + attentionEntry.workspace.display_name; description = workspaceSummaryContext(attentionEntry, accounts.length > 1, workspaceSummaryStatusCopy(attentionEntry.workspace, clientLanguage)); @@ -813,6 +865,7 @@ function renderWorkspaceSummaryFacts(accounts: PortalAccountSummary[], entries: workspaceCountLabel(entries.length, clientLanguage), readyWorkspaceChipLabel(readyWorkspaceEntries(entries).length, clientLanguage), setupNeededWorkspaceChipLabel(setupNeededWorkspaceEntries(entries).length, clientLanguage), + criticalWorkspaceChipLabel(criticalAlertWorkspaceEntries(entries).length, clientLanguage), reviewWorkspaceChipLabel(attentionWorkspaceEntries(entries).length, clientLanguage), suspendedWorkspaceChipLabel(suspendedWorkspaceEntries(entries).length, clientLanguage), ]; @@ -1237,6 +1290,7 @@ function renderAccountWorkspaceSection(account: PortalAccountSummary, accountAPI '
' + '' + escapeHTML(clientLanguage ? 'Client' : 'Workspace') + '' + 'Setup' + + 'Alerts' + 'Health' + 'Actions' + '
' + diff --git a/internal/cloudcp/portal/frontend/src/styles.css b/internal/cloudcp/portal/frontend/src/styles.css index 25500cbaf..350a8539c 100644 --- a/internal/cloudcp/portal/frontend/src/styles.css +++ b/internal/cloudcp/portal/frontend/src/styles.css @@ -325,7 +325,7 @@ header .logout-btn:hover, .workspace-list-head { display: grid; - grid-template-columns: minmax(12rem, 1fr) 112px 88px minmax(300px, auto); + grid-template-columns: minmax(12rem, 1fr) 112px 128px 88px minmax(300px, auto); gap: 8px; padding: 6px 16px; background: var(--bg-subtle); @@ -342,7 +342,7 @@ header .logout-btn:hover, .workspace-row { display: grid; - grid-template-columns: minmax(12rem, 1fr) 112px 88px minmax(300px, auto); + grid-template-columns: minmax(12rem, 1fr) 112px 128px 88px minmax(300px, auto); gap: 8px; align-items: center; padding: 10px 16px; @@ -442,6 +442,10 @@ header .logout-btn:hover, .badge-setup-install_agents, .badge-setup-configure_outputs { border-color: #e3c97a; background: var(--warn-subtle); color: var(--warn); } .badge-setup-review { border-color: #f5b3b3; background: var(--danger-subtle); color: var(--danger); } +.badge-alert-critical { border-color: #f5b3b3; background: var(--danger-subtle); color: var(--danger); } +.badge-alert-warning { border-color: #e3c97a; background: var(--warn-subtle); color: var(--warn); } +.badge-alert-quiet { border-color: #aedcb5; background: var(--success-subtle); color: var(--success); } +.badge-alert-unknown { border-color: var(--border); background: var(--bg-subtle); color: var(--ink-muted); } /* โ”€โ”€ Workspace management panel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ .workspace-operations-shell { diff --git a/internal/cloudcp/portal/frontend/src/types.ts b/internal/cloudcp/portal/frontend/src/types.ts index 943d0672a..3ba0c2906 100644 --- a/internal/cloudcp/portal/frontend/src/types.ts +++ b/internal/cloudcp/portal/frontend/src/types.ts @@ -11,6 +11,9 @@ export interface PortalWorkspaceSummary { last_agent_seen_at?: string; alert_route_count?: number; disabled_alert_route_count?: number; + active_critical_alert_count?: number; + active_warning_alert_count?: number; + active_alerts_updated_at?: string; report_schedule_count?: number; disabled_report_schedule_count?: number; last_health_check?: string; diff --git a/internal/cloudcp/portal/frontend/src/workspace_presentation.ts b/internal/cloudcp/portal/frontend/src/workspace_presentation.ts index 44d5e9ae8..2b2eca628 100644 --- a/internal/cloudcp/portal/frontend/src/workspace_presentation.ts +++ b/internal/cloudcp/portal/frontend/src/workspace_presentation.ts @@ -3,6 +3,7 @@ import type { PortalWorkspaceSummary } from './types'; export type WorkspaceSetupState = 'ready' | 'setup_path' | 'install_agents' | 'configure_outputs' | 'review'; export type WorkspaceSetupStepID = 'workspace' | 'agent' | 'alerts' | 'reports' | 'access'; export type WorkspaceSetupStepTone = 'done' | 'next' | 'pending' | 'blocked' | 'available'; +export type WorkspaceAlertState = 'critical' | 'warning' | 'quiet' | 'unknown'; export interface WorkspaceSetupStepModel { id: WorkspaceSetupStepID; @@ -66,6 +67,36 @@ function hasNumber(value: unknown): boolean { return typeof value === 'number' && Number.isFinite(value); } +export function workspaceActiveAlertState(workspace: PortalWorkspaceSummary): WorkspaceAlertState { + if (!hasNumber(workspace.active_critical_alert_count) || !hasNumber(workspace.active_warning_alert_count)) { + return 'unknown'; + } + if (Number(workspace.active_critical_alert_count) > 0) return 'critical'; + if (Number(workspace.active_warning_alert_count) > 0) return 'warning'; + return 'quiet'; +} + +export function workspaceActiveAlertLabel(workspace: PortalWorkspaceSummary): string { + var state = workspaceActiveAlertState(workspace); + if (state === 'unknown') return 'No alert data yet'; + var critical = Number(workspace.active_critical_alert_count || 0); + var warning = Number(workspace.active_warning_alert_count || 0); + if (state === 'quiet') return '0 active alerts'; + var parts = []; + if (critical > 0) parts.push(String(critical) + ' critical'); + if (warning > 0) parts.push(String(warning) + ' warning'); + return parts.join(', '); +} + +export function workspaceActiveAlertsUpdatedLabel(workspace: PortalWorkspaceSummary, now = new Date()): string { + if (!workspace.active_alerts_updated_at) return 'no alert data yet'; + var updated = new Date(workspace.active_alerts_updated_at); + if (Number.isNaN(updated.getTime())) return 'no alert data yet'; + var minutes = Math.max(0, Math.round((now.getTime() - updated.getTime()) / 60000)); + if (minutes <= 1) return 'as of 1 min ago'; + return 'as of ' + String(minutes) + ' min ago'; +} + function positiveCount(value: unknown): boolean { return hasNumber(value) && Number(value) > 0; } diff --git a/internal/cloudcp/portal/handlers.go b/internal/cloudcp/portal/handlers.go index 0f161db6b..48d9f44e9 100644 --- a/internal/cloudcp/portal/handlers.go +++ b/internal/cloudcp/portal/handlers.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "net/http" + "sort" "strings" "time" @@ -32,6 +33,9 @@ type workspaceSummaryItem struct { LastAgentSeenAt *time.Time `json:"last_agent_seen_at,omitempty"` AlertRouteCount *int `json:"alert_route_count,omitempty"` DisabledAlertRouteCount *int `json:"disabled_alert_route_count,omitempty"` + ActiveCriticalAlertCount *int `json:"active_critical_alert_count,omitempty"` + ActiveWarningAlertCount *int `json:"active_warning_alert_count,omitempty"` + ActiveAlertsUpdatedAt *time.Time `json:"active_alerts_updated_at,omitempty"` ReportScheduleCount *int `json:"report_schedule_count,omitempty"` DisabledReportScheduleCount *int `json:"disabled_report_schedule_count,omitempty"` LastHealthCheck *time.Time `json:"last_health_check"` @@ -44,6 +48,7 @@ type dashboardSummary struct { Healthy int `json:"healthy"` Unhealthy int `json:"unhealthy"` Suspended int `json:"suspended"` + Critical int `json:"critical"` } type dashboardResponse struct { @@ -145,6 +150,9 @@ func HandlePortalDashboardWithSetupFacts(reg *registry.TenantRegistry, setupFact LastAgentSeenAt: facts.LastAgentSeenAt, AlertRouteCount: facts.AlertRouteCount, DisabledAlertRouteCount: facts.DisabledAlertRouteCount, + ActiveCriticalAlertCount: facts.ActiveCriticalAlertCount, + ActiveWarningAlertCount: facts.ActiveWarningAlertCount, + ActiveAlertsUpdatedAt: facts.ActiveAlertsUpdatedAt, ReportScheduleCount: facts.ReportScheduleCount, DisabledReportScheduleCount: facts.DisabledReportScheduleCount, LastHealthCheck: t.LastHealthCheck, @@ -152,6 +160,9 @@ func HandlePortalDashboardWithSetupFacts(reg *registry.TenantRegistry, setupFact }) resp.Summary.Total++ + if factCountIsPositive(facts.ActiveCriticalAlertCount) { + resp.Summary.Critical++ + } switch t.State { case registry.TenantStateActive: @@ -165,6 +176,7 @@ func HandlePortalDashboardWithSetupFacts(reg *registry.TenantRegistry, setupFact resp.Summary.Suspended++ } } + sortWorkspaceSummaries(resp.Workspaces) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) @@ -240,6 +252,32 @@ func isPortalVisibleTenant(t *registry.Tenant) bool { return t.State != registry.TenantStateDeleted && t.State != registry.TenantStateDeleting } +func sortWorkspaceSummaries(workspaces []workspaceSummaryItem) { + sort.SliceStable(workspaces, func(i, j int) bool { + leftCritical := derefInt(workspaces[i].ActiveCriticalAlertCount) + rightCritical := derefInt(workspaces[j].ActiveCriticalAlertCount) + if leftCritical != rightCritical { + return leftCritical > rightCritical + } + leftWarning := derefInt(workspaces[i].ActiveWarningAlertCount) + rightWarning := derefInt(workspaces[j].ActiveWarningAlertCount) + if leftWarning != rightWarning { + return leftWarning > rightWarning + } + if workspaces[i].HealthCheckOK != workspaces[j].HealthCheckOK { + return !workspaces[i].HealthCheckOK + } + return strings.ToLower(workspaces[i].DisplayName) < strings.ToLower(workspaces[j].DisplayName) + }) +} + +func derefInt(value *int) int { + if value == nil { + return -1 + } + return *value +} + // HandlePortalBootstrap returns the renderer-owned Pulse Account bootstrap payload. // Route: GET /api/portal/bootstrap // diff --git a/internal/cloudcp/portal/handlers_test.go b/internal/cloudcp/portal/handlers_test.go index d57d178e6..8f7402ea8 100644 --- a/internal/cloudcp/portal/handlers_test.go +++ b/internal/cloudcp/portal/handlers_test.go @@ -132,6 +132,9 @@ type dashboardResp struct { LastAgentSeenAt *time.Time `json:"last_agent_seen_at"` AlertRouteCount *int `json:"alert_route_count"` DisabledAlertRouteCount *int `json:"disabled_alert_route_count"` + ActiveCriticalAlertCount *int `json:"active_critical_alert_count"` + ActiveWarningAlertCount *int `json:"active_warning_alert_count"` + ActiveAlertsUpdatedAt *time.Time `json:"active_alerts_updated_at"` ReportScheduleCount *int `json:"report_schedule_count"` DisabledReportScheduleCount *int `json:"disabled_report_schedule_count"` LastHealthCheck *time.Time `json:"last_health_check"` @@ -143,6 +146,7 @@ type dashboardResp struct { Healthy int `json:"healthy"` Unhealthy int `json:"unhealthy"` Suspended int `json:"suspended"` + Critical int `json:"critical"` } `json:"summary"` } @@ -262,6 +266,9 @@ func TestPortalDashboard(t *testing.T) { LastAgentSeenAt: ws.LastAgentSeenAt, AlertRouteCount: ws.AlertRouteCount, DisabledAlertRouteCount: ws.DisabledAlertRouteCount, + ActiveCriticalAlertCount: ws.ActiveCriticalAlertCount, + ActiveWarningAlertCount: ws.ActiveWarningAlertCount, + ActiveAlertsUpdatedAt: ws.ActiveAlertsUpdatedAt, ReportScheduleCount: ws.ReportScheduleCount, DisabledReportScheduleCount: ws.DisabledReportScheduleCount, LastHealthCheck: ws.LastHealthCheck, @@ -434,6 +441,9 @@ func TestPortalDashboardUsesWorkspaceSetupFacts(t *testing.T) { LastAgentSeenAt: ws.LastAgentSeenAt, AlertRouteCount: ws.AlertRouteCount, DisabledAlertRouteCount: ws.DisabledAlertRouteCount, + ActiveCriticalAlertCount: ws.ActiveCriticalAlertCount, + ActiveWarningAlertCount: ws.ActiveWarningAlertCount, + ActiveAlertsUpdatedAt: ws.ActiveAlertsUpdatedAt, ReportScheduleCount: ws.ReportScheduleCount, DisabledReportScheduleCount: ws.DisabledReportScheduleCount, LastHealthCheck: ws.LastHealthCheck, @@ -469,6 +479,89 @@ func TestPortalDashboardUsesWorkspaceSetupFacts(t *testing.T) { } } +func TestPortalDashboardRollsUpActiveAlertsAndSortsCriticalFirst(t *testing.T) { + reg := newTestRegistry(t) + mux := http.NewServeMux() + + accountID, err := registry.GenerateAccountID() + if err != nil { + t.Fatal(err) + } + if err := reg.CreateAccount(®istry.Account{ID: accountID, Kind: registry.AccountKindMSP, DisplayName: "Example MSP"}); err != nil { + t.Fatal(err) + } + + criticalID, err := registry.GenerateTenantID() + if err != nil { + t.Fatal(err) + } + unknownID, err := registry.GenerateTenantID() + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 4, 1, 10, 0, 0, 0, time.UTC) + for _, tenant := range []*registry.Tenant{ + {ID: unknownID, AccountID: accountID, DisplayName: "Unknown Alerts", State: registry.TenantStateActive, CreatedAt: now, HealthCheckOK: true}, + {ID: criticalID, AccountID: accountID, DisplayName: "Critical Alerts", State: registry.TenantStateActive, CreatedAt: now, HealthCheckOK: true}, + } { + if err := reg.Create(tenant); err != nil { + t.Fatal(err) + } + } + + mux.Handle(PortalDashboardPath, admin.AdminKeyMiddleware("secret-key", HandlePortalDashboardWithSetupFacts(reg, staticSetupFactReader{ + criticalID: { + AgentCount: intPtr(1), + AlertRouteCount: intPtr(1), + ReportScheduleCount: intPtr(1), + ActiveCriticalAlertCount: intPtr(2), + ActiveWarningAlertCount: intPtr(3), + ActiveAlertsUpdatedAt: &now, + DisabledAlertRouteCount: intPtr(0), + DisabledReportScheduleCount: intPtr(0), + }, + unknownID: { + AgentCount: intPtr(1), + AlertRouteCount: intPtr(1), + ReportScheduleCount: intPtr(1), + DisabledAlertRouteCount: intPtr(0), + DisabledReportScheduleCount: intPtr(0), + }, + }))) + + req := httptest.NewRequest(http.MethodGet, "/api/portal/dashboard?account_id="+accountID, nil) + rec := doRequest(t, mux, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp dashboardResp + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v (body=%q)", err, rec.Body.String()) + } + if len(resp.Workspaces) != 2 { + t.Fatalf("workspaces len = %d, want 2", len(resp.Workspaces)) + } + if resp.Workspaces[0].ID != criticalID { + t.Fatalf("first workspace = %s, want critical workspace %s", resp.Workspaces[0].ID, criticalID) + } + if resp.Workspaces[0].ActiveCriticalAlertCount == nil || *resp.Workspaces[0].ActiveCriticalAlertCount != 2 { + t.Fatalf("critical active_critical_alert_count = %v, want 2", resp.Workspaces[0].ActiveCriticalAlertCount) + } + if resp.Workspaces[0].ActiveWarningAlertCount == nil || *resp.Workspaces[0].ActiveWarningAlertCount != 3 { + t.Fatalf("critical active_warning_alert_count = %v, want 3", resp.Workspaces[0].ActiveWarningAlertCount) + } + if resp.Workspaces[0].ActiveAlertsUpdatedAt == nil || !resp.Workspaces[0].ActiveAlertsUpdatedAt.Equal(now) { + t.Fatalf("critical active_alerts_updated_at = %v, want %v", resp.Workspaces[0].ActiveAlertsUpdatedAt, now) + } + if resp.Workspaces[1].ActiveCriticalAlertCount != nil || resp.Workspaces[1].ActiveWarningAlertCount != nil { + t.Fatalf("unknown alert counts = critical %v warning %v, want nil", resp.Workspaces[1].ActiveCriticalAlertCount, resp.Workspaces[1].ActiveWarningAlertCount) + } + if resp.Summary.Critical != 1 { + t.Fatalf("summary.critical = %d, want 1", resp.Summary.Critical) + } +} + type dashboardRespWorkspace struct { ID string DisplayName string @@ -481,6 +574,9 @@ type dashboardRespWorkspace struct { LastAgentSeenAt *time.Time AlertRouteCount *int DisabledAlertRouteCount *int + ActiveCriticalAlertCount *int + ActiveWarningAlertCount *int + ActiveAlertsUpdatedAt *time.Time ReportScheduleCount *int DisabledReportScheduleCount *int LastHealthCheck *time.Time diff --git a/internal/cloudcp/portal/page.go b/internal/cloudcp/portal/page.go index 73826aab5..ca01fbc73 100644 --- a/internal/cloudcp/portal/page.go +++ b/internal/cloudcp/portal/page.go @@ -4,6 +4,7 @@ import ( "errors" "html/template" "net/http" + "sort" "time" cpauth "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/auth" @@ -26,6 +27,9 @@ type portalPageWorkspace struct { LastAgentSeenAt *time.Time AlertRouteCount *int DisabledAlertRouteCount *int + ActiveCriticalAlertCount *int + ActiveWarningAlertCount *int + ActiveAlertsUpdatedAt *time.Time ReportScheduleCount *int DisabledReportScheduleCount *int LastHealthCheck *time.Time @@ -176,6 +180,7 @@ func loadPortalAccountsForUserWithSetupFacts(reg *registry.TenantRegistry, userI } workspaces = append(workspaces, portalPageWorkspaceFromTenant(t, workspaceSetupFactsForTenant(setupFacts, t.ID))) } + sortPortalPageWorkspaces(workspaces) kindLabel := "Cloud" if a.Kind == registry.AccountKindMSP { @@ -229,6 +234,9 @@ func portalPageWorkspaceFromTenant(t *registry.Tenant, facts WorkspaceSetupFacts LastAgentSeenAt: facts.LastAgentSeenAt, AlertRouteCount: facts.AlertRouteCount, DisabledAlertRouteCount: facts.DisabledAlertRouteCount, + ActiveCriticalAlertCount: facts.ActiveCriticalAlertCount, + ActiveWarningAlertCount: facts.ActiveWarningAlertCount, + ActiveAlertsUpdatedAt: facts.ActiveAlertsUpdatedAt, ReportScheduleCount: facts.ReportScheduleCount, DisabledReportScheduleCount: facts.DisabledReportScheduleCount, LastHealthCheck: t.LastHealthCheck, @@ -236,6 +244,25 @@ func portalPageWorkspaceFromTenant(t *registry.Tenant, facts WorkspaceSetupFacts } } +func sortPortalPageWorkspaces(workspaces []portalPageWorkspace) { + sort.SliceStable(workspaces, func(i, j int) bool { + leftCritical := derefInt(workspaces[i].ActiveCriticalAlertCount) + rightCritical := derefInt(workspaces[j].ActiveCriticalAlertCount) + if leftCritical != rightCritical { + return leftCritical > rightCritical + } + leftWarning := derefInt(workspaces[i].ActiveWarningAlertCount) + rightWarning := derefInt(workspaces[j].ActiveWarningAlertCount) + if leftWarning != rightWarning { + return leftWarning > rightWarning + } + if workspaces[i].Healthy != workspaces[j].Healthy { + return !workspaces[i].Healthy + } + return workspaces[i].DisplayName < workspaces[j].DisplayName + }) +} + func loadPortalAccountMembers(reg *registry.TenantRegistry, accountID string, actorRole registry.MemberRole) ([]portalPageMember, error) { subjects, err := reg.ListAccessSubjectsByAccount(accountID) if err != nil { diff --git a/internal/cloudcp/portal/setup_facts.go b/internal/cloudcp/portal/setup_facts.go index 004f13448..8e6af26bc 100644 --- a/internal/cloudcp/portal/setup_facts.go +++ b/internal/cloudcp/portal/setup_facts.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/rcourtman/pulse-go-rewrite/internal/alerts" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/crypto" "github.com/rcourtman/pulse-go-rewrite/internal/notifications" @@ -21,6 +22,9 @@ type WorkspaceSetupFacts struct { LastAgentSeenAt *time.Time AlertRouteCount *int DisabledAlertRouteCount *int + ActiveCriticalAlertCount *int + ActiveWarningAlertCount *int + ActiveAlertsUpdatedAt *time.Time ReportScheduleCount *int DisabledReportScheduleCount *int } @@ -62,6 +66,7 @@ func readWorkspaceSetupFacts(tenantID, tenantDataDir, orgDir string) WorkspaceSe facts.AgentCount, facts.AgentTokenCount, facts.UnusedAgentTokenCount, facts.LastAgentSeenAt = readAgentSetupFacts(tenantID, tenantDataDir, orgDir) facts.AlertRouteCount, facts.DisabledAlertRouteCount = readAlertRouteFacts(orgDir) + facts.ActiveCriticalAlertCount, facts.ActiveWarningAlertCount, facts.ActiveAlertsUpdatedAt = readActiveAlertFacts(tenantDataDir) facts.ReportScheduleCount, facts.DisabledReportScheduleCount = readReportScheduleFacts(orgDir) return facts } @@ -214,6 +219,47 @@ func nonBlankCount(values []string) int { return count } +func readActiveAlertFacts(tenantDataDir string) (*int, *int, *time.Time) { + path, ok := safeConfigLeafPath(tenantDataDir, filepath.Join("alerts", "active-alerts.json")) + if !ok { + return nil, nil, nil + } + data, err := os.ReadFile(path) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + log.Warn().Err(err).Str("tenant_data_dir", tenantDataDir).Msg("cloudcp.portal.setup_facts: read active alerts") + } + return nil, nil, nil + } + if len(strings.TrimSpace(string(data))) == 0 { + return nil, nil, nil + } + + var active []alerts.Alert + if err := json.Unmarshal(data, &active); err != nil { + log.Warn().Err(err).Str("tenant_data_dir", tenantDataDir).Msg("cloudcp.portal.setup_facts: parse active alerts") + return nil, nil, nil + } + + criticalCount := 0 + warningCount := 0 + for _, alert := range active { + switch strings.ToLower(strings.TrimSpace(string(alert.Level))) { + case "critical": + criticalCount++ + case "warning": + warningCount++ + } + } + + var updatedAt *time.Time + if info, err := os.Stat(path); err == nil { + ts := info.ModTime().UTC() + updatedAt = &ts + } + return intPtr(criticalCount), intPtr(warningCount), updatedAt +} + func readReportScheduleFacts(orgDir string) (*int, *int) { for _, leaf := range []string{"report_schedules.json", filepath.Join("reports", "schedules.json")} { var raw json.RawMessage diff --git a/internal/cloudcp/portal/setup_facts_test.go b/internal/cloudcp/portal/setup_facts_test.go index e33213c20..f57d7dfa2 100644 --- a/internal/cloudcp/portal/setup_facts_test.go +++ b/internal/cloudcp/portal/setup_facts_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/rcourtman/pulse-go-rewrite/internal/alerts" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/notifications" ) @@ -86,6 +87,11 @@ func TestTenantDirWorkspaceSetupFactReaderCountsTenantFacts(t *testing.T) { {"name": "Monthly", "enabled": true}, {"name": "Disabled", "enabled": false}, }) + writeSetupFactJSON(t, filepath.Join(tenantsDir, "ws_one"), filepath.Join("alerts", "active-alerts.json"), []alerts.Alert{ + {ID: "critical-1", Level: alerts.AlertLevelCritical}, + {ID: "warning-1", Level: alerts.AlertLevelWarning}, + {ID: "warning-2", Level: alerts.AlertLevelWarning}, + }) facts := NewTenantDirWorkspaceSetupFactReader(tenantsDir).FactsForWorkspace("ws_one") if facts.AgentCount == nil || *facts.AgentCount != 1 { @@ -106,6 +112,15 @@ func TestTenantDirWorkspaceSetupFactReaderCountsTenantFacts(t *testing.T) { if facts.DisabledAlertRouteCount == nil || *facts.DisabledAlertRouteCount != 1 { t.Fatalf("DisabledAlertRouteCount = %v, want 1", facts.DisabledAlertRouteCount) } + if facts.ActiveCriticalAlertCount == nil || *facts.ActiveCriticalAlertCount != 1 { + t.Fatalf("ActiveCriticalAlertCount = %v, want 1", facts.ActiveCriticalAlertCount) + } + if facts.ActiveWarningAlertCount == nil || *facts.ActiveWarningAlertCount != 2 { + t.Fatalf("ActiveWarningAlertCount = %v, want 2", facts.ActiveWarningAlertCount) + } + if facts.ActiveAlertsUpdatedAt == nil { + t.Fatalf("ActiveAlertsUpdatedAt = nil, want mtime") + } if facts.ReportScheduleCount == nil || *facts.ReportScheduleCount != 1 { t.Fatalf("ReportScheduleCount = %v, want 1", facts.ReportScheduleCount) } @@ -199,6 +214,12 @@ func TestTenantDirWorkspaceSetupFactReaderMissingFactsAreZero(t *testing.T) { if facts.DisabledAlertRouteCount == nil || *facts.DisabledAlertRouteCount != 0 { t.Fatalf("DisabledAlertRouteCount = %v, want 0", facts.DisabledAlertRouteCount) } + if facts.ActiveCriticalAlertCount != nil { + t.Fatalf("ActiveCriticalAlertCount = %v, want unknown", facts.ActiveCriticalAlertCount) + } + if facts.ActiveWarningAlertCount != nil { + t.Fatalf("ActiveWarningAlertCount = %v, want unknown", facts.ActiveWarningAlertCount) + } if facts.ReportScheduleCount == nil || *facts.ReportScheduleCount != 0 { t.Fatalf("ReportScheduleCount = %v, want 0", facts.ReportScheduleCount) } @@ -215,8 +236,93 @@ func TestTenantDirWorkspaceSetupFactReaderRejectsUnsafeTenantID(t *testing.T) { facts.UnusedAgentTokenCount != nil || facts.AlertRouteCount != nil || facts.DisabledAlertRouteCount != nil || + facts.ActiveCriticalAlertCount != nil || + facts.ActiveWarningAlertCount != nil || facts.ReportScheduleCount != nil || facts.DisabledReportScheduleCount != nil { t.Fatalf("unsafe tenant facts = %+v, want empty facts", facts) } } + +func TestTenantDirWorkspaceSetupFactReaderTreatsCorruptActiveAlertFileAsUnknown(t *testing.T) { + tenantsDir := t.TempDir() + alertsDir := filepath.Join(tenantsDir, "ws_corrupt", "alerts") + orgDir := filepath.Join(tenantsDir, "ws_corrupt", "orgs", "ws_corrupt") + if err := os.MkdirAll(alertsDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(orgDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(alertsDir, "active-alerts.json"), []byte(`{"bad":`), 0o600); err != nil { + t.Fatal(err) + } + + facts := NewTenantDirWorkspaceSetupFactReader(tenantsDir).FactsForWorkspace("ws_corrupt") + if facts.ActiveCriticalAlertCount != nil || facts.ActiveWarningAlertCount != nil || facts.ActiveAlertsUpdatedAt != nil { + t.Fatalf("active alert facts = critical %v warning %v mtime %v, want unknown", facts.ActiveCriticalAlertCount, facts.ActiveWarningAlertCount, facts.ActiveAlertsUpdatedAt) + } +} + +func TestTenantDirWorkspaceSetupFactReaderCountsRuntimePersistedReportSchedules(t *testing.T) { + tenantsDir := t.TempDir() + orgDir := filepath.Join(tenantsDir, "ws_runtime", "orgs", "ws_runtime") + if err := os.MkdirAll(orgDir, 0o755); err != nil { + t.Fatal(err) + } + persistence := config.NewConfigPersistence(orgDir) + if err := persistence.SaveReportScheduleStore(config.ReportScheduleStore{ + Schedules: []config.ReportSchedule{ + { + ID: "schedule-enabled", + Name: "Monthly client report", + Enabled: true, + Cadence: config.ReportScheduleCadence{ + Type: config.ReportScheduleCadenceMonthly, + DayOfMonth: 1, + Time: "09:00", + Timezone: "UTC", + }, + Scope: config.ReportScheduleScope{ + Resources: []config.ReportScheduleResource{{ResourceType: "vm", ResourceID: "vm-1"}}, + }, + Format: config.ReportScheduleFormatPDF, + Delivery: config.ReportScheduleDelivery{ + Method: config.ReportScheduleDeliveryEmail, + Attach: true, + SaveToDisk: true, + }, + }, + { + ID: "schedule-disabled", + Name: "Disabled", + Enabled: false, + Cadence: config.ReportScheduleCadence{ + Type: config.ReportScheduleCadenceMonthly, + DayOfMonth: 1, + Time: "09:00", + Timezone: "UTC", + }, + Scope: config.ReportScheduleScope{ + Resources: []config.ReportScheduleResource{{ResourceType: "vm", ResourceID: "vm-2"}}, + }, + Format: config.ReportScheduleFormatPDF, + Delivery: config.ReportScheduleDelivery{ + Method: config.ReportScheduleDeliveryEmail, + Attach: true, + SaveToDisk: true, + }, + }, + }, + }); err != nil { + t.Fatalf("SaveReportScheduleStore: %v", err) + } + + facts := NewTenantDirWorkspaceSetupFactReader(tenantsDir).FactsForWorkspace("ws_runtime") + if facts.ReportScheduleCount == nil || *facts.ReportScheduleCount != 1 { + t.Fatalf("ReportScheduleCount = %v, want 1", facts.ReportScheduleCount) + } + if facts.DisabledReportScheduleCount == nil || *facts.DisabledReportScheduleCount != 1 { + t.Fatalf("DisabledReportScheduleCount = %v, want 1", facts.DisabledReportScheduleCount) + } +} diff --git a/internal/config/persistence.go b/internal/config/persistence.go index a73501da9..ada1c88c7 100644 --- a/internal/config/persistence.go +++ b/internal/config/persistence.go @@ -32,6 +32,7 @@ type ConfigPersistence struct { emailFile string webhookFile string appriseFile string + reportSchedulesFile string nodesFile string trueNASFile string vmwareFile string @@ -109,6 +110,7 @@ type resolvedConfigPersistencePaths struct { emailFile string webhookFile string appriseFile string + reportSchedulesFile string nodesFile string trueNASFile string vmwareFile string @@ -156,6 +158,10 @@ func resolveConfigPersistencePaths(configDir string) (string, resolvedConfigPers if err != nil { return "", resolvedConfigPersistencePaths{}, fmt.Errorf("resolve apprise.enc: %w", err) } + reportSchedulesFile, err := resolveLeaf("report_schedules.json") + if err != nil { + return "", resolvedConfigPersistencePaths{}, fmt.Errorf("resolve report_schedules.json: %w", err) + } nodesFile, err := resolveLeaf("nodes.enc") if err != nil { return "", resolvedConfigPersistencePaths{}, fmt.Errorf("resolve nodes.enc: %w", err) @@ -238,6 +244,7 @@ func resolveConfigPersistencePaths(configDir string) (string, resolvedConfigPers emailFile: emailFile, webhookFile: webhookFile, appriseFile: appriseFile, + reportSchedulesFile: reportSchedulesFile, nodesFile: nodesFile, trueNASFile: trueNASFile, vmwareFile: vmwareFile, @@ -292,6 +299,7 @@ func newConfigPersistence(configDir string) (*ConfigPersistence, error) { emailFile: resolvedPaths.emailFile, webhookFile: resolvedPaths.webhookFile, appriseFile: resolvedPaths.appriseFile, + reportSchedulesFile: resolvedPaths.reportSchedulesFile, nodesFile: resolvedPaths.nodesFile, trueNASFile: resolvedPaths.trueNASFile, vmwareFile: resolvedPaths.vmwareFile, diff --git a/internal/config/report_schedules.go b/internal/config/report_schedules.go new file mode 100644 index 000000000..086b4bad5 --- /dev/null +++ b/internal/config/report_schedules.go @@ -0,0 +1,225 @@ +package config + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "strings" + "time" +) + +const ( + ReportScheduleCadenceMonthly = "monthly" + ReportScheduleCadenceWeekly = "weekly" + + ReportScheduleFormatPDF = "pdf" + ReportScheduleFormatCSV = "csv" + + ReportScheduleDeliveryEmail = "email" + ReportScheduleDeliveryDisk = "disk" + + ReportScheduleLastRunOK = "ok" + ReportScheduleLastRunFailed = "failed" + + DefaultReportScheduleRetentionCount = 12 +) + +type ReportScheduleStore struct { + Schedules []ReportSchedule `json:"schedules"` +} + +type ReportSchedule struct { + ID string `json:"id"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + Cadence ReportScheduleCadence `json:"cadence"` + Scope ReportScheduleScope `json:"scope"` + Window string `json:"window,omitempty"` + Format string `json:"format"` + Delivery ReportScheduleDelivery `json:"delivery"` + RetentionCount int `json:"retention_count,omitempty"` + LastRunAt *time.Time `json:"last_run_at,omitempty"` + LastRunStatus string `json:"last_run_status,omitempty"` + LastError string `json:"last_error,omitempty"` + NextRunAt *time.Time `json:"next_run_at,omitempty"` + LastOccurrenceKey string `json:"last_occurrence_key,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ReportScheduleCadence struct { + Type string `json:"type"` + DayOfMonth int `json:"day_of_month,omitempty"` + Weekday string `json:"weekday,omitempty"` + Time string `json:"time"` + Timezone string `json:"timezone"` +} + +type ReportScheduleScope struct { + Resources []ReportScheduleResource `json:"resources,omitempty"` + Tags []string `json:"tags,omitempty"` +} + +type ReportScheduleResource struct { + ResourceType string `json:"resourceType"` + ResourceID string `json:"resourceId"` + Name string `json:"name,omitempty"` +} + +type ReportScheduleDelivery struct { + Method string `json:"method"` + To []string `json:"to,omitempty"` + Attach bool `json:"attach"` + SaveToDisk bool `json:"save_to_disk"` +} + +func EmptyReportScheduleStore() ReportScheduleStore { + return ReportScheduleStore{Schedules: []ReportSchedule{}} +} + +func NormalizeReportScheduleStore(store ReportScheduleStore) ReportScheduleStore { + if store.Schedules == nil { + store.Schedules = []ReportSchedule{} + } + for i := range store.Schedules { + store.Schedules[i] = NormalizeReportSchedule(store.Schedules[i]) + } + return store +} + +func NormalizeReportSchedule(schedule ReportSchedule) ReportSchedule { + schedule.ID = strings.TrimSpace(schedule.ID) + schedule.Name = strings.TrimSpace(schedule.Name) + schedule.Cadence.Type = strings.ToLower(strings.TrimSpace(schedule.Cadence.Type)) + schedule.Cadence.Weekday = strings.ToLower(strings.TrimSpace(schedule.Cadence.Weekday)) + schedule.Cadence.Time = strings.TrimSpace(schedule.Cadence.Time) + schedule.Cadence.Timezone = strings.TrimSpace(schedule.Cadence.Timezone) + schedule.Window = strings.TrimSpace(schedule.Window) + schedule.Format = strings.ToLower(strings.TrimSpace(schedule.Format)) + schedule.Delivery.Method = strings.ToLower(strings.TrimSpace(schedule.Delivery.Method)) + schedule.LastRunStatus = strings.ToLower(strings.TrimSpace(schedule.LastRunStatus)) + schedule.LastError = strings.TrimSpace(schedule.LastError) + schedule.LastOccurrenceKey = strings.TrimSpace(schedule.LastOccurrenceKey) + if schedule.RetentionCount <= 0 { + schedule.RetentionCount = DefaultReportScheduleRetentionCount + } + if schedule.Scope.Resources == nil { + schedule.Scope.Resources = []ReportScheduleResource{} + } + for i := range schedule.Scope.Resources { + schedule.Scope.Resources[i].ResourceType = strings.ToLower(strings.TrimSpace(schedule.Scope.Resources[i].ResourceType)) + schedule.Scope.Resources[i].ResourceID = strings.TrimSpace(schedule.Scope.Resources[i].ResourceID) + schedule.Scope.Resources[i].Name = strings.TrimSpace(schedule.Scope.Resources[i].Name) + } + schedule.Scope.Tags = normalizeReportScheduleStringSlice(schedule.Scope.Tags) + schedule.Delivery.To = normalizeReportScheduleStringSlice(schedule.Delivery.To) + return schedule +} + +func normalizeReportScheduleStringSlice(values []string) []string { + if values == nil { + return []string{} + } + out := make([]string, 0, len(values)) + seen := map[string]struct{}{} + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + key := strings.ToLower(trimmed) + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + out = append(out, trimmed) + } + return out +} + +func parseReportScheduleStoreJSON(data []byte) (ReportScheduleStore, error) { + if len(strings.TrimSpace(string(data))) == 0 { + return EmptyReportScheduleStore(), nil + } + var store ReportScheduleStore + if err := json.Unmarshal(data, &store); err == nil && store.Schedules != nil { + return NormalizeReportScheduleStore(store), nil + } + + var schedules []ReportSchedule + if err := json.Unmarshal(data, &schedules); err != nil { + return ReportScheduleStore{}, err + } + return NormalizeReportScheduleStore(ReportScheduleStore{Schedules: schedules}), nil +} + +func (c *ConfigPersistence) ReportSchedulesPath() string { + if c == nil { + return "" + } + return c.reportSchedulesFile +} + +func (c *ConfigPersistence) LoadReportScheduleStore() (*ReportScheduleStore, error) { + if c == nil { + store := EmptyReportScheduleStore() + return &store, nil + } + + c.mu.RLock() + data, err := c.fs.ReadFile(c.reportSchedulesFile) + cryptoMgr := c.crypto + c.mu.RUnlock() + if err != nil { + if errors.Is(err, os.ErrNotExist) { + store := EmptyReportScheduleStore() + return &store, nil + } + return nil, fmt.Errorf("read report schedules: %w", err) + } + + store, parseErr := parseReportScheduleStoreJSON(data) + if parseErr == nil { + return &store, nil + } + if cryptoMgr == nil { + return nil, fmt.Errorf("parse report schedules: %w", parseErr) + } + + decrypted, decryptErr := cryptoMgr.Decrypt(data) + if decryptErr != nil { + return nil, fmt.Errorf("decrypt report schedules: %w", decryptErr) + } + store, parseErr = parseReportScheduleStoreJSON(decrypted) + if parseErr != nil { + return nil, fmt.Errorf("parse decrypted report schedules: %w", parseErr) + } + return &store, nil +} + +func (c *ConfigPersistence) SaveReportScheduleStore(store ReportScheduleStore) error { + if c == nil { + return fmt.Errorf("config persistence is not configured") + } + + store = NormalizeReportScheduleStore(store) + data, err := json.MarshalIndent(store, "", " ") + if err != nil { + return fmt.Errorf("serialize report schedules: %w", err) + } + + c.mu.Lock() + defer c.mu.Unlock() + if c.crypto != nil { + encrypted, err := c.crypto.Encrypt(data) + if err != nil { + return fmt.Errorf("encrypt report schedules: %w", err) + } + data = encrypted + } + if err := c.writeConfigFileLocked(c.reportSchedulesFile, data, 0600); err != nil { + return fmt.Errorf("write report schedules: %w", err) + } + return nil +} diff --git a/internal/monitoring/canonical_guardrails_test.go b/internal/monitoring/canonical_guardrails_test.go index 4a6a89a6a..14d87dd50 100644 --- a/internal/monitoring/canonical_guardrails_test.go +++ b/internal/monitoring/canonical_guardrails_test.go @@ -75,6 +75,16 @@ func TestNoGetStateResourceArrayRegression(t *testing.T) { } } +func TestMultiTenantMonitorListOrganizationIDsFallsBackToDefault(t *testing.T) { + got, err := (&MultiTenantMonitor{}).ListOrganizationIDs() + if err != nil { + t.Fatalf("ListOrganizationIDs() error = %v", err) + } + if len(got) != 1 || got[0] != "default" { + t.Fatalf("ListOrganizationIDs() = %v, want [default]", got) + } +} + func TestPVETagStyleRefreshStaysPerInstance(t *testing.T) { data, err := os.ReadFile("monitor_pve.go") if err != nil { diff --git a/internal/monitoring/multi_tenant_monitor.go b/internal/monitoring/multi_tenant_monitor.go index 2fc7dcf29..08b0b678e 100644 --- a/internal/monitoring/multi_tenant_monitor.go +++ b/internal/monitoring/multi_tenant_monitor.go @@ -104,6 +104,29 @@ func (mtm *MultiTenantMonitor) ForEachMonitor(fn func(*Monitor)) { } } +// ListOrganizationIDs returns the persisted organization IDs known to the +// tenant monitor without requiring every monitor to be initialized first. +func (mtm *MultiTenantMonitor) ListOrganizationIDs() ([]string, error) { + if mtm == nil || mtm.persistence == nil { + return []string{"default"}, nil + } + orgs, err := mtm.persistence.ListOrganizations() + if err != nil { + return nil, err + } + ids := make([]string, 0, len(orgs)) + for _, org := range orgs { + if org == nil || strings.TrimSpace(org.ID) == "" { + continue + } + ids = append(ids, strings.TrimSpace(org.ID)) + } + if len(ids) == 0 { + ids = append(ids, "default") + } + return ids, nil +} + // GetMonitor returns the monitor instance for a specific organization. // It lazily initializes the monitor if it doesn't exist. func (mtm *MultiTenantMonitor) GetMonitor(orgID string) (*Monitor, error) { diff --git a/internal/notifications/email_enhanced.go b/internal/notifications/email_enhanced.go index 30f0a5b27..d144033ac 100644 --- a/internal/notifications/email_enhanced.go +++ b/internal/notifications/email_enhanced.go @@ -3,7 +3,9 @@ package notifications import ( "bytes" "crypto/tls" + "encoding/base64" "fmt" + "io" "mime/multipart" "mime/quotedprintable" "net" @@ -18,6 +20,12 @@ import ( "github.com/rs/zerolog/log" ) +type EmailAttachment struct { + Filename string + ContentType string + Data []byte +} + // loginAuth implements the SMTP LOGIN authentication mechanism. // Many mail servers (notably Microsoft 365) advertise only AUTH LOGIN // and reject AUTH PLAIN with "504 5.7.4 Unrecognized authentication type". @@ -233,6 +241,132 @@ func buildMultipartEmailMessage(addresses resolvedEmailAddresses, subject, htmlB return message.Bytes(), nil } +func buildMultipartEmailMessageWithAttachments(addresses resolvedEmailAddresses, subject, htmlBody, textBody string, attachments []EmailAttachment, now time.Time) ([]byte, error) { + if len(attachments) == 0 { + return buildMultipartEmailMessage(addresses, subject, htmlBody, textBody, now) + } + + var message bytes.Buffer + var body bytes.Buffer + mixedWriter := multipart.NewWriter(&body) + + if _, err := fmt.Fprintf(&message, "From: %s\r\n", addresses.from.String()); err != nil { + return nil, fmt.Errorf("write from header: %w", err) + } + if _, err := fmt.Fprintf(&message, "To: %s\r\n", formatHeaderAddresses(addresses.to)); err != nil { + return nil, fmt.Errorf("write to header: %w", err) + } + if addresses.replyTo != nil { + if _, err := fmt.Fprintf(&message, "Reply-To: %s\r\n", addresses.replyTo.String()); err != nil { + return nil, fmt.Errorf("write reply-to header: %w", err) + } + } + if _, err := fmt.Fprintf(&message, "Subject: %s\r\n", sanitizeEmailHeaderValue(subject)); err != nil { + return nil, fmt.Errorf("write subject header: %w", err) + } + if _, err := fmt.Fprintf(&message, "Date: %s\r\n", now.Format(time.RFC1123Z)); err != nil { + return nil, fmt.Errorf("write date header: %w", err) + } + if _, err := fmt.Fprintf(&message, "Message-ID: <%d@pulse-monitoring>\r\n", now.UnixNano()); err != nil { + return nil, fmt.Errorf("write message-id header: %w", err) + } + if _, err := message.WriteString("MIME-Version: 1.0\r\n"); err != nil { + return nil, fmt.Errorf("write mime-version header: %w", err) + } + if _, err := fmt.Fprintf(&message, "Content-Type: multipart/mixed; boundary=%q\r\n", mixedWriter.Boundary()); err != nil { + return nil, fmt.Errorf("write content-type header: %w", err) + } + if _, err := message.WriteString("X-Mailer: Pulse Monitoring System\r\n\r\n"); err != nil { + return nil, fmt.Errorf("write x-mailer header: %w", err) + } + + var alternativeBody bytes.Buffer + alternativeWriter := multipart.NewWriter(&alternativeBody) + if err := writeMultipartBodyPart(alternativeWriter, "text/plain", textBody); err != nil { + return nil, err + } + if err := writeMultipartBodyPart(alternativeWriter, "text/html", htmlBody); err != nil { + return nil, err + } + if err := alternativeWriter.Close(); err != nil { + return nil, fmt.Errorf("close alternative writer: %w", err) + } + alternativeHeader := textproto.MIMEHeader{} + alternativeHeader.Set("Content-Type", fmt.Sprintf("multipart/alternative; boundary=%q", alternativeWriter.Boundary())) + alternativePart, err := mixedWriter.CreatePart(alternativeHeader) + if err != nil { + return nil, fmt.Errorf("create alternative part: %w", err) + } + if _, err := alternativePart.Write(alternativeBody.Bytes()); err != nil { + return nil, fmt.Errorf("write alternative part: %w", err) + } + + for _, attachment := range attachments { + if len(attachment.Data) == 0 { + continue + } + filename := sanitizeEmailHeaderValue(strings.TrimSpace(attachment.Filename)) + if filename == "" { + filename = "attachment" + } + contentType := strings.TrimSpace(attachment.ContentType) + if contentType == "" { + contentType = "application/octet-stream" + } + header := textproto.MIMEHeader{} + header.Set("Content-Type", fmt.Sprintf("%s; name=%q", contentType, filename)) + header.Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) + header.Set("Content-Transfer-Encoding", "base64") + part, err := mixedWriter.CreatePart(header) + if err != nil { + return nil, fmt.Errorf("create attachment part: %w", err) + } + encoder := base64.NewEncoder(base64.StdEncoding, newBase64LineWriter(part)) + if _, err := encoder.Write(attachment.Data); err != nil { + _ = encoder.Close() + return nil, fmt.Errorf("encode attachment: %w", err) + } + if err := encoder.Close(); err != nil { + return nil, fmt.Errorf("close attachment encoder: %w", err) + } + } + + if err := mixedWriter.Close(); err != nil { + return nil, fmt.Errorf("close mixed writer: %w", err) + } + if _, err := message.Write(body.Bytes()); err != nil { + return nil, fmt.Errorf("write multipart body: %w", err) + } + return message.Bytes(), nil +} + +type base64LineWriter struct { + w io.Writer + count int +} + +func newBase64LineWriter(w io.Writer) *base64LineWriter { + return &base64LineWriter{w: w} +} + +func (w *base64LineWriter) Write(p []byte) (int, error) { + written := 0 + for _, b := range p { + if w.count == 76 { + if _, err := w.w.Write([]byte("\r\n")); err != nil { + return written, err + } + w.count = 0 + } + if _, err := w.w.Write([]byte{b}); err != nil { + return written, err + } + w.count++ + written++ + } + return written, nil +} + // negotiateAuth queries the server for supported AUTH mechanisms after EHLO // and returns the best smtp.Auth to use. Prefers PLAIN, falls back to LOGIN. // Returns nil if auth is not configured. @@ -312,6 +446,10 @@ func NewEnhancedEmailManager(config EmailProviderConfig) *EnhancedEmailManager { // Total attempts = MaxRetries * MaxAttempts (e.g., 3 * 3 = 9 SMTP calls for a single notification) // This ensures delivery even during transient failures at either layer. func (e *EnhancedEmailManager) SendEmailWithRetry(subject, htmlBody, textBody string) error { + return e.SendEmailWithAttachments(subject, htmlBody, textBody, nil) +} + +func (e *EnhancedEmailManager) SendEmailWithAttachments(subject, htmlBody, textBody string, attachments []EmailAttachment) error { var lastErr error for attempt := 0; attempt <= e.config.MaxRetries; attempt++ { @@ -331,7 +469,7 @@ func (e *EnhancedEmailManager) SendEmailWithRetry(subject, htmlBody, textBody st } // Try to send - err := e.sendEmailOnce(subject, htmlBody, textBody) + err := e.sendEmailOnceWithAttachments(subject, htmlBody, textBody, attachments) if err == nil { if attempt > 0 { log.Info(). @@ -376,14 +514,18 @@ func (e *EnhancedEmailManager) checkRateLimit() error { return nil } -// sendEmailOnce sends a single email +// sendEmailOnce sends a single email. func (e *EnhancedEmailManager) sendEmailOnce(subject, htmlBody, textBody string) error { + return e.sendEmailOnceWithAttachments(subject, htmlBody, textBody, nil) +} + +func (e *EnhancedEmailManager) sendEmailOnceWithAttachments(subject, htmlBody, textBody string, attachments []EmailAttachment) error { addresses, err := e.resolveEmailAddresses() if err != nil { return err } - msg, err := buildMultipartEmailMessage(addresses, subject, htmlBody, textBody, time.Now()) + msg, err := buildMultipartEmailMessageWithAttachments(addresses, subject, htmlBody, textBody, attachments, time.Now()) if err != nil { return err } diff --git a/internal/notifications/email_enhanced_test.go b/internal/notifications/email_enhanced_test.go index 02c14bf3f..1d372d5ab 100644 --- a/internal/notifications/email_enhanced_test.go +++ b/internal/notifications/email_enhanced_test.go @@ -4,6 +4,7 @@ import ( "bufio" "bytes" "crypto/tls" + "encoding/base64" "fmt" "io" "mime" @@ -425,6 +426,43 @@ func TestBuildMultipartEmailMessage_EncodesMultipartBodies(t *testing.T) { } } +func TestBuildMultipartEmailMessageWithAttachments_EncodesReportAttachment(t *testing.T) { + addresses := resolvedEmailAddresses{ + from: &mail.Address{Address: "sender@example.com"}, + to: []*mail.Address{{Address: "recipient@example.com"}}, + } + + attachment := EmailAttachment{ + Filename: "report.pdf", + ContentType: "application/pdf", + Data: []byte("%PDF-1.7\nreport\n"), + } + msg, err := buildMultipartEmailMessageWithAttachments( + addresses, + "Scheduled report", + "

Attached

", + "Attached", + []EmailAttachment{attachment}, + time.Unix(1711711711, 1234).UTC(), + ) + if err != nil { + t.Fatalf("buildMultipartEmailMessageWithAttachments() error = %v", err) + } + + raw := string(msg) + for _, want := range []string{ + `Subject: Scheduled report`, + `Content-Disposition: attachment; filename="report.pdf"`, + `Content-Type: application/pdf`, + `Content-Transfer-Encoding: base64`, + base64.StdEncoding.EncodeToString(attachment.Data), + } { + if !strings.Contains(raw, want) { + t.Fatalf("attachment message missing %q in:\n%s", want, raw) + } + } +} + func TestTestConnection_TLSRouting(t *testing.T) { tests := []struct { name string diff --git a/pkg/extensions/reporting_admin.go b/pkg/extensions/reporting_admin.go index 1dd5d3b03..cb0bf21c2 100644 --- a/pkg/extensions/reporting_admin.go +++ b/pkg/extensions/reporting_admin.go @@ -16,6 +16,18 @@ type ReportingAdminEndpoints interface { HandleExportVMInventory(http.ResponseWriter, *http.Request) } +// ReportingScheduleAdminEndpoints is an optional extension surface for +// scheduled report management. It is separate from ReportingAdminEndpoints so +// existing enterprise binders keep compiling while newer binders can decorate +// the schedule API explicitly. +type ReportingScheduleAdminEndpoints interface { + HandleListReportSchedules(http.ResponseWriter, *http.Request) + HandleCreateReportSchedule(http.ResponseWriter, *http.Request) + HandleUpdateReportSchedule(http.ResponseWriter, *http.Request) + HandleDeleteReportSchedule(http.ResponseWriter, *http.Request) + HandleRunReportSchedule(http.ResponseWriter, *http.Request) +} + // WriteReportingErrorFunc writes a structured reporting error response. type WriteReportingErrorFunc func(http.ResponseWriter, int, string, string, map[string]string) diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index 42fc9c0fc..bfa1a2d97 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -2917,8 +2917,8 @@ class SubsystemLookupTest(unittest.TestCase): { "heading": "## Shared Boundaries", "path": "internal/api/access_control_handlers.go", - "line": 1176, - "heading_line": 137, + "line": 1179, + "heading_line": 140, } ], )