Add MSP report scheduling and alert rollup

This commit is contained in:
rcourtman 2026-07-07 20:37:18 +01:00
parent d43255889d
commit 4382919447
51 changed files with 3399 additions and 118 deletions

View file

@ -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 |

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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`

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 |

View file

@ -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<ReportingFormat, typeof FileText> = {
@ -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() {
</section>
</Show>
<section class="space-y-4 border-t border-base-300/80 pt-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="space-y-1">
<h4 class="text-base font-semibold text-base-content">Scheduled reports</h4>
<p class="text-sm text-muted">
Send recurring client performance reports using the same resource scope and branding as generated reports.
</p>
</div>
<div class="flex flex-wrap gap-2">
<Button variant="secondary" size="sm" class="gap-2" onClick={reloadReportSchedules}>
<RefreshCw size={16} />
Refresh
</Button>
<Button variant="primary" size="sm" class="gap-2" onClick={startCreateSchedule}>
<Plus size={16} />
Create schedule
</Button>
</div>
</div>
<Show when={reportSchedulesLoading()}>
<p class="text-sm text-muted">Loading report schedules...</p>
</Show>
<Show when={reportSchedulesError()}>
<p class="text-sm text-warning">{reportSchedulesError()}</p>
</Show>
<Show
when={reportSchedules().length > 0}
fallback={
<div class="flex flex-col gap-3 border border-dashed border-base-300 p-4 sm:flex-row sm:items-center sm:justify-between">
<p class="text-sm text-muted">
No scheduled reports are configured yet.
</p>
<Button variant="secondary" size="sm" class="gap-2" onClick={startCreateSchedule}>
<Plus size={16} />
Create schedule
</Button>
</div>
}
>
<div class="overflow-x-auto rounded-md border border-base-300">
<table class="w-full min-w-[980px] table-fixed text-left text-sm">
<colgroup>
<col class="w-[18%]" />
<col class="w-[16%]" />
<col class="w-[14%]" />
<col class="w-[14%]" />
<col class="w-[16%]" />
<col class="w-[8%]" />
<col class="w-[14%]" />
</colgroup>
<thead class="border-b border-base-300 bg-base-200/50 text-xs uppercase text-muted">
<tr>
<th class="px-3 py-2 font-semibold">Name</th>
<th class="px-3 py-2 font-semibold">Cadence</th>
<th class="px-3 py-2 font-semibold">Scope</th>
<th class="px-3 py-2 font-semibold">Delivery</th>
<th class="px-3 py-2 font-semibold">Last run</th>
<th class="px-3 py-2 font-semibold">Enabled</th>
<th class="px-3 py-2 text-right font-semibold">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-base-300">
<For each={reportSchedules()}>
{(schedule) => (
<tr>
<td class="truncate px-3 py-2 font-medium text-base-content" title={schedule.name}>
{schedule.name}
</td>
<td class="truncate px-3 py-2 text-muted" title={reportScheduleCadenceLabel(schedule)}>
{reportScheduleCadenceLabel(schedule)}
</td>
<td class="truncate px-3 py-2 text-muted" title={reportScheduleScopeLabel(schedule)}>
{reportScheduleScopeLabel(schedule)}
</td>
<td class="truncate px-3 py-2 text-muted" title={reportScheduleDeliveryLabel(schedule)}>
{reportScheduleDeliveryLabel(schedule)}
</td>
<td class="truncate px-3 py-2 text-muted" title={schedule.last_error || ''}>
<div class="truncate">{reportScheduleLastRunLabel(schedule)}</div>
<Show when={schedule.last_run_at}>
<div class="truncate text-xs text-muted">
{formatReportScheduleTime(schedule.last_run_at)}
</div>
</Show>
</td>
<td class="px-3 py-2">
<label class="inline-flex items-center">
<input
type="checkbox"
class="h-4 w-4"
checked={schedule.enabled}
onChange={() => toggleReportSchedule(schedule)}
/>
</label>
</td>
<td class="px-3 py-2">
<div class="flex justify-end gap-1">
<Button
variant="ghost"
size="sm"
class="gap-1 px-2"
title="Run now"
isLoading={runningScheduleID() === schedule.id}
disabled={runningScheduleID() !== ''}
onClick={() => runReportScheduleNow(schedule)}
>
<Show when={runningScheduleID() !== schedule.id}>
<Play size={15} />
</Show>
Run
</Button>
<Button
variant="ghost"
size="sm"
class="gap-1 px-2"
title="Edit"
onClick={() => startEditSchedule(schedule)}
>
<Pencil size={15} />
Edit
</Button>
<Button
variant="ghost"
size="sm"
class="gap-1 px-2"
title="Delete"
isLoading={deletingScheduleID() === schedule.id}
disabled={deletingScheduleID() !== ''}
onClick={() => deleteReportSchedule(schedule)}
>
<Trash2 size={15} />
Delete
</Button>
</div>
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</Show>
<Show when={scheduleFormOpen()}>
<section class="space-y-4 border-y border-base-300 py-4">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField label="Schedule name">
<input
type="text"
class={formControl}
value={scheduleForm().name}
onInput={(e) => updateScheduleForm({ name: e.currentTarget.value })}
placeholder="Monthly client report"
/>
</FormField>
<FormField label="Timezone">
<input
type="text"
class={formControl}
value={scheduleForm().timezone}
onInput={(e) => updateScheduleForm({ timezone: e.currentTarget.value })}
placeholder="Europe/London"
/>
</FormField>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-4">
<FormField label="Cadence">
<select
class={formControl}
value={scheduleForm().cadenceType}
onChange={(e) => updateScheduleForm({ cadenceType: e.currentTarget.value as 'monthly' | 'weekly' })}
>
<option value="monthly">Monthly</option>
<option value="weekly">Weekly</option>
</select>
</FormField>
<Show
when={scheduleForm().cadenceType === 'monthly'}
fallback={
<FormField label="Weekday">
<select
class={formControl}
value={scheduleForm().weekday}
onChange={(e) => updateScheduleForm({ weekday: e.currentTarget.value })}
>
<For each={WEEKDAY_OPTIONS}>
{(day) => <option value={day}>{day[0].toUpperCase() + day.slice(1)}</option>}
</For>
</select>
</FormField>
}
>
<FormField label="Day of month">
<input
type="number"
min="1"
max="28"
class={formControl}
value={scheduleForm().dayOfMonth}
onInput={(e) => updateScheduleForm({ dayOfMonth: Number(e.currentTarget.value) })}
/>
</FormField>
</Show>
<FormField label="Time">
<input
type="time"
class={formControl}
value={scheduleForm().time}
onInput={(e) => updateScheduleForm({ time: e.currentTarget.value })}
/>
</FormField>
<FormField label="Format">
<select
class={formControl}
value={scheduleForm().format}
onChange={(e) => updateScheduleForm({ format: e.currentTarget.value as ReportingFormat })}
>
<option value="pdf">PDF</option>
<option value="csv">CSV</option>
</select>
</FormField>
</div>
<FormField
label="Resources"
helpText="Use explicit resources, tags, or both. Scheduled reports use the previous reporting boundary."
>
<ResourcePicker
maxSelection={performanceReport()?.multiResourceMax}
selected={scheduleResources}
onSelectionChange={setScheduleResources}
/>
</FormField>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<FormField label="Tag filter" helpText="Comma-separated tags">
<input
type="text"
class={formControl}
value={scheduleForm().tagFilter}
onInput={(e) => updateScheduleForm({ tagFilter: e.currentTarget.value })}
placeholder="production, customer-facing"
/>
</FormField>
<FormField label="Delivery">
<select
class={formControl}
value={scheduleForm().deliveryMethod}
onChange={(e) => updateScheduleForm({ deliveryMethod: e.currentTarget.value as 'email' | 'disk' })}
>
<option value="email">Email recipients</option>
<option value="disk">Save to disk</option>
</select>
</FormField>
<FormField label="Retention">
<input
type="number"
min="1"
max="120"
class={formControl}
value={scheduleForm().retentionCount}
onInput={(e) => updateScheduleForm({ retentionCount: Number(e.currentTarget.value) })}
/>
</FormField>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField label="Email recipients" helpText="Blank uses the existing email notification recipients">
<input
type="text"
class={formControl}
value={scheduleForm().recipients}
onInput={(e) => updateScheduleForm({ recipients: e.currentTarget.value })}
placeholder="client@example.com, ops@example.com"
disabled={scheduleForm().deliveryMethod !== 'email'}
/>
</FormField>
<div class="grid grid-cols-1 gap-3 pt-6 sm:grid-cols-3">
<label class="inline-flex items-center gap-2 text-sm text-muted">
<input
type="checkbox"
checked={scheduleForm().enabled}
onChange={(e) => updateScheduleForm({ enabled: e.currentTarget.checked })}
/>
Enabled
</label>
<label class="inline-flex items-center gap-2 text-sm text-muted">
<input
type="checkbox"
checked={scheduleForm().attach}
onChange={(e) => updateScheduleForm({ attach: e.currentTarget.checked })}
disabled={scheduleForm().deliveryMethod !== 'email'}
/>
Attach
</label>
<label class="inline-flex items-center gap-2 text-sm text-muted">
<input
type="checkbox"
checked={scheduleForm().saveToDisk}
onChange={(e) => updateScheduleForm({ saveToDisk: e.currentTarget.checked })}
/>
Save copy
</label>
</div>
</div>
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button variant="secondary" size="md" class="gap-2" onClick={closeScheduleForm}>
<X size={16} />
Cancel
</Button>
<Button
variant="primary"
size="md"
class="gap-2"
isLoading={savingSchedule()}
disabled={savingSchedule()}
onClick={saveReportSchedule}
>
<Save size={16} />
Save schedule
</Button>
</div>
</section>
</Show>
</section>
<Show when={inventoryDefinition()}>
<section class="space-y-4 rounded-xl border border-base-300/80 bg-base-200/30 p-4 sm:p-5">
<div class="space-y-2">

View file

@ -72,6 +72,9 @@ const baseCatalog = {
function buildState(overrides: Record<string, unknown> = {}) {
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<string, unknown> = {}) {
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(() => <ReportingPanel />);
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({

View file

@ -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({

View file

@ -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');
});
});

View file

@ -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<typeof vi.fn>;
@ -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<typeof import('@/utils/apiClient')>('@/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,9 +319,7 @@ describe('useReportingPanelState', () => {
it('extracts structured API error messages for reporting catalog failures', async () => {
vi.resetModules();
apiFetchMock = vi
.fn()
.mockResolvedValueOnce(
apiFetchMock = buildCatalogAndSchedulesFetchMock(
new Response(JSON.stringify({ error: 'Catalog is unavailable right now' }), {
status: 503,
}),
@ -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<typeof import('@/utils/apiClient')>('@/utils/apiClient');

View file

@ -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),
};
}

View file

@ -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<string, string> = {
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<ReportSchedule, 'created_at' | 'updated_at'> {
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<string>();
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' });
}

View file

@ -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<SelectedResource[]>([]);
@ -41,6 +51,18 @@ export const useReportingPanelState = () => {
const [reportingCatalogError, setReportingCatalogError] = createSignal('');
const [reportingCatalogAttempted, setReportingCatalogAttempted] = createSignal(false);
const [title, setTitle] = createSignal('');
const [reportSchedules, setReportSchedules] = createSignal<ReportSchedule[]>([]);
const [reportSchedulesLoading, setReportSchedulesLoading] = createSignal(false);
const [reportSchedulesAttempted, setReportSchedulesAttempted] = createSignal(false);
const [reportSchedulesError, setReportSchedulesError] = createSignal('');
const [scheduleFormOpen, setScheduleFormOpen] = createSignal(false);
const [scheduleForm, setScheduleForm] = createSignal<ReportScheduleFormState>(
DEFAULT_REPORT_SCHEDULE_FORM(),
);
const [scheduleResources, setScheduleResources] = createSignal<SelectedResource[]>([]);
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<ReportScheduleFormState>) => {
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,
};
};

View file

@ -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")

View file

@ -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{

View file

@ -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

View file

@ -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 {

View file

@ -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 := "<p>Your scheduled Pulse report is ready.</p>"
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 = "<p>Your scheduled Pulse report was generated but is too large to attach.</p><p>Saved path: " + htmlEscape(path) + "</p>"
textBody = "Your scheduled Pulse report was generated but is too large to attach.\nSaved path: " + path
} else {
htmlBody = "<p>Your scheduled Pulse report was generated and saved to disk.</p><p>Saved path: " + htmlEscape(path) + "</p>"
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("&", "&amp;", "<", "&lt;", ">", "&gt;", "\"", "&quot;", "'", "&#39;")
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")
}
}
}

View file

@ -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)
}
}

View file

@ -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",

View file

@ -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)
})
}
})
}

View file

@ -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,

View file

@ -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: ""},

View file

@ -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,

View file

@ -1,5 +1,5 @@
{
"source_hash": "f5a44a4680080b9767af0ee2c44469b71199f0c46f7b4d2da16f21de97dba024",
"source_hash": "ba206fd32b57fa162bf26df7e590c49f43a953f096c908858a2ddd79c8909396",
"build_inputs": [
"package.json",
"tsconfig.json",

View file

@ -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;

File diff suppressed because one or more lines are too long

View file

@ -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 '<span class="badge badge-setup-' + escapeHTML(setup) + '">' + escapeHTML(workspaceSetupLabel(workspace)) + '</span>';
}
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 '<span class="badge badge-alert-' + escapeAttr(state) + '" title="' + escapeAttr(title) + '">' + escapeHTML(label) + '</span>';
}
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
'<div class="workspace-row-status-cell workspace-row-status-cell-badge">' +
setupBadgeHTML(workspace) +
'</div>' +
'<div class="workspace-row-status-cell workspace-row-status-cell-badge">' +
alertBadgeHTML(workspace) +
'</div>' +
'<div class="workspace-row-status-cell workspace-row-status-cell-badge">' +
healthBadgeHTML(workspace) +
'</div>' +
@ -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
'<div class="workspace-list-head">' +
'<span>' + escapeHTML(clientLanguage ? 'Client' : 'Workspace') + '</span>' +
'<span>Setup</span>' +
'<span>Alerts</span>' +
'<span>Health</span>' +
'<span>Actions</span>' +
'</div>' +

View file

@ -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 {

View file

@ -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;

View file

@ -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;
}

View file

@ -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
//

View file

@ -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(&registry.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

View file

@ -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 {

View file

@ -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

View file

@ -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)
}
}

View file

@ -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,

View file

@ -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
}

View file

@ -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 {

View file

@ -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) {

View file

@ -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
}

View file

@ -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",
"<p>Attached</p>",
"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

View file

@ -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)

View file

@ -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,
}
],
)