Offer the Patrol weekly summary as a scheduled report type

The patrol_digest report schedule kind has been on main since early
September, but only the API could create one: the Settings > Reporting
form had no way to choose it, so the weekly "what Patrol did" email never
reached the customers it was built for, the ones who have stopped opening
Pulse. The schedule form now has a Report type choice between the
performance report and the Patrol weekly summary. The choice shows only
while the session reports Patrol can run (AI on with a provider), and an
existing summary schedule keeps showing its type so it stays editable
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 and delivery to
email, and the format, resource picker, tag filter, retention, attach and
save-copy controls are hidden because the server fixes those values; the
save path no longer demands a resource scope for this kind. The schedules
table describes a summary as "Patrol activity, last 7 days". Model, hook
and panel tests cover the gating, the pinned fields and the saved
payload, and the browser receipt records a Pro-licensed pass at 1280 and
375 pixels, including a run without an email destination that surfaces
the server's message naming the missing setting.
This commit is contained in:
rcourtman
2026-10-02 23:13:34 +01:00
parent 78796b411c
commit f43972c321
10 changed files with 658 additions and 126 deletions
+17 -6
View File
@@ -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
@@ -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.
+25 -24
View File
@@ -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"
]
}
@@ -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() {
<p class="text-sm text-muted">
Send recurring client performance reports using the same resource scope and
branding as generated reports.
<Show when={patrolDigestAvailable()}>
{' '}
A Patrol weekly summary can also be emailed: what Patrol checked, found,
fixed, and spent in the last seven days.
</Show>
</p>
</div>
<div class="flex flex-wrap gap-2">
@@ -526,6 +539,27 @@ export function ReportingPanel() {
<Show when={scheduleFormOpen()}>
<section class="space-y-4 border-y border-base-300 py-4">
<Show when={showReportTypeSelect()}>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormSelect
label="Report type"
value={scheduleForm().kind}
onChange={(e) =>
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.'
}
>
<option value="resources">{reportScheduleKindLabel('resources')}</option>
<option value="patrol_digest">
{reportScheduleKindLabel('patrol_digest')}
</option>
</FormSelect>
</div>
</Show>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField label="Schedule name">
<input
@@ -534,7 +568,9 @@ export function ReportingPanel() {
class={formControl}
value={scheduleForm().name}
onInput={(e) => updateScheduleForm({ name: e.currentTarget.value })}
placeholder="Monthly client report"
placeholder={
isDigestForm() ? 'Patrol weekly summary' : 'Monthly client report'
}
/>
</FormField>
<FormField label="Timezone">
@@ -549,21 +585,29 @@ export function ReportingPanel() {
</FormField>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-4">
<FormSelect
label="Cadence"
value={scheduleForm().cadenceType}
onChange={(e) =>
updateScheduleForm({
cadenceType: e.currentTarget.value as 'monthly' | 'weekly',
})
}
>
<option value="monthly">Monthly</option>
<option value="weekly">Weekly</option>
</FormSelect>
<div
class={
isDigestForm()
? 'grid grid-cols-1 gap-4 md:grid-cols-2'
: 'grid grid-cols-1 gap-4 md:grid-cols-4'
}
>
<Show when={!isDigestForm()}>
<FormSelect
label="Cadence"
value={scheduleForm().cadenceType}
onChange={(e) =>
updateScheduleForm({
cadenceType: e.currentTarget.value as 'monthly' | 'weekly',
})
}
>
<option value="monthly">Monthly</option>
<option value="weekly">Weekly</option>
</FormSelect>
</Show>
<Show
when={scheduleForm().cadenceType === 'monthly'}
when={scheduleForm().cadenceType === 'monthly' && !isDigestForm()}
fallback={
<FormSelect
label="Weekday"
@@ -601,66 +645,72 @@ export function ReportingPanel() {
onInput={(e) => updateScheduleForm({ time: e.currentTarget.value })}
/>
</FormField>
<FormSelect
label="Format"
value={scheduleForm().format}
onChange={(e) =>
updateScheduleForm({ format: e.currentTarget.value as ReportingFormat })
}
>
<option value="pdf">PDF</option>
<option value="csv">CSV</option>
</FormSelect>
</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
aria-label="Tag filter"
type="text"
class={formControl}
value={scheduleForm().tagFilter}
onInput={(e) => updateScheduleForm({ tagFilter: e.currentTarget.value })}
placeholder="production, customer-facing"
/>
</FormField>
<FormSelect
label="Delivery"
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>
</FormSelect>
<FormField label="Retention">
<input
aria-label="Retention"
type="number"
min="1"
max="120"
class={formControl}
value={scheduleForm().retentionCount}
onInput={(e) =>
updateScheduleForm({ retentionCount: Number(e.currentTarget.value) })
<Show when={!isDigestForm()}>
<FormSelect
label="Format"
value={scheduleForm().format}
onChange={(e) =>
updateScheduleForm({ format: e.currentTarget.value as ReportingFormat })
}
>
<option value="pdf">PDF</option>
<option value="csv">CSV</option>
</FormSelect>
</Show>
</div>
<Show when={!isDigestForm()}>
<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>
</Show>
<Show when={!isDigestForm()}>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<FormField label="Tag filter" helpText="Comma-separated tags">
<input
aria-label="Tag filter"
type="text"
class={formControl}
value={scheduleForm().tagFilter}
onInput={(e) => updateScheduleForm({ tagFilter: e.currentTarget.value })}
placeholder="production, customer-facing"
/>
</FormField>
<FormSelect
label="Delivery"
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>
</FormSelect>
<FormField label="Retention">
<input
aria-label="Retention"
type="number"
min="1"
max="120"
class={formControl}
value={scheduleForm().retentionCount}
onInput={(e) =>
updateScheduleForm({ retentionCount: Number(e.currentTarget.value) })
}
/>
</FormField>
</div>
</Show>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField
@@ -686,25 +736,29 @@ export function ReportingPanel() {
/>
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>
<Show when={!isDigestForm()}>
<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>
</Show>
</div>
</div>
@@ -83,6 +83,7 @@ function buildState(overrides: Record<string, unknown> = {}) {
isLocked: () => false,
isReportingEnabled: () => true,
metricType: () => '',
patrolDigestAvailable: () => false,
range: () => '24h',
reportSchedules: () => [],
reportSchedulesError: () => '',
@@ -99,6 +100,7 @@ function buildState(overrides: Record<string, unknown> = {}) {
scheduleForm: () => ({
id: '',
name: '',
kind: 'resources',
enabled: true,
cadenceType: 'monthly',
dayOfMonth: 1,
@@ -119,6 +121,7 @@ function buildState(overrides: Record<string, unknown> = {}) {
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(() => <ReportingPanel />);
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(() => <ReportingPanel />);
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(() => <ReportingPanel />);
// 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(() => <ReportingPanel />);
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({
@@ -281,6 +281,7 @@ describe('reporting panel model', () => {
{
id: '',
name: ' Acme monthly ',
kind: 'resources',
enabled: true,
cadenceType: 'monthly',
dayOfMonth: 1,
@@ -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');
});
});
@@ -98,15 +98,27 @@ describe('useReportingPanelState', () => {
let hasReportingFeature: boolean;
let loadRuntimeLicenseStatusMock: ReturnType<typeof vi.fn>;
let loadCommercialLicenseStatusMock: ReturnType<typeof vi.fn>;
let aiEnabled: boolean | null;
let showWarningMock: ReturnType<typeof vi.fn>;
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<typeof import('@/utils/apiClient')>('@/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();
});
});
@@ -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<string, string> = {
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<ReportSchedule, 'kind'>): 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<ReportSchedule, 'created_at' | 'updated_at'> {
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 = [];
@@ -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,