diff --git a/docs/PATROL_WEEKLY_DIGEST.md b/docs/PATROL_WEEKLY_DIGEST.md index 584538e2b5..bcca2943e4 100644 --- a/docs/PATROL_WEEKLY_DIGEST.md +++ b/docs/PATROL_WEEKLY_DIGEST.md @@ -1,9 +1,11 @@ # Patrol Weekly Digest Status: building — endpoint and in-app "This week" card on `main` (PRs #1856 -and #1860, 2026-09-02); the weekly email lands as a `patrol_digest` report -schedule kind. Demand ledger: `pulse-pro/FEATURE_REQUESTS.md`, "Patrol weekly digest -(what Patrol did for you)", a named bet under the Patrol operations loop. +and #1860, 2026-09-02); the weekly email is the `patrol_digest` report +schedule kind, and Settings > Reporting creates it through the "Report type" +choice on the schedule form (2026-10-02). Demand ledger: +`pulse-pro/FEATURE_REQUESTS.md`, "Patrol weekly digest (what Patrol did for +you)", a named bet under the Patrol operations loop. ## The job, in the customer's words @@ -80,9 +82,18 @@ reused, and the run renders the same digest as plain-language HTML and text `SendEmailWithRetry`. Digest schedules are weekly and email-only; nothing is written to disk. A run without an email destination fails with a message that names the missing setting instead of silently doing nothing. Webhook and -Apprise channels are out of scope. The Settings > Reporting form gains a -"Report type" selector for the kind in a follow-up; its browser proof needs a -Pro-licensed instance, which the isolated verification stack does not have. +Apprise channels are out of scope. + +In Settings > Reporting, "Create schedule" offers a "Report type" choice +between the performance report and the Patrol weekly summary. The choice only +appears while the session reports that Patrol can run (AI on with a provider +configured, the same capability that shows the Patrol navigation), and an +existing summary schedule keeps showing its type so it can be edited after AI +is switched off. Choosing the summary reduces the form to a name, weekday, +time, timezone, and optional recipients: cadence is pinned to weekly, delivery +to email, and the format, resources, tag filter, retention, attachment, and +save-copy controls are hidden because the server fixes those values. The +schedules table describes a summary's scope as "Patrol activity, last 7 days". ## API diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index d945c80542..fdfe7163a9 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -7729,3 +7729,20 @@ actual series pixels and tooltips in desktop Chromium and phone WebKit, using the production chart and API transport with synthetic stored samples. This does not establish native collection, whole-report resolution or availability in a release. Existing request, source, units and access boundaries are unchanged. + +### Scheduled report kinds + +The Settings > Reporting schedule form composes the shared `FormSelect` for a +"Report type" choice between the performance report and the Patrol weekly +summary (`patrol_digest`). The choice renders only while the session's AI +capability reports Patrol can run, or while an existing summary schedule is +being edited, so an AI-off install sees the unchanged performance-report form. +The summary variant hides the cadence, format, resource picker, tag filter, +delivery, retention, attach, and save-copy controls and keeps name, weekday, +time, timezone, recipients, and enabled; `reportingSchedulesModel.ts` owns the +kind normalisation, the kind switch, the email-only payload, and the plain +"Patrol activity, last 7 days" scope label. `ReportingPanel.test.tsx`, +`useReportingPanelState.test.ts`, and `reportingSchedulesModel.branchcov2.test.ts` +cover the gating, the reduced form, the pinned fields, and the saved payload. +This is a presentation and form-state boundary; the schedule API contract, +report rendering, and email delivery are unchanged. diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 4c08b1474d..4833520b01 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,45 +1,46 @@ { "version": 1, - "base_sha": "e6540da45da4de4bcb954463373d8ea9f196eb56", - "verified_at": "2026-10-02T21:04:53Z", + "base_sha": "78796b411c1f37f57b303cae9fab5ddaa13e1121", + "verified_at": "2026-10-02T22:13:00Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx" + "frontend-modern/src/components/Settings/ReportingPanel.tsx", + "frontend-modern/src/components/Settings/reportingSchedulesModel.ts", + "frontend-modern/src/components/Settings/useReportingPanelState.ts" ], "content_sha256": { - "frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx": "f2d8a72d62d06a89d3a70bd82d63d658ece3ac8acf3a0f688fb751a250d6f057" + "frontend-modern/src/components/Settings/ReportingPanel.tsx": "fbf12a7025b520e1f4ba99144f88a35778e9360f982b72bba251797361504afb", + "frontend-modern/src/components/Settings/reportingSchedulesModel.ts": "69fe58abb763e528ac3e3dfe5c50b969038e905fcf46974209cac29d1bc77e83", + "frontend-modern/src/components/Settings/useReportingPanelState.ts": "cc102740cf6eec47b8f3e5b4c9d6c24e1017d7ffd14bd653d25bfd45fc88b1af" }, "routes": [ - "/kubernetes" + "/settings/support/reporting" ], "viewports": [ - { - "width": 1440, - "height": 900 - }, { "width": 1280, "height": 900 }, { - "width": 900, - "height": 900 - }, - { - "width": 390, - "height": 900 + "width": 375, + "height": 812 } ], "states": [ - "Mock estate (1 Kubernetes cluster, 2 nodes, 6 pods, 2 deployments) with one CronJob carrying lastSuccessfulTime, one completed Job, one DaemonSet and one StatefulSet in the Workload Controllers table, Chromium", - "Workload Controllers table at the full desktop layout where the Detail column is shown (1440px) and at container widths where it is hidden (1280, 900, 390px)", - "Controller row expanded to its detail disclosure at 390px" + "Pro licence active with advanced_reporting; Scheduled reports header mentions the Patrol weekly summary because sessionCapabilities.assistantEnabled is true", + "Create schedule form opened: Report type select present with Performance report selected; cadence, format, resources, tag filter, delivery, retention, attach and save-copy controls visible", + "Report type switched to Patrol weekly summary: form reduced to name, timezone, weekday, time, email recipients and Enabled; help text explains the email and that nothing is saved to disk; name placeholder reads Patrol weekly summary", + "Saved schedule row: Weekly Patrol, Monday at 09:00, scope Patrol activity, last 7 days, delivery Email config recipients; API record carries kind patrol_digest and format email", + "Run now without an email destination: Last run shows Failed with the server message naming Settings > Notifications", + "Edit reopens the saved schedule with Report type Patrol weekly summary selected", + "375px: form fields stack full width, table shows name with cadence line, no horizontal clipping" ], "interactions": [ - "Measured every controllers table header and cell for clipped text (scrollWidth over clientWidth) at each viewport; before the change the Detail cells needed 258px in a 119px column, after it the CronJob row reads \"Last success 1h ago\" and the Job row \"Completed 41m ago\" with no clipped element in the column and no horizontal page scroll", - "Read the Detail span title attributes: they carry the absolute local time (\"Last success: Oct 2, 2026, 08:51 PM\") instead of the raw RFC 3339 string", - "Probed the rendered width of every label the column can produce (Started, Completed, Last success, Last run with ages up to 365d) against the 135px Detail cell at 1440px; all fit whole", - "Compared before and after screenshots of the table at 1440px; the StatefulSet service name now truncates with an ellipsis inside the cell instead of being cut by the cell edge" - ], - "command": "playwright chromium against a private mock-mode stack built from this commit (backend :7693, vite :5213)" + "Click Create schedule", + "Change Report type select to Patrol weekly summary", + "Type schedule name Weekly Patrol and click Save schedule", + "Click Run now on the saved schedule and reload", + "Click Edit on the saved schedule", + "Resize viewport to 375x812 and scroll through the form and table" + ] } diff --git a/frontend-modern/src/components/Settings/ReportingPanel.tsx b/frontend-modern/src/components/Settings/ReportingPanel.tsx index 11d3717a5e..686c794c10 100644 --- a/frontend-modern/src/components/Settings/ReportingPanel.tsx +++ b/frontend-modern/src/components/Settings/ReportingPanel.tsx @@ -25,8 +25,10 @@ import { formatReportScheduleTime, reportScheduleCadenceLabel, reportScheduleDeliveryLabel, + reportScheduleKindLabel, reportScheduleLastRunLabel, reportScheduleScopeLabel, + type ReportScheduleKind, } from '@/components/Settings/reportingSchedulesModel'; import { ResourcePicker } from './ResourcePicker'; @@ -74,6 +76,7 @@ export function ReportingPanel() { isLocked, isReportingEnabled, metricType, + patrolDigestAvailable, range, reportSchedules, reportSchedulesError, @@ -94,6 +97,7 @@ export function ReportingPanel() { setFormat, setMetricType, setRange, + setScheduleKind, setScheduleResources, setSelectedResources, setTitle, @@ -107,6 +111,10 @@ export function ReportingPanel() { } = useReportingPanelState(); const performanceReport = () => reportingCatalog()?.performanceReport ?? null; + const isDigestForm = () => scheduleForm().kind === 'patrol_digest'; + // Offer the Patrol weekly summary only while Patrol can run; an existing + // digest schedule still shows its type so it can be edited after AI is off. + const showReportTypeSelect = () => patrolDigestAvailable() || isDigestForm(); const inventoryDefinition = () => reportingCatalog()?.vmInventoryExport ?? null; const lockedState = () => { const catalog = reportingCatalog(); @@ -309,6 +317,11 @@ export function ReportingPanel() {

Send recurring client performance reports using the same resource scope and branding as generated reports. + + {' '} + A Patrol weekly summary can also be emailed: what Patrol checked, found, + fixed, and spent in the last seven days. +

@@ -526,6 +539,27 @@ export function ReportingPanel() {
+ +
+ + setScheduleKind(e.currentTarget.value as ReportScheduleKind) + } + help={ + isDigestForm() + ? 'Emails what Patrol checked, found, fixed, and spent over the last seven days. Nothing is saved to disk.' + : 'A performance report for the resources you pick, as a PDF or CSV.' + } + > + + + +
+
updateScheduleForm({ name: e.currentTarget.value })} - placeholder="Monthly client report" + placeholder={ + isDigestForm() ? 'Patrol weekly summary' : 'Monthly client report' + } /> @@ -549,21 +585,29 @@ export function ReportingPanel() {
-
- - updateScheduleForm({ - cadenceType: e.currentTarget.value as 'monthly' | 'weekly', - }) - } - > - - - +
+ + + updateScheduleForm({ + cadenceType: e.currentTarget.value as 'monthly' | 'weekly', + }) + } + > + + + + updateScheduleForm({ time: e.currentTarget.value })} /> - - updateScheduleForm({ format: e.currentTarget.value as ReportingFormat }) - } - > - - - -
- - - - - -
- - updateScheduleForm({ tagFilter: e.currentTarget.value })} - placeholder="production, customer-facing" - /> - - - updateScheduleForm({ - deliveryMethod: e.currentTarget.value as 'email' | 'disk', - }) - } - > - - - - - - updateScheduleForm({ retentionCount: Number(e.currentTarget.value) }) + + + updateScheduleForm({ format: e.currentTarget.value as ReportingFormat }) } + > + + + + +
+ + + + -
+ + + +
+ + updateScheduleForm({ tagFilter: e.currentTarget.value })} + placeholder="production, customer-facing" + /> + + + updateScheduleForm({ + deliveryMethod: e.currentTarget.value as 'email' | 'disk', + }) + } + > + + + + + + updateScheduleForm({ retentionCount: Number(e.currentTarget.value) }) + } + /> + +
+
Enabled - - + + + +
diff --git a/frontend-modern/src/components/Settings/__tests__/ReportingPanel.test.tsx b/frontend-modern/src/components/Settings/__tests__/ReportingPanel.test.tsx index eee7f8a175..cf86494145 100644 --- a/frontend-modern/src/components/Settings/__tests__/ReportingPanel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/ReportingPanel.test.tsx @@ -83,6 +83,7 @@ function buildState(overrides: Record = {}) { isLocked: () => false, isReportingEnabled: () => true, metricType: () => '', + patrolDigestAvailable: () => false, range: () => '24h', reportSchedules: () => [], reportSchedulesError: () => '', @@ -99,6 +100,7 @@ function buildState(overrides: Record = {}) { scheduleForm: () => ({ id: '', name: '', + kind: 'resources', enabled: true, cadenceType: 'monthly', dayOfMonth: 1, @@ -119,6 +121,7 @@ function buildState(overrides: Record = {}) { setFormat: vi.fn(), setMetricType: vi.fn(), setRange: vi.fn(), + setScheduleKind: vi.fn(), setScheduleResources: vi.fn(), setSelectedResources: vi.fn(), setTitle: vi.fn(), @@ -163,6 +166,120 @@ describe('ReportingPanel', () => { expect(screen.getByText('No scheduled reports are configured yet.')).toBeInTheDocument(); }); + it('keeps the schedule form free of the report type choice while Patrol cannot run', () => { + useReportingPanelStateMock.mockReturnValue( + buildState({ scheduleFormOpen: () => true, patrolDigestAvailable: () => false }), + ); + + render(() => ); + + expect(screen.queryByLabelText('Report type')).not.toBeInTheDocument(); + expect(screen.queryByText(/Patrol weekly summary can also be emailed/)).not.toBeInTheDocument(); + expect(screen.getByLabelText('Cadence')).toBeInTheDocument(); + // One picker for on-demand reports, one in the schedule form. + expect(screen.getAllByText('Mock Resource Picker')).toHaveLength(2); + }); + + it('offers the Patrol weekly summary as a report type when Patrol can run', () => { + const setScheduleKind = vi.fn(); + useReportingPanelStateMock.mockReturnValue( + buildState({ + scheduleFormOpen: () => true, + patrolDigestAvailable: () => true, + setScheduleKind, + }), + ); + + render(() => ); + + expect(screen.getByText(/Patrol weekly summary can also be emailed/)).toBeInTheDocument(); + const select = screen.getByLabelText('Report type') as HTMLSelectElement; + expect(select.value).toBe('resources'); + expect(Array.from(select.options).map((option) => option.textContent)).toEqual([ + 'Performance report', + 'Patrol weekly summary', + ]); + expect( + screen.getByText('A performance report for the resources you pick, as a PDF or CSV.'), + ).toBeInTheDocument(); + + select.value = 'patrol_digest'; + select.dispatchEvent(new Event('change', { bubbles: true })); + expect(setScheduleKind).toHaveBeenCalledWith('patrol_digest'); + }); + + it('reduces the form to weekday, time and recipients for a Patrol weekly summary', () => { + const base = buildState(); + useReportingPanelStateMock.mockReturnValue( + buildState({ + scheduleFormOpen: () => true, + patrolDigestAvailable: () => false, + scheduleForm: () => ({ + ...base.scheduleForm(), + kind: 'patrol_digest', + cadenceType: 'weekly', + weekday: 'friday', + deliveryMethod: 'email', + attach: false, + saveToDisk: false, + }), + }), + ); + + render(() => ); + + // An existing digest schedule stays editable even when Patrol is off now. + const select = screen.getByLabelText('Report type') as HTMLSelectElement; + expect(select.value).toBe('patrol_digest'); + expect( + screen.getByText(/Emails what Patrol checked, found, fixed, and spent/), + ).toBeInTheDocument(); + expect(screen.getByLabelText('Weekday')).toBeInTheDocument(); + expect(screen.getByLabelText('Time', { selector: 'input' })).toBeInTheDocument(); + expect(screen.getByLabelText('Email recipients', { selector: 'input' })).toBeInTheDocument(); + expect(screen.getByLabelText('Schedule name', { selector: 'input' })).toHaveAttribute( + 'placeholder', + 'Patrol weekly summary', + ); + expect(screen.getByText('Enabled')).toBeInTheDocument(); + + expect(screen.queryByLabelText('Cadence')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Day of month')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Format')).not.toBeInTheDocument(); + // Only the on-demand report picker remains. + expect(screen.getAllByText('Mock Resource Picker')).toHaveLength(1); + expect(screen.queryByLabelText('Tag filter')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Delivery')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Retention')).not.toBeInTheDocument(); + expect(screen.queryByText('Attach')).not.toBeInTheDocument(); + expect(screen.queryByText('Save copy')).not.toBeInTheDocument(); + }); + + it('describes a digest schedule by its Patrol window in the schedules table', () => { + useReportingPanelStateMock.mockReturnValue( + buildState({ + reportSchedules: () => [ + { + id: 'digest-1', + name: 'Weekly Patrol', + kind: 'patrol_digest', + enabled: true, + cadence: { type: 'weekly', weekday: 'monday', time: '09:00', timezone: 'UTC' }, + scope: { resources: [], tags: [] }, + format: 'email', + delivery: { method: 'email', to: [], attach: false, save_to_disk: false }, + }, + ], + }), + ); + + render(() => ); + + expect(screen.getByText('Weekly Patrol')).toBeInTheDocument(); + expect(screen.getByText('Patrol activity, last 7 days')).toBeInTheDocument(); + expect(screen.getByText('Email config recipients')).toBeInTheDocument(); + }); + it('hides unsupported optional controls from the reporting surface', () => { useReportingPanelStateMock.mockReturnValue( buildState({ diff --git a/frontend-modern/src/components/Settings/__tests__/reportingPanelModel.test.ts b/frontend-modern/src/components/Settings/__tests__/reportingPanelModel.test.ts index 710efdd555..a058281e36 100644 --- a/frontend-modern/src/components/Settings/__tests__/reportingPanelModel.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/reportingPanelModel.test.ts @@ -281,6 +281,7 @@ describe('reporting panel model', () => { { id: '', name: ' Acme monthly ', + kind: 'resources', enabled: true, cadenceType: 'monthly', dayOfMonth: 1, diff --git a/frontend-modern/src/components/Settings/__tests__/reportingSchedulesModel.branchcov2.test.ts b/frontend-modern/src/components/Settings/__tests__/reportingSchedulesModel.branchcov2.test.ts index 5bd81037f7..8931200e41 100644 --- a/frontend-modern/src/components/Settings/__tests__/reportingSchedulesModel.branchcov2.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/reportingSchedulesModel.branchcov2.test.ts @@ -8,13 +8,17 @@ import { describe, expect, it } from 'vitest'; import type { SelectedResource } from '@/components/Settings/ResourcePicker'; import { DEFAULT_REPORT_SCHEDULE_FORM, + applyReportScheduleKind, buildReportSchedulePayload, formatReportScheduleTime, + isPatrolDigestSchedule, normalizeReportSchedule, + normalizeReportScheduleKind, parseCommaList, parseReportSchedulesResponse, reportScheduleCadenceLabel, reportScheduleDeliveryLabel, + reportScheduleKindLabel, reportScheduleLastRunLabel, reportScheduleScopeLabel, scheduleToForm, @@ -337,6 +341,7 @@ describe('scheduleToForm', () => { expect(scheduleToForm(schedule)).toStrictEqual({ id: 'sched-1', name: 'Nightly ops digest', + kind: 'resources', enabled: true, cadenceType: 'monthly', dayOfMonth: 27, @@ -696,3 +701,112 @@ describe('formatReportScheduleTime', () => { expect(jul).not.toBe(''); }); }); + +// ---- Patrol weekly summary kind -------------------------------------------- + +describe('report schedule kinds', () => { + it('normalizes unknown or missing kinds to the performance report', () => { + expect(normalizeReportScheduleKind(undefined)).toBe('resources'); + expect(normalizeReportScheduleKind('bogus')).toBe('resources'); + expect(normalizeReportScheduleKind('patrol_digest')).toBe('patrol_digest'); + expect(normalizeReportSchedule(makeSchedule()).kind).toBe('resources'); + expect(isPatrolDigestSchedule(makeSchedule({ kind: 'patrol_digest' }))).toBe(true); + expect(isPatrolDigestSchedule(makeSchedule())).toBe(false); + }); + + it('keeps the server-assigned email format on digest schedules and maps it to pdf in the form', () => { + const digest = normalizeReportSchedule( + makeSchedule({ + kind: 'patrol_digest', + format: 'email', + cadence: { + type: 'weekly', + weekday: 'friday', + time: '08:00', + timezone: 'Europe/London', + }, + }), + ); + expect(digest.format).toBe('email'); + const form = scheduleToForm(digest); + expect(form.kind).toBe('patrol_digest'); + expect(form.format).toBe('pdf'); + expect(form.cadenceType).toBe('weekly'); + expect(form.weekday).toBe('friday'); + expect(scheduleToForm(makeSchedule()).kind).toBe('resources'); + }); + + it('builds an email-only weekly payload with no scope for the digest kind', () => { + const form = makeForm({ + kind: 'patrol_digest', + name: ' Weekly Patrol ', + cadenceType: 'weekly', + weekday: 'sunday', + time: '07:30', + timezone: ' ', + recipients: 'ops@example.com, ops@example.com, lead@example.com', + tagFilter: 'ignored', + attach: true, + saveToDisk: true, + }); + const payload = buildReportSchedulePayload(form, [makeResource()]); + expect(payload).toEqual({ + id: '', + name: 'Weekly Patrol', + kind: 'patrol_digest', + enabled: true, + cadence: { type: 'weekly', weekday: 'sunday', time: '07:30', timezone: 'UTC' }, + scope: { resources: [], tags: [] }, + format: 'email', + delivery: { + method: 'email', + to: ['ops@example.com', 'lead@example.com'], + attach: false, + save_to_disk: false, + }, + retention_count: 12, + }); + expect(buildReportSchedulePayload(makeForm(), []).kind).toBe('resources'); + }); + + it('pins digest-only fields when switching kind and restores delivery defaults on the way back', () => { + const start = makeForm({ + cadenceType: 'monthly', + deliveryMethod: 'disk', + attach: true, + saveToDisk: true, + tagFilter: 'production', + }); + expect(applyReportScheduleKind(start, 'resources')).toBe(start); + + const digest = applyReportScheduleKind(start, 'patrol_digest'); + expect(digest).toMatchObject({ + kind: 'patrol_digest', + cadenceType: 'weekly', + deliveryMethod: 'email', + attach: false, + saveToDisk: false, + tagFilter: '', + }); + expect(applyReportScheduleKind(digest, 'patrol_digest')).toBe(digest); + + const back = applyReportScheduleKind(digest, 'resources'); + expect(back).toMatchObject({ + kind: 'resources', + attach: true, + saveToDisk: true, + deliveryMethod: 'email', + cadenceType: 'weekly', + }); + }); + + it('labels the digest kind and its scope in plain words', () => { + expect(reportScheduleKindLabel('patrol_digest')).toBe('Patrol weekly summary'); + expect(reportScheduleKindLabel('resources')).toBe('Performance report'); + expect(reportScheduleKindLabel(undefined)).toBe('Performance report'); + expect(reportScheduleScopeLabel(makeSchedule({ kind: 'patrol_digest' }))).toBe( + 'Patrol activity, last 7 days', + ); + expect(reportScheduleScopeLabel(makeSchedule())).toBe('No scope'); + }); +}); diff --git a/frontend-modern/src/components/Settings/__tests__/useReportingPanelState.test.ts b/frontend-modern/src/components/Settings/__tests__/useReportingPanelState.test.ts index d6961c5799..aaff7556e7 100644 --- a/frontend-modern/src/components/Settings/__tests__/useReportingPanelState.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/useReportingPanelState.test.ts @@ -98,15 +98,27 @@ describe('useReportingPanelState', () => { let hasReportingFeature: boolean; let loadRuntimeLicenseStatusMock: ReturnType; let loadCommercialLicenseStatusMock: ReturnType; + let aiEnabled: boolean | null; + let showWarningMock: ReturnType; beforeEach(async () => { vi.resetModules(); apiFetchMock = buildCatalogAndSchedulesFetchMock(); hasReportingFeature = true; + aiEnabled = null; + showWarningMock = vi.fn(); loadRuntimeLicenseStatusMock = vi.fn(); loadCommercialLicenseStatusMock = vi.fn(); + vi.doMock('@/stores/aiChat', () => ({ + aiChatStore: { + get enabled() { + return aiEnabled; + }, + }, + })); + vi.doMock('@/utils/apiClient', async () => { const actual = await vi.importActual('@/utils/apiClient'); return { @@ -117,7 +129,7 @@ describe('useReportingPanelState', () => { vi.doMock('@/utils/toast', () => ({ showSuccess: vi.fn(), - showWarning: vi.fn(), + showWarning: showWarningMock, })); vi.doMock('@/stores/license', () => ({ @@ -426,4 +438,108 @@ describe('useReportingPanelState', () => { dispose(); }); + + it('offers the Patrol weekly summary only while the session reports Patrol can run', async () => { + aiEnabled = null; + const first = mountHook(); + await flushAsync(); + expect(first.hookState.patrolDigestAvailable()).toBe(false); + first.dispose(); + + aiEnabled = true; + const second = mountHook(); + await flushAsync(); + expect(second.hookState.patrolDigestAvailable()).toBe(true); + second.dispose(); + }); + + it('pins a Patrol weekly summary to a weekly email and drops any picked resources', async () => { + const { hookState, dispose } = mountHook(); + await flushAsync(); + + hookState.startCreateSchedule(); + hookState.updateScheduleForm({ cadenceType: 'monthly', deliveryMethod: 'disk' }); + hookState.setScheduleResources([{ id: 'vm-1', type: 'vm', name: 'vm-1' }]); + + hookState.setScheduleKind('patrol_digest'); + + expect(hookState.scheduleForm()).toMatchObject({ + kind: 'patrol_digest', + cadenceType: 'weekly', + deliveryMethod: 'email', + attach: false, + saveToDisk: false, + }); + expect(hookState.scheduleResources()).toEqual([]); + + hookState.setScheduleKind('resources'); + expect(hookState.scheduleForm()).toMatchObject({ kind: 'resources', attach: true }); + + dispose(); + }); + + it('saves a Patrol weekly summary without a resource scope', async () => { + const savedSchedule = { + id: 'digest-1', + name: 'Weekly Patrol', + kind: 'patrol_digest', + enabled: true, + cadence: { type: 'weekly', weekday: 'monday', time: '09:00', timezone: 'UTC' }, + scope: { resources: [], tags: [] }, + format: 'email', + delivery: { method: 'email', to: [], attach: false, save_to_disk: false }, + }; + apiFetchMock.mockImplementation((url: string, init?: RequestInit) => { + if (url === '/api/admin/reports/schedules' && init?.method === 'POST') { + return Promise.resolve(jsonResponse(savedSchedule)); + } + if (url === '/api/admin/reports/schedules') { + return Promise.resolve(jsonResponse(schedulesPayload)); + } + return Promise.resolve(jsonResponse(catalogPayload)); + }); + + const { hookState, dispose } = mountHook(); + await flushAsync(); + + hookState.startCreateSchedule(); + hookState.updateScheduleForm({ name: 'Weekly Patrol', timezone: 'UTC' }); + hookState.setScheduleKind('patrol_digest'); + await hookState.saveReportSchedule(); + await flushAsync(); + + expect(showWarningMock).not.toHaveBeenCalled(); + const post = apiFetchMock.mock.calls.find( + ([url, init]) => url === '/api/admin/reports/schedules' && init?.method === 'POST', + ); + expect(post).toBeDefined(); + const body = JSON.parse(String(post![1].body)); + expect(body.kind).toBe('patrol_digest'); + expect(body.format).toBe('email'); + expect(body.scope).toEqual({ resources: [], tags: [] }); + expect(body.delivery).toMatchObject({ method: 'email', attach: false, save_to_disk: false }); + expect(hookState.reportSchedules()).toHaveLength(1); + expect(hookState.reportSchedules()[0].kind).toBe('patrol_digest'); + expect(hookState.scheduleFormOpen()).toBe(false); + + dispose(); + }); + + it('still requires a scope for a performance report schedule', async () => { + const { hookState, dispose } = mountHook(); + await flushAsync(); + + hookState.startCreateSchedule(); + hookState.updateScheduleForm({ name: 'Monthly clients' }); + await hookState.saveReportSchedule(); + + expect(showWarningMock).toHaveBeenCalledWith('Select resources or enter at least one tag'); + expect( + apiFetchMock.mock.calls.some( + ([, init]) => (init as RequestInit | undefined)?.method === 'POST', + ), + ).toBe(false); + + dispose(); + }); }); diff --git a/frontend-modern/src/components/Settings/reportingSchedulesModel.ts b/frontend-modern/src/components/Settings/reportingSchedulesModel.ts index cf73e7b7ec..6201c2a7d8 100644 --- a/frontend-modern/src/components/Settings/reportingSchedulesModel.ts +++ b/frontend-modern/src/components/Settings/reportingSchedulesModel.ts @@ -4,6 +4,12 @@ import type { ReportingFormat } from '@/components/Settings/reportingCatalogMode export type ReportScheduleCadenceType = 'monthly' | 'weekly'; export type ReportScheduleRunStatus = '' | 'ok' | 'failed'; export type ReportScheduleDeliveryMethod = 'email' | 'disk'; +// 'resources' is the multi-resource performance report; 'patrol_digest' emails +// the last seven days of Patrol work and has no scope, format, or disk output. +export type ReportScheduleKind = 'resources' | 'patrol_digest'; +// Digest schedules carry the server-assigned 'email' format: the email body is +// the report, so there is no PDF or CSV artifact. +export type ReportScheduleFormat = ReportingFormat | 'email'; export interface ReportScheduleResource { resourceType: string; @@ -14,6 +20,7 @@ export interface ReportScheduleResource { export interface ReportSchedule { id: string; name: string; + kind?: ReportScheduleKind; enabled: boolean; cadence: { type: ReportScheduleCadenceType; @@ -26,7 +33,7 @@ export interface ReportSchedule { resources?: ReportScheduleResource[]; tags?: string[]; }; - format: ReportingFormat; + format: ReportScheduleFormat; delivery: { method: ReportScheduleDeliveryMethod; to?: string[]; @@ -45,6 +52,7 @@ export interface ReportSchedule { export interface ReportScheduleFormState { id: string; name: string; + kind: ReportScheduleKind; enabled: boolean; cadenceType: ReportScheduleCadenceType; dayOfMonth: number; @@ -77,6 +85,7 @@ const WEEKDAY_LABELS: Record = { export const DEFAULT_REPORT_SCHEDULE_FORM = (): ReportScheduleFormState => ({ id: '', name: '', + kind: 'resources', enabled: true, cadenceType: 'monthly', dayOfMonth: 1, @@ -98,9 +107,18 @@ export function parseReportSchedulesResponse(value: unknown): ReportSchedule[] { return Array.isArray(schedules) ? schedules.map(normalizeReportSchedule) : []; } +export function normalizeReportScheduleKind(kind: unknown): ReportScheduleKind { + return kind === 'patrol_digest' ? 'patrol_digest' : 'resources'; +} + +export function isPatrolDigestSchedule(schedule: Pick): boolean { + return normalizeReportScheduleKind(schedule.kind) === 'patrol_digest'; +} + export function normalizeReportSchedule(schedule: ReportSchedule): ReportSchedule { return { ...schedule, + kind: normalizeReportScheduleKind(schedule.kind), enabled: schedule.enabled !== false, cadence: { type: schedule.cadence?.type === 'weekly' ? 'weekly' : 'monthly', @@ -113,7 +131,7 @@ export function normalizeReportSchedule(schedule: ReportSchedule): ReportSchedul resources: Array.isArray(schedule.scope?.resources) ? schedule.scope.resources : [], tags: Array.isArray(schedule.scope?.tags) ? schedule.scope.tags : [], }, - format: schedule.format === 'csv' ? 'csv' : 'pdf', + format: schedule.format === 'csv' || schedule.format === 'email' ? schedule.format : 'pdf', delivery: { method: schedule.delivery?.method === 'disk' ? 'disk' : 'email', to: Array.isArray(schedule.delivery?.to) ? schedule.delivery.to : [], @@ -129,13 +147,14 @@ export function scheduleToForm(schedule: ReportSchedule): ReportScheduleFormStat return { id: normalized.id, name: normalized.name, + kind: normalizeReportScheduleKind(normalized.kind), 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, + format: normalized.format === 'csv' ? 'csv' : 'pdf', deliveryMethod: normalized.delivery.method, recipients: (normalized.delivery.to ?? []).join(', '), attach: normalized.delivery.attach, @@ -153,13 +172,73 @@ export function scheduleToSelectedResources(schedule: ReportSchedule): SelectedR })); } +// applyReportScheduleKind switches the form between kinds. The Patrol weekly +// summary is fleet-wide and email-only, so it pins the fields the server would +// reject or ignore (cadence, delivery, attachments, disk copy, tag scope) rather +// than leaving stale performance-report values behind hidden controls. Going +// back to a performance report restores the delivery defaults it cleared. +export function applyReportScheduleKind( + form: ReportScheduleFormState, + kind: ReportScheduleKind, +): ReportScheduleFormState { + if (form.kind === kind) return form; + if (kind === 'patrol_digest') { + return { + ...form, + kind, + cadenceType: 'weekly', + deliveryMethod: 'email', + attach: false, + saveToDisk: false, + tagFilter: '', + }; + } + const defaults = DEFAULT_REPORT_SCHEDULE_FORM(); + return { + ...form, + kind, + attach: defaults.attach, + saveToDisk: defaults.saveToDisk, + }; +} + +export function reportScheduleKindLabel(kind: unknown): string { + return normalizeReportScheduleKind(kind) === 'patrol_digest' + ? 'Patrol weekly summary' + : 'Performance report'; +} + export function buildReportSchedulePayload( form: ReportScheduleFormState, resources: SelectedResource[], ): Omit { + if (form.kind === 'patrol_digest') { + return { + id: form.id, + name: form.name.trim(), + kind: 'patrol_digest', + enabled: form.enabled, + cadence: { + type: 'weekly', + weekday: form.weekday, + time: form.time, + timezone: form.timezone.trim() || 'UTC', + }, + scope: { resources: [], tags: [] }, + format: 'email', + delivery: { + method: 'email', + to: parseCommaList(form.recipients), + attach: false, + save_to_disk: false, + }, + retention_count: form.retentionCount, + }; + } return { id: form.id, name: form.name.trim(), + kind: 'resources', enabled: form.enabled, cadence: { type: form.cadenceType, @@ -210,6 +289,7 @@ export function reportScheduleCadenceLabel(schedule: ReportSchedule): string { } export function reportScheduleScopeLabel(schedule: ReportSchedule): string { + if (isPatrolDigestSchedule(schedule)) return 'Patrol activity, last 7 days'; const resources = schedule.scope.resources?.length ?? 0; const tags = schedule.scope.tags?.length ?? 0; const parts = []; diff --git a/frontend-modern/src/components/Settings/useReportingPanelState.ts b/frontend-modern/src/components/Settings/useReportingPanelState.ts index 24f43fe565..2d8e7c7e8c 100644 --- a/frontend-modern/src/components/Settings/useReportingPanelState.ts +++ b/frontend-modern/src/components/Settings/useReportingPanelState.ts @@ -1,6 +1,7 @@ import { createEffect, createSignal, onMount } from 'solid-js'; import { apiErrorFromResponse, apiFetch } from '@/utils/apiClient'; import { showSuccess, showWarning } from '@/utils/toast'; +import { aiChatStore } from '@/stores/aiChat'; import type { SelectedResource } from '@/components/Settings/ResourcePicker'; import { hasFeature, runtimeCapabilitiesLoaded } from '@/stores/license'; import { getUpgradeActionDestination } from '@/stores/licenseCommercial'; @@ -29,6 +30,7 @@ import { type ReportingFormat, } from '@/components/Settings/reportingCatalogModel'; import { + applyReportScheduleKind, buildReportSchedulePayload, DEFAULT_REPORT_SCHEDULE_FORM, normalizeReportSchedule, @@ -37,6 +39,7 @@ import { scheduleToSelectedResources, type ReportSchedule, type ReportScheduleFormState, + type ReportScheduleKind, } from '@/components/Settings/reportingSchedulesModel'; export const useReportingPanelState = () => { @@ -256,6 +259,18 @@ export const useReportingPanelState = () => { setScheduleForm((current) => ({ ...current, ...patch })); }; + // The Patrol weekly summary only has content when Patrol can run, which is + // the same session capability that shows the Patrol navigation and the + // Assistant launcher: AI switched on with a provider configured. + const patrolDigestAvailable = () => aiChatStore.enabled === true; + + const setScheduleKind = (kind: ReportScheduleKind) => { + setScheduleForm((current) => applyReportScheduleKind(current, kind)); + if (kind === 'patrol_digest') { + setScheduleResources([]); + } + }; + const startCreateSchedule = () => { setScheduleForm(DEFAULT_REPORT_SCHEDULE_FORM()); setScheduleResources([]); @@ -283,7 +298,11 @@ export const useReportingPanelState = () => { showWarning('Schedule name is required'); return; } - if ((payload.scope.resources?.length ?? 0) === 0 && (payload.scope.tags?.length ?? 0) === 0) { + if ( + payload.kind !== 'patrol_digest' && + (payload.scope.resources?.length ?? 0) === 0 && + (payload.scope.tags?.length ?? 0) === 0 + ) { showWarning('Select resources or enter at least one tag'); return; } @@ -417,6 +436,7 @@ export const useReportingPanelState = () => { isLocked, isReportingEnabled, metricType, + patrolDigestAvailable, range, reportSchedules, reportSchedulesError, @@ -445,6 +465,7 @@ export const useReportingPanelState = () => { setFormat, setMetricType, setRange, + setScheduleKind, setScheduleResources, setSelectedResources, setTitle,