mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Add MSP report scheduling and alert rollup
This commit is contained in:
parent
d43255889d
commit
4382919447
51 changed files with 3399 additions and 118 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
16
docs/MSP.md
16
docs/MSP.md
|
|
@ -192,6 +192,11 @@ that client's resources:
|
|||
`POST /api/admin/reports/generate-multi` (up to 50 resources per report),
|
||||
returning PDF or CSV. In shared-process mode, scope with `X-Pulse-Org-ID`
|
||||
or an org-bound token.
|
||||
- **Schedules**: `GET`/`POST /api/admin/reports/schedules`,
|
||||
`PUT`/`DELETE /api/admin/reports/schedules/{id}`, and
|
||||
`POST /api/admin/reports/schedules/{id}/run`. Schedules can target explicit
|
||||
resources and/or comma-separated resource tags, choose weekly or monthly
|
||||
cadence, and deliver PDF or CSV output by email or to disk.
|
||||
|
||||
Report branding (logo + display name) supports a provider-wide default via
|
||||
environment (`PULSE_REPORT_PROVIDER_BRAND_DISPLAY_NAME`,
|
||||
|
|
@ -202,9 +207,14 @@ per-client; in shared-process mode the settings override applies
|
|||
instance-wide, so all organizations share one brand (usually yours). Branding
|
||||
requires the `white_label` entitlement on the licence.
|
||||
|
||||
Pulse does not yet schedule recurring reports; generate monthly client reports
|
||||
on demand from the UI, or call the report API from your own scheduler with an
|
||||
org-bound token.
|
||||
Scheduled reports are tenant-local. In provider-hosted MSP, each client
|
||||
runtime stores its own schedules in `report_schedules.json`, writes generated
|
||||
outputs under `reports/generated/`, and applies its own SMTP settings,
|
||||
recipients, resource tags, branding, and entitlement checks. If email delivery
|
||||
is selected before SMTP is configured, Pulse records the run and saves the
|
||||
report to disk instead of sending it. The Pulse Account portal may show whether
|
||||
a workspace has an enabled report schedule, but it does not render cross-client
|
||||
reports or collect report data in the provider control plane.
|
||||
|
||||
## Licensing
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
851
internal/api/report_schedules.go
Normal file
851
internal/api/report_schedules.go
Normal 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("&", "&", "<", "<", ">", ">", "\"", """, "'", "'")
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
172
internal/api/report_schedules_test.go
Normal file
172
internal/api/report_schedules_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: ""},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"source_hash": "f5a44a4680080b9767af0ee2c44469b71199f0c46f7b4d2da16f21de97dba024",
|
||||
"source_hash": "ba206fd32b57fa162bf26df7e590c49f43a953f096c908858a2ddd79c8909396",
|
||||
"build_inputs": [
|
||||
"package.json",
|
||||
"tsconfig.json",
|
||||
|
|
|
|||
24
internal/cloudcp/portal/dist/portal_app.css
vendored
24
internal/cloudcp/portal/dist/portal_app.css
vendored
|
|
@ -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;
|
||||
|
|
|
|||
78
internal/cloudcp/portal/dist/portal_app.js
vendored
78
internal/cloudcp/portal/dist/portal_app.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -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>' +
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
//
|
||||
|
|
|
|||
|
|
@ -132,6 +132,9 @@ type dashboardResp struct {
|
|||
LastAgentSeenAt *time.Time `json:"last_agent_seen_at"`
|
||||
AlertRouteCount *int `json:"alert_route_count"`
|
||||
DisabledAlertRouteCount *int `json:"disabled_alert_route_count"`
|
||||
ActiveCriticalAlertCount *int `json:"active_critical_alert_count"`
|
||||
ActiveWarningAlertCount *int `json:"active_warning_alert_count"`
|
||||
ActiveAlertsUpdatedAt *time.Time `json:"active_alerts_updated_at"`
|
||||
ReportScheduleCount *int `json:"report_schedule_count"`
|
||||
DisabledReportScheduleCount *int `json:"disabled_report_schedule_count"`
|
||||
LastHealthCheck *time.Time `json:"last_health_check"`
|
||||
|
|
@ -143,6 +146,7 @@ type dashboardResp struct {
|
|||
Healthy int `json:"healthy"`
|
||||
Unhealthy int `json:"unhealthy"`
|
||||
Suspended int `json:"suspended"`
|
||||
Critical int `json:"critical"`
|
||||
} `json:"summary"`
|
||||
}
|
||||
|
||||
|
|
@ -262,6 +266,9 @@ func TestPortalDashboard(t *testing.T) {
|
|||
LastAgentSeenAt: ws.LastAgentSeenAt,
|
||||
AlertRouteCount: ws.AlertRouteCount,
|
||||
DisabledAlertRouteCount: ws.DisabledAlertRouteCount,
|
||||
ActiveCriticalAlertCount: ws.ActiveCriticalAlertCount,
|
||||
ActiveWarningAlertCount: ws.ActiveWarningAlertCount,
|
||||
ActiveAlertsUpdatedAt: ws.ActiveAlertsUpdatedAt,
|
||||
ReportScheduleCount: ws.ReportScheduleCount,
|
||||
DisabledReportScheduleCount: ws.DisabledReportScheduleCount,
|
||||
LastHealthCheck: ws.LastHealthCheck,
|
||||
|
|
@ -434,6 +441,9 @@ func TestPortalDashboardUsesWorkspaceSetupFacts(t *testing.T) {
|
|||
LastAgentSeenAt: ws.LastAgentSeenAt,
|
||||
AlertRouteCount: ws.AlertRouteCount,
|
||||
DisabledAlertRouteCount: ws.DisabledAlertRouteCount,
|
||||
ActiveCriticalAlertCount: ws.ActiveCriticalAlertCount,
|
||||
ActiveWarningAlertCount: ws.ActiveWarningAlertCount,
|
||||
ActiveAlertsUpdatedAt: ws.ActiveAlertsUpdatedAt,
|
||||
ReportScheduleCount: ws.ReportScheduleCount,
|
||||
DisabledReportScheduleCount: ws.DisabledReportScheduleCount,
|
||||
LastHealthCheck: ws.LastHealthCheck,
|
||||
|
|
@ -469,6 +479,89 @@ func TestPortalDashboardUsesWorkspaceSetupFacts(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestPortalDashboardRollsUpActiveAlertsAndSortsCriticalFirst(t *testing.T) {
|
||||
reg := newTestRegistry(t)
|
||||
mux := http.NewServeMux()
|
||||
|
||||
accountID, err := registry.GenerateAccountID()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := reg.CreateAccount(®istry.Account{ID: accountID, Kind: registry.AccountKindMSP, DisplayName: "Example MSP"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
criticalID, err := registry.GenerateTenantID()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unknownID, err := registry.GenerateTenantID()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 4, 1, 10, 0, 0, 0, time.UTC)
|
||||
for _, tenant := range []*registry.Tenant{
|
||||
{ID: unknownID, AccountID: accountID, DisplayName: "Unknown Alerts", State: registry.TenantStateActive, CreatedAt: now, HealthCheckOK: true},
|
||||
{ID: criticalID, AccountID: accountID, DisplayName: "Critical Alerts", State: registry.TenantStateActive, CreatedAt: now, HealthCheckOK: true},
|
||||
} {
|
||||
if err := reg.Create(tenant); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
mux.Handle(PortalDashboardPath, admin.AdminKeyMiddleware("secret-key", HandlePortalDashboardWithSetupFacts(reg, staticSetupFactReader{
|
||||
criticalID: {
|
||||
AgentCount: intPtr(1),
|
||||
AlertRouteCount: intPtr(1),
|
||||
ReportScheduleCount: intPtr(1),
|
||||
ActiveCriticalAlertCount: intPtr(2),
|
||||
ActiveWarningAlertCount: intPtr(3),
|
||||
ActiveAlertsUpdatedAt: &now,
|
||||
DisabledAlertRouteCount: intPtr(0),
|
||||
DisabledReportScheduleCount: intPtr(0),
|
||||
},
|
||||
unknownID: {
|
||||
AgentCount: intPtr(1),
|
||||
AlertRouteCount: intPtr(1),
|
||||
ReportScheduleCount: intPtr(1),
|
||||
DisabledAlertRouteCount: intPtr(0),
|
||||
DisabledReportScheduleCount: intPtr(0),
|
||||
},
|
||||
})))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/portal/dashboard?account_id="+accountID, nil)
|
||||
rec := doRequest(t, mux, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp dashboardResp
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v (body=%q)", err, rec.Body.String())
|
||||
}
|
||||
if len(resp.Workspaces) != 2 {
|
||||
t.Fatalf("workspaces len = %d, want 2", len(resp.Workspaces))
|
||||
}
|
||||
if resp.Workspaces[0].ID != criticalID {
|
||||
t.Fatalf("first workspace = %s, want critical workspace %s", resp.Workspaces[0].ID, criticalID)
|
||||
}
|
||||
if resp.Workspaces[0].ActiveCriticalAlertCount == nil || *resp.Workspaces[0].ActiveCriticalAlertCount != 2 {
|
||||
t.Fatalf("critical active_critical_alert_count = %v, want 2", resp.Workspaces[0].ActiveCriticalAlertCount)
|
||||
}
|
||||
if resp.Workspaces[0].ActiveWarningAlertCount == nil || *resp.Workspaces[0].ActiveWarningAlertCount != 3 {
|
||||
t.Fatalf("critical active_warning_alert_count = %v, want 3", resp.Workspaces[0].ActiveWarningAlertCount)
|
||||
}
|
||||
if resp.Workspaces[0].ActiveAlertsUpdatedAt == nil || !resp.Workspaces[0].ActiveAlertsUpdatedAt.Equal(now) {
|
||||
t.Fatalf("critical active_alerts_updated_at = %v, want %v", resp.Workspaces[0].ActiveAlertsUpdatedAt, now)
|
||||
}
|
||||
if resp.Workspaces[1].ActiveCriticalAlertCount != nil || resp.Workspaces[1].ActiveWarningAlertCount != nil {
|
||||
t.Fatalf("unknown alert counts = critical %v warning %v, want nil", resp.Workspaces[1].ActiveCriticalAlertCount, resp.Workspaces[1].ActiveWarningAlertCount)
|
||||
}
|
||||
if resp.Summary.Critical != 1 {
|
||||
t.Fatalf("summary.critical = %d, want 1", resp.Summary.Critical)
|
||||
}
|
||||
}
|
||||
|
||||
type dashboardRespWorkspace struct {
|
||||
ID string
|
||||
DisplayName string
|
||||
|
|
@ -481,6 +574,9 @@ type dashboardRespWorkspace struct {
|
|||
LastAgentSeenAt *time.Time
|
||||
AlertRouteCount *int
|
||||
DisabledAlertRouteCount *int
|
||||
ActiveCriticalAlertCount *int
|
||||
ActiveWarningAlertCount *int
|
||||
ActiveAlertsUpdatedAt *time.Time
|
||||
ReportScheduleCount *int
|
||||
DisabledReportScheduleCount *int
|
||||
LastHealthCheck *time.Time
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
225
internal/config/report_schedules.go
Normal file
225
internal/config/report_schedules.go
Normal 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
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue