diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index b4c032b..518063f 100644 --- a/src/components/layout/MainContent.tsx +++ b/src/components/layout/MainContent.tsx @@ -137,6 +137,11 @@ const SharedMailboxesPage = lazyFeature( () => import('@/features/lock/SharedMailboxesPage'), (m) => m.SharedMailboxesPage, ); +// inbuxa: Reports › Scheduled (scheduled-reports spec). +const ScheduledReportsPage = lazyFeature( + () => import('@/features/scheduledReports/ScheduledReportsPage'), + (m) => m.ScheduledReportsPage, +); // inbuxa: Domains › Deliverability (deliverability spec, DL-17). const DeliverabilityPage = lazyFeature( () => import('@/features/deliverability/DeliverabilityPage'), @@ -256,6 +261,9 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti if (componentName === 'Deliverability') { return ; } + if (componentName === 'ScheduledReports') { + return ; + } if (componentName === 'LegalHolds') { return ; } diff --git a/src/features/dashboard/attention.test.ts b/src/features/dashboard/attention.test.ts new file mode 100644 index 0000000..8d9e573 --- /dev/null +++ b/src/features/dashboard/attention.test.ts @@ -0,0 +1,51 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, it } from 'vitest'; +import type { TFunction } from 'i18next'; +import { buildAttention } from './attention'; + +const t = ((key: string, opts?: unknown) => { + const o = (typeof opts === 'object' && opts ? opts : {}) as Record; + const text = (o.count === 1 ? o.defaultValue_one : o.defaultValue_other) ?? (typeof opts === 'string' ? opts : key); + return String(text); +}) as unknown as TFunction; + +const quiet = { + t, + may: () => true, + facts: null, + health: null, + critical: null, + samples: [], + liveStatus: '', + nodesHref: null, + slowHref: null, + linkOf: () => null, +}; + +describe('buildAttention: scheduled reports (RP-17)', () => { + it('lists reports that keep failing, linking to their page', () => { + const items = buildAttention({ + ...quiet, + failingReports: 2, + failingReportsHref: '/Management/CustomComponent/ScheduledReports', + }); + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ + id: 'scheduledReports', + severity: 'warn', + count: 2, + title: 'scheduled reports keep failing', + href: '/Management/CustomComponent/ScheduledReports', + }); + }); + + it('says nothing when none do', () => { + expect(buildAttention({ ...quiet, failingReports: 0 })).toEqual([]); + expect(buildAttention({ ...quiet, failingReports: null })).toEqual([]); + }); +}); diff --git a/src/features/dashboard/attention.ts b/src/features/dashboard/attention.ts index 202a745..124d69a 100644 --- a/src/features/dashboard/attention.ts +++ b/src/features/dashboard/attention.ts @@ -42,6 +42,9 @@ export interface AttentionInput { /** inbuxa: blocklist and reverse-DNS failures on Domains › Deliverability (DL-18). */ deliverability?: number | null; deliverabilityHref?: string | null; + /** inbuxa: scheduled reports whose last two runs failed (RP-17). */ + failingReports?: number | null; + failingReportsHref?: string | null; samples: Metric[]; liveStatus: string; nodesHref: string | null; @@ -58,6 +61,8 @@ export function buildAttention({ critical, deliverability, deliverabilityHref, + failingReports, + failingReportsHref, samples, liveStatus, nodesHref, @@ -95,6 +100,20 @@ export function buildAttention({ detail: t('cc.a.deliverabilityDetail', 'Other mail servers may refuse mail from this one.'), href: deliverabilityHref ?? null, }); + if (failingReports) + attention.push({ + id: 'scheduledReports', + severity: 'warn', + icon: MailWarning, + count: failingReports, + title: t('cc.a.scheduledReports', { + count: failingReports, + defaultValue_one: 'scheduled report keeps failing', + defaultValue_other: 'scheduled reports keep failing', + }), + detail: t('cc.a.scheduledReportsDetail', 'Their last two runs failed; the report page says why.'), + href: failingReportsHref ?? null, + }); if (health && health.unhealthy) attention.push({ id: 'nodes', diff --git a/src/features/dashboard/components/DashboardShell.tsx b/src/features/dashboard/components/DashboardShell.tsx index cea8d7d..6275fde 100644 --- a/src/features/dashboard/components/DashboardShell.tsx +++ b/src/features/dashboard/components/DashboardShell.tsx @@ -13,6 +13,7 @@ import { LEGACY_PROTOCOLS_VIEW } from '@/features/hardening/LegacyProtocolsBanne import { useLegacyProtocolsOff } from '@/features/hardening/useLegacyProtocolsOff'; import { useCriticalCount } from '@/features/security/useCriticalCount'; import { useDeliverabilityAlarms } from '@/features/deliverability/useDeliverabilityAlarms'; +import { useReportFailures } from '@/features/scheduledReports/useReportFailures'; import { useSchemaStore } from '@/stores/schemaStore'; import type { Dashboard } from '../types/schema'; import type { Metric } from '../types/metrics'; @@ -64,6 +65,8 @@ export function DashboardShell({ const critical = useCriticalCount(); // inbuxa: DL-18 const deliverability = useDeliverabilityAlarms(tick); + const failingReports = useReportFailures(tick); + const reportsSection = useSchemaStore((s) => s.viewToSection['CustomComponent/ScheduledReports']); const deliverabilitySection = useSchemaStore((s) => s.viewToSection['CustomComponent/Deliverability']); const legacyOff = useLegacyProtocolsOff(); @@ -106,6 +109,8 @@ export function DashboardShell({ critical, deliverability, deliverabilityHref: deliverabilitySection ? `/${deliverabilitySection}/CustomComponent/Deliverability` : null, + failingReports, + failingReportsHref: reportsSection ? `/${reportsSection}/CustomComponent/ScheduledReports` : null, samples, liveStatus, nodesHref: health ? `/${section}/Dashboard/cluster` : null, diff --git a/src/features/scheduledReports/DownloadDialog.tsx b/src/features/scheduledReports/DownloadDialog.tsx new file mode 100644 index 0000000..8196cc4 --- /dev/null +++ b/src/features/scheduledReports/DownloadDialog.tsx @@ -0,0 +1,135 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: Download (scheduled-reports spec, RP-19): the report for a period + * as a ZIP of a summary and CSVs, mailing nobody. Up to 90 days back, as far + * as the server keeps its numbers. + */ + +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Download, Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { downloadReport, type ScheduledReport } from './api'; + +type Period = 'last' | '7' | '30' | 'custom'; + +const DAY = 86_400_000; +const isoDay = (d: Date) => d.toISOString().slice(0, 10); + +export function DownloadDialog({ report, onClose }: { report: ScheduledReport; onClose: () => void }) { + const { t } = useTranslation(); + const [period, setPeriod] = useState('last'); + const today = new Date(); + const [from, setFrom] = useState(isoDay(new Date(today.getTime() - 7 * DAY))); + const [to, setTo] = useState(isoDay(today)); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const earliest = isoDay(new Date(today.getTime() - 89 * DAY)); + + const go = async () => { + setBusy(true); + setError(null); + try { + const now = new Date(); + if (period === 'last') await downloadReport(report); + else if (period === 'custom') { + // Whole UTC days; a period ending today ends now + const end = new Date(`${to}T00:00:00Z`).getTime() + DAY; + await downloadReport(report, new Date(`${from}T00:00:00Z`), new Date(Math.min(end, now.getTime()))); + } else await downloadReport(report, new Date(now.getTime() - Number(period) * DAY), now); + onClose(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + + const options: { value: Period; label: string }[] = [ + { value: 'last', label: t('schedRep.dlLast', 'Its last full period') }, + { value: '7', label: t('schedRep.dl7', 'The last 7 days') }, + { value: '30', label: t('schedRep.dl30', 'The last 30 days') }, + { value: 'custom', label: t('schedRep.dlCustom', 'Pick dates') }, + ]; + + return ( + !open && onClose()}> + + + {t('schedRep.dlTitle', 'Download {{name}}', { name: report.name })} + + {t( + 'schedRep.dlHint', + 'A ZIP with the summary and a CSV for each section that has rows. Nobody is mailed. Up to 90 days back.', + )} + + +
+ {options.map((o) => ( + + ))} + {period === 'custom' && ( +
+
+ + setFrom(e.target.value)} + /> +
+
+ + setTo(e.target.value)} + /> +
+
+ )} + {error &&

{error}

} +
+ + + + +
+
+ ); +} diff --git a/src/features/scheduledReports/ReportEditor.tsx b/src/features/scheduledReports/ReportEditor.tsx new file mode 100644 index 0000000..2809a9c --- /dev/null +++ b/src/features/scheduledReports/ReportEditor.tsx @@ -0,0 +1,288 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: making or changing a scheduled report (scheduled-reports spec): + * name, sections with what each holds, when it goes in which time zone, who + * gets it (accounts on this server; the digest's are the administrators), + * and CSV files. + */ + +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Combobox } from '@/components/ui/combobox'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Switch } from '@/components/ui/switch'; +import { Textarea } from '@/components/ui/textarea'; +import { useAuthStore } from '@/stores/authStore'; +import { createReport, updateReport, type Frequency, type ReportInput, type ScheduledReport } from './api'; +import { browserTimeZone, newReport, parseRecipients, SECTIONS, SERVER_WIDE, timeZones } from './describe'; +import { useSectionLabels } from './sections'; + +export function ReportEditor({ + report, + tenant, + onClose, +}: { + report: ScheduledReport | null; + /** A tenant administrator's report leaves out the server-wide sections. */ + tenant: boolean; + onClose: (saved: boolean) => void; +}) { + const { t, i18n } = useTranslation(); + const labels = useSectionLabels(); + const me = useAuthStore((s) => s.username); + const [input, setInput] = useState(() => + report + ? { + name: report.name, + sections: report.sections, + schedule: report.schedule, + recipients: report.recipients, + attachCsv: report.attachCsv, + } + : newReport(me && me.includes('@') ? me : null, browserTimeZone()), + ); + const [recipientText, setRecipientText] = useState(input.recipients.join('\n')); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const builtIn = report?.builtIn ?? false; + const s = input.schedule; + const zones = useMemo( + () => timeZones(s.timeZone).map((z) => ({ value: z, label: z.replace(/_/g, ' ') })), + [s.timeZone], + ); + const weekdays = useMemo( + () => + [1, 2, 3, 4, 5, 6, 7].map((d) => ({ + value: String(d), + label: new Date(Date.UTC(2026, 9, 4 + d)).toLocaleDateString(i18n.language, { + weekday: 'long', + timeZone: 'UTC', + }), + })), + [i18n.language], + ); + const setSchedule = (patch: Partial) => + setInput((i) => ({ ...i, schedule: { ...i.schedule, ...patch } })); + const toggleSection = (name: (typeof SECTIONS)[number], on: boolean) => + setInput((i) => ({ + ...i, + sections: SECTIONS.filter((x) => (x === name ? on : i.sections.includes(x))), + })); + + const save = async () => { + setSaving(true); + setError(null); + const body: ReportInput = { + ...input, + name: input.name.trim(), + sections: tenant ? input.sections.filter((x) => !SERVER_WIDE.has(x)) : input.sections, + recipients: builtIn ? [] : parseRecipients(recipientText), + }; + try { + if (report) { + const { recipients, ...rest } = body; + await updateReport(report.id, builtIn ? rest : { ...rest, recipients }); + } else { + await createReport(body); + } + onClose(true); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setSaving(false); + } + }; + + return ( + !open && onClose(false)}> + + + + {report + ? t('schedRep.editTitle', 'Edit {{name}}', { name: report.name }) + : t('schedRep.newTitle', 'New report')} + + + {t( + 'schedRep.editHint', + 'Built when it’s due, from what the server already keeps. Empty sections are left out.', + )} + + + +
+
+ + setInput((i) => ({ ...i, name: e.target.value }))} + /> +
+ +
+ {t('schedRep.sections', 'What it covers')} + {SECTIONS.map((name) => { + const off = tenant && SERVER_WIDE.has(name); + return ( + + ); + })} +
+ +
+ {t('schedRep.when', 'When')} +
+
+ + +
+ {s.frequency === 'weekly' && ( +
+ + +
+ )} + {s.frequency === 'monthly' && ( +
+ + + setSchedule({ dayOfMonth: Math.min(28, Math.max(1, Number(e.target.value) || 1)) }) + } + /> +
+ )} +
+ + { + const [h, m] = e.target.value.split(':').map(Number); + if (!Number.isNaN(h) && !Number.isNaN(m)) setSchedule({ hour: h, minute: m }); + }} + /> +
+
+
+ + v && setSchedule({ timeZone: v })} + searchPlaceholder={t('schedRep.zoneSearch', 'Search time zones…')} + /> +
+
+ +
+ + {builtIn ? ( +

+ {t('schedRep.digestRecipients', 'The system administrators, kept up to date as they change.')} +

+ ) : ( + <> +