diff --git a/client/src/App.tsx b/client/src/App.tsx index ffb481e9..3d5e8457 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -14,6 +14,7 @@ import { SessionDetail } from "./pages/SessionDetail"; import { ActivityFeed } from "./pages/ActivityFeed"; import { Analytics } from "./pages/Analytics"; import { Workflows } from "./pages/Workflows"; +import { Reports } from "./pages/Reports"; import { Settings } from "./pages/Settings"; import { CcConfig } from "./pages/CcConfig"; import { Run } from "./pages/Run"; @@ -42,6 +43,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index b3330340..9d5223b7 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -15,6 +15,7 @@ import { Activity, BarChart3, Workflow, + FileBarChart, Boxes, Play, Settings, @@ -49,6 +50,7 @@ const NAV_KEYS = [ { to: "/activity", icon: Activity, key: "nav:activityFeed" }, { to: "/analytics", icon: BarChart3, key: "nav:analytics" }, { to: "/workflows", icon: Workflow, key: "nav:workflows" }, + { to: "/reports", icon: FileBarChart, key: "nav:reports" }, { to: "/cc-config", icon: Boxes, key: "nav:ccConfig" }, { to: "/run", icon: Play, key: "nav:run" }, { to: "/settings", icon: Settings, key: "nav:settings" }, diff --git a/client/src/i18n/index.ts b/client/src/i18n/index.ts index 3129e0d7..99d41da6 100644 --- a/client/src/i18n/index.ts +++ b/client/src/i18n/index.ts @@ -50,6 +50,9 @@ import run_vi from "./locales/vi/run.json"; import alerts_en from "./locales/en/alerts.json"; import alerts_zh from "./locales/zh/alerts.json"; import alerts_vi from "./locales/vi/alerts.json"; +import reports_en from "./locales/en/reports.json"; +import reports_zh from "./locales/zh/reports.json"; +import reports_vi from "./locales/vi/reports.json"; i18n .use(LanguageDetector) @@ -71,6 +74,7 @@ i18n ccConfig: ccConfig_en, run: run_en, alerts: alerts_en, + reports: reports_en, }, zh: { common: common_zh, @@ -87,6 +91,7 @@ i18n ccConfig: ccConfig_zh, run: run_zh, alerts: alerts_zh, + reports: reports_zh, }, vi: { common: common_vi, @@ -103,6 +108,7 @@ i18n ccConfig: ccConfig_vi, run: run_vi, alerts: alerts_vi, + reports: reports_vi, }, }, supportedLngs: ["en", "zh", "vi"], @@ -123,6 +129,7 @@ i18n "ccConfig", "run", "alerts", + "reports", ], defaultNS: "common", interpolation: { escapeValue: false }, diff --git a/client/src/i18n/locales/en/nav.json b/client/src/i18n/locales/en/nav.json index 8f89b09c..96dd461c 100644 --- a/client/src/i18n/locales/en/nav.json +++ b/client/src/i18n/locales/en/nav.json @@ -7,6 +7,7 @@ "activityFeed": "Activity Feed", "analytics": "Analytics", "workflows": "Workflows", + "reports": "Reports", "alerts": "Alerts", "ccConfig": "Claude Config", "run": "Run Claude", diff --git a/client/src/i18n/locales/en/reports.json b/client/src/i18n/locales/en/reports.json new file mode 100644 index 00000000..52d272ac --- /dev/null +++ b/client/src/i18n/locales/en/reports.json @@ -0,0 +1,85 @@ +{ + "title": "Scheduled Reports", + "subtitle": "Automated analytics reports delivered on a schedule", + "newReport": "New report", + "refresh": "Refresh", + "failedLoad": "Failed to load reports", + "definitions": { + "empty": "No scheduled reports yet", + "emptyDesc": "Create a report to receive automated analytics summaries on a schedule.", + "createFirst": "Create report", + "nextRun": "Next run", + "lastRun": "Last run", + "never": "Never", + "runNow": "Run now", + "running": "Running…", + "edit": "Edit", + "delete": "Delete", + "enabled": "Enabled", + "disabled": "Disabled", + "formats": "Formats", + "window": "{{count}}-day window", + "viewHistory": "View history", + "hideHistory": "Hide history" + }, + "frequency": { + "daily": "Daily", + "weekly": "Weekly", + "monthly": "Monthly", + "dailyAt": "Daily · {{time}}", + "weeklyAt": "Weekly · {{day}} {{time}}", + "monthlyAt": "Monthly · day 1 {{time}}" + }, + "days": { + "0": "Sun", + "1": "Mon", + "2": "Tue", + "3": "Wed", + "4": "Thu", + "5": "Fri", + "6": "Sat" + }, + "status": { + "success": "Success", + "error": "Error", + "pending": "Pending" + }, + "form": { + "createTitle": "New scheduled report", + "editTitle": "Edit report", + "name": "Name", + "namePlaceholder": "Weekly analytics digest", + "template": "Template", + "frequency": "Frequency", + "dayOfWeek": "Day of week", + "hour": "Hour (0–23)", + "formats": "Formats", + "windowDays": "Window (days)", + "enabled": "Enabled", + "enabledDesc": "Run this report automatically on its schedule", + "cancel": "Cancel", + "create": "Create report", + "save": "Save changes", + "saving": "Saving…", + "validationName": "A report name is required", + "validationTemplate": "Select a template", + "validationFormats": "Select at least one format" + }, + "history": { + "title": "Run history", + "empty": "No runs yet", + "emptyDesc": "This report hasn't run yet. Use \"Run now\" to generate the first report.", + "loadFailed": "Failed to load run history", + "window": "Window", + "started": "Started", + "viewHtml": "View HTML", + "downloadJson": "Download JSON", + "errorLabel": "Error" + }, + "delete": { + "title": "Delete report?", + "message": "\"{{name}}\" and its run history will be permanently removed.", + "confirm": "Delete", + "cancel": "Cancel" + } +} diff --git a/client/src/i18n/locales/vi/nav.json b/client/src/i18n/locales/vi/nav.json index 49fe0a1e..5f55574e 100644 --- a/client/src/i18n/locales/vi/nav.json +++ b/client/src/i18n/locales/vi/nav.json @@ -7,6 +7,7 @@ "activityFeed": "Luồng hoạt động", "analytics": "Phân tích", "workflows": "Quy trình", + "reports": "Báo cáo", "alerts": "Cảnh báo", "ccConfig": "Cấu hình Claude", "run": "Chạy Claude", diff --git a/client/src/i18n/locales/vi/reports.json b/client/src/i18n/locales/vi/reports.json new file mode 100644 index 00000000..089f128a --- /dev/null +++ b/client/src/i18n/locales/vi/reports.json @@ -0,0 +1,85 @@ +{ + "title": "Báo cáo định kỳ", + "subtitle": "Báo cáo phân tích tự động được tạo theo lịch", + "newReport": "Báo cáo mới", + "refresh": "Làm mới", + "failedLoad": "Không tải được báo cáo", + "definitions": { + "empty": "Chưa có báo cáo định kỳ", + "emptyDesc": "Tạo một báo cáo để nhận tóm tắt phân tích tự động theo lịch.", + "createFirst": "Tạo báo cáo", + "nextRun": "Lần chạy tiếp theo", + "lastRun": "Lần chạy gần nhất", + "never": "Chưa bao giờ", + "runNow": "Chạy ngay", + "running": "Đang chạy…", + "edit": "Sửa", + "delete": "Xóa", + "enabled": "Đang bật", + "disabled": "Đang tắt", + "formats": "Định dạng", + "window": "Khoảng {{count}} ngày", + "viewHistory": "Xem lịch sử", + "hideHistory": "Ẩn lịch sử" + }, + "frequency": { + "daily": "Hằng ngày", + "weekly": "Hằng tuần", + "monthly": "Hằng tháng", + "dailyAt": "Hằng ngày · {{time}}", + "weeklyAt": "Hằng tuần · {{day}} {{time}}", + "monthlyAt": "Hằng tháng · ngày 1 {{time}}" + }, + "days": { + "0": "CN", + "1": "T2", + "2": "T3", + "3": "T4", + "4": "T5", + "5": "T6", + "6": "T7" + }, + "status": { + "success": "Thành công", + "error": "Lỗi", + "pending": "Đang chờ" + }, + "form": { + "createTitle": "Báo cáo định kỳ mới", + "editTitle": "Sửa báo cáo", + "name": "Tên", + "namePlaceholder": "Tóm tắt phân tích hằng tuần", + "template": "Mẫu", + "frequency": "Tần suất", + "dayOfWeek": "Ngày trong tuần", + "hour": "Giờ (0–23)", + "formats": "Định dạng", + "windowDays": "Khoảng (ngày)", + "enabled": "Đang bật", + "enabledDesc": "Tự động chạy báo cáo này theo lịch", + "cancel": "Hủy", + "create": "Tạo báo cáo", + "save": "Lưu thay đổi", + "saving": "Đang lưu…", + "validationName": "Cần nhập tên báo cáo", + "validationTemplate": "Chọn một mẫu", + "validationFormats": "Chọn ít nhất một định dạng" + }, + "history": { + "title": "Lịch sử chạy", + "empty": "Chưa có lần chạy nào", + "emptyDesc": "Báo cáo này chưa chạy. Dùng \"Chạy ngay\" để tạo báo cáo đầu tiên.", + "loadFailed": "Không tải được lịch sử chạy", + "window": "Khoảng", + "started": "Bắt đầu", + "viewHtml": "Xem HTML", + "downloadJson": "Tải JSON", + "errorLabel": "Lỗi" + }, + "delete": { + "title": "Xóa báo cáo?", + "message": "\"{{name}}\" và lịch sử chạy của nó sẽ bị xóa vĩnh viễn.", + "confirm": "Xóa", + "cancel": "Hủy" + } +} diff --git a/client/src/i18n/locales/zh/nav.json b/client/src/i18n/locales/zh/nav.json index bbd0e565..5c6f3594 100644 --- a/client/src/i18n/locales/zh/nav.json +++ b/client/src/i18n/locales/zh/nav.json @@ -7,6 +7,7 @@ "activityFeed": "活动流", "analytics": "分析", "workflows": "工作流", + "reports": "报表", "alerts": "警报", "ccConfig": "Claude 配置", "run": "运行 Claude", diff --git a/client/src/i18n/locales/zh/reports.json b/client/src/i18n/locales/zh/reports.json new file mode 100644 index 00000000..80660b84 --- /dev/null +++ b/client/src/i18n/locales/zh/reports.json @@ -0,0 +1,85 @@ +{ + "title": "定时报表", + "subtitle": "按计划自动生成并交付分析报表", + "newReport": "新建报表", + "refresh": "刷新", + "failedLoad": "加载报表失败", + "definitions": { + "empty": "暂无定时报表", + "emptyDesc": "创建一个报表,即可按计划自动接收分析摘要。", + "createFirst": "创建报表", + "nextRun": "下次运行", + "lastRun": "上次运行", + "never": "从未", + "runNow": "立即运行", + "running": "运行中…", + "edit": "编辑", + "delete": "删除", + "enabled": "已启用", + "disabled": "已停用", + "formats": "格式", + "window": "{{count}} 天窗口", + "viewHistory": "查看历史", + "hideHistory": "隐藏历史" + }, + "frequency": { + "daily": "每天", + "weekly": "每周", + "monthly": "每月", + "dailyAt": "每天 · {{time}}", + "weeklyAt": "每周 · {{day}} {{time}}", + "monthlyAt": "每月 · 1 号 {{time}}" + }, + "days": { + "0": "周日", + "1": "周一", + "2": "周二", + "3": "周三", + "4": "周四", + "5": "周五", + "6": "周六" + }, + "status": { + "success": "成功", + "error": "失败", + "pending": "待处理" + }, + "form": { + "createTitle": "新建定时报表", + "editTitle": "编辑报表", + "name": "名称", + "namePlaceholder": "每周分析摘要", + "template": "模板", + "frequency": "频率", + "dayOfWeek": "星期几", + "hour": "小时(0–23)", + "formats": "格式", + "windowDays": "窗口(天)", + "enabled": "已启用", + "enabledDesc": "按计划自动运行此报表", + "cancel": "取消", + "create": "创建报表", + "save": "保存更改", + "saving": "保存中…", + "validationName": "请填写报表名称", + "validationTemplate": "请选择模板", + "validationFormats": "请至少选择一种格式" + }, + "history": { + "title": "运行历史", + "empty": "暂无运行记录", + "emptyDesc": "此报表尚未运行。点击“立即运行”以生成首份报表。", + "loadFailed": "加载运行历史失败", + "window": "窗口", + "started": "开始时间", + "viewHtml": "查看 HTML", + "downloadJson": "下载 JSON", + "errorLabel": "错误" + }, + "delete": { + "title": "删除报表?", + "message": "“{{name}}”及其运行历史将被永久删除。", + "confirm": "删除", + "cancel": "取消" + } +} diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 5858ebdc..a22d9a68 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -12,6 +12,9 @@ import type { CostResult, DashboardEvent, ModelPricing, + ReportDefinition, + ReportRun, + ReportTemplatesResponse, Session, SessionDrillIn, SessionStats, @@ -464,6 +467,71 @@ export const api = { ); }, }, + + // Scheduled Analytics Reports. Every list/object response from the server is + // wrapped in an envelope; we unwrap here so callers get the bare value + // (matching the alerts/webhooks groups — a prior PR shipped a bug by NOT + // unwrapping, so this is deliberate). + reports: { + templates: () => + request("/reports/templates").then((res) => ({ + templates: res.templates, + frequencies: res.frequencies, + })), + list: () => + request<{ definitions: ReportDefinition[] }>("/reports").then((res) => res.definitions), + create: (body: { + name: string; + template: string; + frequency: ReportDefinition["frequency"]; + day_of_week?: number | null; + hour?: number; + tz_offset?: number; + formats?: string[]; + window_days?: number; + enabled?: boolean; + }) => + request<{ definition: ReportDefinition }>("/reports", { + method: "POST", + body: JSON.stringify(body), + }).then((res) => res.definition), + update: ( + id: string, + body: Partial<{ + name: string; + template: string; + frequency: ReportDefinition["frequency"]; + day_of_week: number | null; + hour: number; + tz_offset: number; + formats: string[]; + window_days: number; + enabled: boolean; + }> + ) => + request<{ definition: ReportDefinition }>(`/reports/${encodeURIComponent(id)}`, { + method: "PATCH", + body: JSON.stringify(body), + }).then((res) => res.definition), + remove: (id: string) => + request<{ ok: true }>(`/reports/${encodeURIComponent(id)}`, { method: "DELETE" }), + run: (id: string) => + request<{ run: ReportRun }>(`/reports/${encodeURIComponent(id)}/run`, { + method: "POST", + }).then((res) => res.run), + runs: (id: string) => + request<{ runs: ReportRun[] }>(`/reports/${encodeURIComponent(id)}/runs`).then( + (res) => res.runs + ), + getRun: (runId: string) => + request<{ run: ReportRun }>(`/reports/runs/${encodeURIComponent(runId)}`).then( + (res) => res.run + ), + // Artifact endpoint is NOT JSON-wrapped — it's a file. Return the full URL + // so callers can open it in a new tab (html) or trigger a download (json). + artifactUrl: (runId: string, format: "html" | "json") => + `${window.location.origin}${BASE}/reports/runs/${encodeURIComponent(runId)}/artifact?format=${format}`, + }, }; function requestBackupsHelper(params?: { scope?: "user" | "project"; type?: CcArtifactType }) { diff --git a/client/src/lib/types.ts b/client/src/lib/types.ts index e21437f7..b98e654b 100644 --- a/client/src/lib/types.ts +++ b/client/src/lib/types.ts @@ -380,6 +380,54 @@ export interface WebhookTestResult { error: string | null; } +// ── Scheduled Analytics Reports ── + +export type ReportFrequency = "daily" | "weekly" | "monthly"; + +export interface ReportTemplate { + key: string; + label: string; + description: string; + default_window_days: number; +} + +export interface ReportDefinition { + id: string; + name: string; + template: string; + frequency: ReportFrequency; + day_of_week: number | null; + hour: number; + tz_offset: number; + formats: string[]; + window_days: number; + enabled: boolean; + last_run_at: string | null; + next_run_at: string | null; + last_status: "success" | "error" | null; + created_at: string; + updated_at: string; +} + +export interface ReportRun { + id: string; + definition_id: string; + template: string; + status: "success" | "error"; + started_at: string; + finished_at: string | null; + window_start: string | null; + window_end: string | null; + error: string | null; + summary: Record | null; + formats_available: string[]; +} + +export interface ReportTemplatesResponse { + templates: ReportTemplate[]; + frequencies: ReportFrequency[]; +} + export interface WSMessage { type: | "session_created" diff --git a/client/src/pages/Reports.tsx b/client/src/pages/Reports.tsx new file mode 100644 index 00000000..4513cbaf --- /dev/null +++ b/client/src/pages/Reports.tsx @@ -0,0 +1,831 @@ +/** + * @file Reports.tsx + * @description Scheduled Analytics Reports management screen. Lists report + * definitions (name, template, human-readable schedule, next run, enabled + * toggle, last-run status), with per-row Run now / Edit / Delete actions and an + * expandable per-definition run history. A modal handles create + edit. Runs + * expose "View HTML" (opens the print-friendly artifact in a new tab) and + * "Download JSON" (triggers a file download) when those formats are available. + * @author Son Nguyen + */ + +import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; +import { useTranslation } from "react-i18next"; +import { + FileBarChart, + Plus, + Pencil, + Trash2, + Play, + RefreshCw, + ChevronDown, + ChevronRight, + CheckCircle, + XCircle, + Clock, + ExternalLink, + Download, + X, + History, +} from "lucide-react"; +import { api } from "../lib/api"; +import { eventBus } from "../lib/eventBus"; +import { EmptyState } from "../components/EmptyState"; +import { Skeleton } from "../components/Skeleton"; +import { ConfirmModal } from "../components/ConfirmModal"; +import { Checkbox } from "../components/Checkbox"; +import { formatDateTime } from "../lib/format"; +import type { ReportDefinition, ReportFrequency, ReportRun, ReportTemplate } from "../lib/types"; + +const FORMAT_OPTIONS = ["html", "json"] as const; +const DAYS = [0, 1, 2, 3, 4, 5, 6] as const; + +interface FormState { + name: string; + template: string; + frequency: ReportFrequency; + day_of_week: number; + hour: number; + formats: string[]; + window_days: number; + enabled: boolean; +} + +function defaultForm(templates: ReportTemplate[]): FormState { + const first = templates[0]; + return { + name: "", + template: first?.key ?? "", + frequency: "weekly", + day_of_week: 1, + hour: 9, + formats: ["html"], + window_days: first?.default_window_days ?? 7, + enabled: true, + }; +} + +function pad2(n: number): string { + return String(n).padStart(2, "0"); +} + +export function Reports() { + const { t } = useTranslation("reports"); + + const [templates, setTemplates] = useState([]); + const [frequencies, setFrequencies] = useState(["daily", "weekly", "monthly"]); + const [definitions, setDefinitions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Modal (create + edit) state. + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form, setForm] = useState(() => defaultForm([])); + const [formError, setFormError] = useState(null); + const [saving, setSaving] = useState(false); + + // Delete confirmation. + const [confirmDelete, setConfirmDelete] = useState(null); + + // Per-row "Run now" in-flight tracking + expanded history. + const [runningId, setRunningId] = useState(null); + const [expanded, setExpanded] = useState>(() => new Set()); + const [runsById, setRunsById] = useState>({}); + const [runsLoading, setRunsLoading] = useState>(() => new Set()); + const [runsError, setRunsError] = useState>({}); + + const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected); + + const templateLabel = useCallback( + (key: string) => templates.find((tpl) => tpl.key === key)?.label ?? key, + [templates] + ); + + const load = useCallback(async () => { + try { + const [tplRes, defs] = await Promise.all([api.reports.templates(), api.reports.list()]); + setTemplates(tplRes.templates); + if (tplRes.frequencies.length > 0) setFrequencies(tplRes.frequencies); + setDefinitions(defs); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : t("failedLoad")); + } finally { + setLoading(false); + } + }, [t]); + + useEffect(() => { + load(); + }, [load]); + + const loadRuns = useCallback(async (id: string) => { + setRunsLoading((prev) => new Set(prev).add(id)); + setRunsError((prev) => { + const next = { ...prev }; + delete next[id]; + return next; + }); + try { + const runs = await api.reports.runs(id); + setRunsById((prev) => ({ ...prev, [id]: runs })); + } catch (err) { + setRunsError((prev) => ({ + ...prev, + [id]: err instanceof Error ? err.message : "error", + })); + } finally { + setRunsLoading((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + } + }, []); + + const toggleHistory = useCallback( + (id: string) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + if (!runsById[id]) loadRuns(id); + } + return next; + }); + }, + [runsById, loadRuns] + ); + + const openCreate = () => { + setEditing(null); + setForm(defaultForm(templates)); + setFormError(null); + setModalOpen(true); + }; + + const openEdit = (def: ReportDefinition) => { + setEditing(def); + setForm({ + name: def.name, + template: def.template, + frequency: def.frequency, + day_of_week: def.day_of_week ?? 1, + hour: def.hour, + formats: def.formats.length > 0 ? [...def.formats] : ["html"], + window_days: def.window_days, + enabled: def.enabled, + }); + setFormError(null); + setModalOpen(true); + }; + + const closeModal = () => { + if (saving) return; + setModalOpen(false); + setEditing(null); + }; + + const setF = (patch: Partial) => setForm((prev) => ({ ...prev, ...patch })); + + // When the chosen template changes, default the window to the template's + // suggested window (only while creating — editing keeps the saved value + // unless the user explicitly switches templates). + const onTemplateChange = (key: string) => { + const tpl = templates.find((x) => x.key === key); + setF({ template: key, window_days: tpl?.default_window_days ?? form.window_days }); + }; + + const toggleFormat = (fmt: string) => { + setForm((prev) => ({ + ...prev, + formats: prev.formats.includes(fmt) + ? prev.formats.filter((x) => x !== fmt) + : [...prev.formats, fmt], + })); + }; + + const submit = async () => { + if (saving) return; + if (!form.name.trim()) { + setFormError(t("form.validationName")); + return; + } + if (!form.template) { + setFormError(t("form.validationTemplate")); + return; + } + if (form.formats.length === 0) { + setFormError(t("form.validationFormats")); + return; + } + setSaving(true); + setFormError(null); + const body = { + name: form.name.trim(), + template: form.template, + frequency: form.frequency, + day_of_week: form.frequency === "weekly" ? form.day_of_week : null, + hour: form.hour, + tz_offset: new Date().getTimezoneOffset(), + formats: form.formats, + window_days: form.window_days, + enabled: form.enabled, + }; + try { + if (editing) { + await api.reports.update(editing.id, body); + } else { + await api.reports.create(body); + } + setModalOpen(false); + setEditing(null); + await load(); + } catch (err) { + setFormError(err instanceof Error ? err.message : String(err)); + } finally { + setSaving(false); + } + }; + + const onToggleEnabled = async (def: ReportDefinition) => { + // Optimistic flip so the toggle feels instant; reconcile from the response. + setDefinitions((prev) => + prev.map((d) => (d.id === def.id ? { ...d, enabled: !d.enabled } : d)) + ); + try { + const updated = await api.reports.update(def.id, { enabled: !def.enabled }); + setDefinitions((prev) => prev.map((d) => (d.id === def.id ? updated : d))); + } catch { + // Revert on failure. + setDefinitions((prev) => + prev.map((d) => (d.id === def.id ? { ...d, enabled: def.enabled } : d)) + ); + } + }; + + const onRunNow = async (def: ReportDefinition) => { + if (runningId) return; + setRunningId(def.id); + try { + await api.reports.run(def.id); + // Refresh the definition list (last_run_at / last_status / next_run_at + // may have moved) and this definition's run history. + await load(); + // Ensure the row's history is visible + refreshed. + setExpanded((prev) => new Set(prev).add(def.id)); + await loadRuns(def.id); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setRunningId(null); + } + }; + + const onDelete = async (def: ReportDefinition) => { + try { + await api.reports.remove(def.id); + setConfirmDelete(null); + setExpanded((prev) => { + const next = new Set(prev); + next.delete(def.id); + return next; + }); + await load(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + const describeSchedule = (def: ReportDefinition): string => { + const time = `${pad2(def.hour)}:00`; + if (def.frequency === "daily") return t("frequency.dailyAt", { time }); + if (def.frequency === "weekly") { + const day = t(`days.${def.day_of_week ?? 1}`); + return t("frequency.weeklyAt", { day, time }); + } + return t("frequency.monthlyAt", { time }); + }; + + const downloadJson = (run: ReportRun) => { + const url = api.reports.artifactUrl(run.id, "json"); + const a = document.createElement("a"); + a.href = url; + a.download = `report-${run.id}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + }; + + return ( +
+ {/* Header */} +
+
+
+ +
+
+
+

{t("title")}

+ {wsConnected ? ( + + + {t("common:live")} + + ) : ( + + + {t("common:offline")} + + )} +
+

{t("subtitle")}

+
+
+
+ + +
+
+ + {error && ( +
+ {error} +
+ )} + + {/* Definitions list */} + {loading ? ( +
+ + + +
+ ) : definitions.length === 0 ? ( + + {t("definitions.createFirst")} + + } + /> + ) : ( +
+ {definitions.map((def) => { + const isOpen = expanded.has(def.id); + return ( +
+
+
+
+ + {def.name} + + + {templateLabel(def.template)} + + +
+
+ + + {describeSchedule(def)} + + + {t("definitions.nextRun")}:{" "} + + {def.next_run_at + ? formatDateTime(def.next_run_at) + : t("definitions.never")} + + + + {t("definitions.window", { count: def.window_days })} + + + {def.formats.map((fmt) => ( + + {fmt} + + ))} + +
+
+ +
+ + + + + +
+
+ + {isOpen && ( +
+ +
+ )} +
+ ); + })} +
+ )} + + {/* Create / Edit modal */} + {modalOpen && ( +
+
e.stopPropagation()} + > +
+

+ {editing ? t("form.editTitle") : t("form.createTitle")} +

+ +
+ +
+ + +
+ + + +
+ + {/* Template description hint */} + {templates.find((x) => x.key === form.template)?.description && ( +

+ {templates.find((x) => x.key === form.template)?.description} +

+ )} + +
+ {form.frequency === "weekly" && ( + + )} + + + + +
+ +
+ {t("form.formats")} +
+ {FORMAT_OPTIONS.map((fmt) => ( + toggleFormat(fmt)} + label={fmt.toUpperCase()} + /> + ))} +
+
+ +
+ setF({ enabled: v })} + label={t("form.enabled")} + /> +

{t("form.enabledDesc")}

+
+ + {formError &&

{formError}

} +
+ +
+ + +
+
+
+ )} + + setConfirmDelete(null)} + onConfirm={() => confirmDelete && onDelete(confirmDelete)} + /> +
+ ); +} + +// ── Status badge for a definition's last run / a single run ── + +function StatusBadge({ + status, + t, +}: { + status: "success" | "error" | null; + t: (key: string, opts?: Record) => string; +}) { + if (status === "success") { + return ( + + + {t("status.success")} + + ); + } + if (status === "error") { + return ( + + + {t("status.error")} + + ); + } + return ( + + {t("status.pending")} + + ); +} + +// ── Per-definition run history ── + +function RunHistory({ + defId, + loading, + error, + runs, + onDownloadJson, + t, +}: { + defId: string; + loading: boolean; + error?: string; + runs?: ReportRun[]; + onDownloadJson: (run: ReportRun) => void; + t: (key: string, opts?: Record) => string; +}) { + if (loading) { + return ( +
+ + +
+ ); + } + + if (error) { + return ( +
+ {t("history.loadFailed")}: {error} +
+ ); + } + + if (!runs || runs.length === 0) { + return ( + + ); + } + + return ( +
+

+ {t("history.title")} +

+
    + {runs.map((run) => { + const hasHtml = run.formats_available.includes("html"); + const hasJson = run.formats_available.includes("json"); + return ( +
  • +
    +
    + + + {t("history.started")}: {formatDateTime(run.started_at)} + + {run.window_start && run.window_end && ( + + {t("history.window")}: {formatDateTime(run.window_start)} →{" "} + {formatDateTime(run.window_end)} + + )} +
    + {run.status === "error" && run.error && ( +

    + {t("history.errorLabel")}: {run.error} +

    + )} +
    +
    + {hasHtml && ( + + {t("history.viewHtml")} + + + )} + {hasJson && ( + + )} +
    +
  • + ); + })} +
+
+ ); +} diff --git a/server/__tests__/reports.test.js b/server/__tests__/reports.test.js new file mode 100644 index 00000000..d991119b --- /dev/null +++ b/server/__tests__/reports.test.js @@ -0,0 +1,523 @@ +/** + * @file Integration tests for Scheduled Analytics Reports (server/routes/reports.js, + * server/lib/report-generator.js, server/lib/report-scheduler.js). Covers + * definition CRUD + validation, computeNextRun determinism, windowed-number + * consistency with the analytics-style queries, on-demand run + artifact + * download, error-run capture (no crash), runDueReports due/not-due selection, + * artifact Content-Type + 404 for an ungenerated format, and HTML escaping of a + * hostile session name. + * @author Son Nguyen + */ + +const { describe, it, before, after } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const http = require("http"); + +const TEST_DB = path.join(os.tmpdir(), `dashboard-reports-test-${Date.now()}-${process.pid}.db`); +process.env.DASHBOARD_DB_PATH = TEST_DB; +// Keep the background tick out of the way — we drive runDueReports directly. +process.env.DASHBOARD_REPORTS_DISABLED = "1"; + +const { createApp, startServer } = require("../index"); +const dbModule = require("../db"); +const { db } = dbModule; +const { computeNextRun, generateReport } = require("../lib/report-generator"); +const { runDueReports } = require("../lib/report-scheduler"); + +let server; +let BASE; + +// Fixed window for the consistency tests. Everything seeded sits inside it. +const WINDOW_START = "2026-01-01T00:00:00.000Z"; +const WINDOW_END = "2026-01-08T00:00:00.000Z"; +const HOSTILE_NAME = ''; + +function fetchJson(urlPath, options = {}) { + return new Promise((resolve, reject) => { + const url = new URL(urlPath, BASE); + const req = http.request( + { + hostname: url.hostname, + port: url.port, + path: url.pathname + url.search, + method: options.method || "GET", + headers: { "Content-Type": "application/json", ...options.headers }, + }, + (res) => { + let body = ""; + res.on("data", (c) => (body += c)); + res.on("end", () => { + const ct = res.headers["content-type"] || ""; + if (ct.includes("application/json")) { + try { + resolve({ + status: res.statusCode, + headers: res.headers, + body: body ? JSON.parse(body) : null, + }); + return; + } catch { + /* fall through to raw */ + } + } + resolve({ status: res.statusCode, headers: res.headers, body }); + }); + } + ); + req.on("error", reject); + if (options.body) req.write(JSON.stringify(options.body)); + req.end(); + }); +} + +function seedWindow() { + // Two sessions inside the window (one with a hostile name), one outside. + const insSession = db.prepare( + "INSERT INTO sessions (id, name, status, started_at) VALUES (?, ?, ?, ?)" + ); + insSession.run("rs-in-1", HOSTILE_NAME, "completed", "2026-01-02T10:00:00.000Z"); + insSession.run("rs-in-2", "Quiet session", "completed", "2026-01-04T10:00:00.000Z"); + insSession.run("rs-out-1", "Out of window", "completed", "2025-12-20T10:00:00.000Z"); + + const insAgent = db.prepare( + "INSERT INTO agents (id, session_id, name, type, subagent_type, status) VALUES (?, ?, ?, ?, ?, ?)" + ); + insAgent.run("rs-in-1-main", "rs-in-1", "main", "main", null, "completed"); + insAgent.run("rs-in-1-sub", "rs-in-1", "sub", "subagent", "Explore", "error"); + insAgent.run("rs-in-2-main", "rs-in-2", "main", "main", null, "completed"); + insAgent.run("rs-out-main", "rs-out-1", "main", "main", null, "working"); + + const insEvent = db.prepare( + "INSERT INTO events (session_id, event_type, tool_name, summary, created_at) VALUES (?, ?, ?, ?, ?)" + ); + // In-window events + insEvent.run("rs-in-1", "PreToolUse", "Bash", "ran ls", "2026-01-02T10:01:00.000Z"); + insEvent.run("rs-in-1", "PreToolUse", "Bash", "ran cat", "2026-01-02T10:02:00.000Z"); + insEvent.run("rs-in-1", "PreToolUse", "Read", "read file", "2026-01-02T10:03:00.000Z"); + insEvent.run("rs-in-2", "PreToolUse", "Edit", "edit file", "2026-01-04T10:01:00.000Z"); + // A failure-prone event (error convention) + insEvent.run("rs-in-1", "ToolError", "Bash", "Failed to run", "2026-01-02T10:04:00.000Z"); + // Out-of-window event (must NOT be counted) + insEvent.run("rs-out-1", "PreToolUse", "Bash", "ran ls", "2025-12-20T10:01:00.000Z"); + + // token_usage for in-window + out-of-window sessions + const insTok = db.prepare( + "INSERT INTO token_usage (session_id, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens) VALUES (?, ?, ?, ?, ?, ?)" + ); + insTok.run("rs-in-1", "claude-opus-4-8", 1_000_000, 500_000, 0, 0); + insTok.run("rs-in-2", "claude-opus-4-8", 2_000_000, 0, 0, 0); + insTok.run("rs-out-1", "claude-opus-4-8", 9_000_000, 9_000_000, 0, 0); // outside window +} + +before(async () => { + seedWindow(); + const app = createApp(); + server = await startServer(app, 0); + BASE = `http://127.0.0.1:${server.address().port}`; +}); + +after(() => { + server?.close(); + try { + db.close(); + } catch { + /* ignore */ + } + try { + fs.rmSync(TEST_DB, { force: true }); + } catch { + /* ignore */ + } +}); + +describe("GET /api/reports/templates", () => { + it("lists templates + frequencies", async () => { + const r = await fetchJson("/api/reports/templates"); + assert.equal(r.status, 200); + assert.ok(Array.isArray(r.body.templates) && r.body.templates.length >= 4); + assert.deepEqual(r.body.frequencies, ["daily", "weekly", "monthly"]); + const wk = r.body.templates.find((t) => t.key === "weekly_health"); + assert.ok(wk && typeof wk.default_window_days === "number"); + }); +}); + +describe("definition CRUD + validation", () => { + it("creates a valid weekly definition with next_run_at", async () => { + const r = await fetchJson("/api/reports", { + method: "POST", + body: { + name: "My Weekly", + template: "weekly_health", + frequency: "weekly", + day_of_week: 1, + hour: 9, + tz_offset: 0, + formats: ["html", "json"], + }, + }); + assert.equal(r.status, 201); + const d = r.body.definition; + assert.ok(d.id); + assert.equal(d.frequency, "weekly"); + assert.equal(d.day_of_week, 1); + assert.equal(d.enabled, true); + assert.ok(typeof d.next_run_at === "string" && d.next_run_at.endsWith("Z")); + assert.equal(d.last_status, null); + assert.deepEqual(d.formats, ["html", "json"]); + }); + + it("rejects unknown template / frequency / bad hour / weekly without day_of_week / bad formats / bad window_days", async () => { + const base = { name: "x", template: "weekly_health", frequency: "daily", hour: 9 }; + const cases = [ + { ...base, template: "nope" }, + { ...base, frequency: "hourly" }, + { ...base, hour: 24 }, + { name: "x", template: "weekly_health", frequency: "weekly", hour: 9 }, // weekly, no day_of_week + { ...base, formats: [] }, + { ...base, formats: ["pdf"] }, + { ...base, window_days: 0 }, + { ...base, window_days: 100020623 }, // huge → would overflow Date if accepted + { ...base, name: "" }, + ]; + for (const body of cases) { + const r = await fetchJson("/api/reports", { method: "POST", body }); + assert.equal(r.status, 400, `expected 400 for ${JSON.stringify(body)}`); + assert.ok(r.body.error && r.body.error.message); + } + }); + + it("lists definitions newest-first", async () => { + const r = await fetchJson("/api/reports"); + assert.equal(r.status, 200); + assert.ok(Array.isArray(r.body.definitions)); + assert.ok(r.body.definitions.length >= 1); + }); + + it("patches a definition and recomputes next_run_at; 404 unknown", async () => { + const created = ( + await fetchJson("/api/reports", { + method: "POST", + body: { name: "Patch me", template: "tool_usage", frequency: "daily", hour: 8 }, + }) + ).body.definition; + + const patched = await fetchJson(`/api/reports/${created.id}`, { + method: "PATCH", + body: { hour: 15, enabled: false }, + }); + assert.equal(patched.status, 200); + assert.equal(patched.body.definition.hour, 15); + assert.equal(patched.body.definition.enabled, false); + assert.notEqual(patched.body.definition.next_run_at, created.next_run_at); + + const miss = await fetchJson("/api/reports/does-not-exist", { + method: "PATCH", + body: { hour: 1 }, + }); + assert.equal(miss.status, 404); + }); + + it("deletes a definition + its runs; 404 unknown", async () => { + const created = ( + await fetchJson("/api/reports", { + method: "POST", + body: { name: "Delete me", template: "session_throughput", frequency: "daily" }, + }) + ).body.definition; + // Generate a run so we can assert cascade cleanup. + await fetchJson(`/api/reports/${created.id}/run`, { method: "POST" }); + const del = await fetchJson(`/api/reports/${created.id}`, { method: "DELETE" }); + assert.equal(del.status, 200); + assert.deepEqual(del.body, { ok: true }); + // Runs gone. + const runsRow = db + .prepare("SELECT COUNT(*) as c FROM report_runs WHERE definition_id = ?") + .get(created.id); + assert.equal(runsRow.c, 0); + const miss = await fetchJson("/api/reports/does-not-exist", { method: "DELETE" }); + assert.equal(miss.status, 404); + }); +}); + +describe("computeNextRun determinism", () => { + // Pin a reference instant: 2026-01-05 is a Monday. 10:00 UTC. + const fromMs = Date.parse("2026-01-05T10:00:00.000Z"); + + it("daily: same day if hour ahead, next day if passed", () => { + assert.equal( + computeNextRun({ frequency: "daily", hour: 15, tz_offset: 0 }, fromMs), + "2026-01-05T15:00:00.000Z" + ); + assert.equal( + computeNextRun({ frequency: "daily", hour: 8, tz_offset: 0 }, fromMs), + "2026-01-06T08:00:00.000Z" + ); + }); + + it("daily: respects tz_offset (minutes west of UTC)", () => { + // tz_offset 60 (UTC-1): local now is 09:00; hour 10 local → 11:00 UTC today. + assert.equal( + computeNextRun({ frequency: "daily", hour: 10, tz_offset: 60 }, fromMs), + "2026-01-05T11:00:00.000Z" + ); + }); + + it("weekly: next occurrence of day_of_week at hour", () => { + // Monday now; target Wednesday (3) at 09:00 → 2026-01-07. + assert.equal( + computeNextRun({ frequency: "weekly", day_of_week: 3, hour: 9, tz_offset: 0 }, fromMs), + "2026-01-07T09:00:00.000Z" + ); + // Target Monday (1) but 08:00 already passed today → next Monday 2026-01-12. + assert.equal( + computeNextRun({ frequency: "weekly", day_of_week: 1, hour: 8, tz_offset: 0 }, fromMs), + "2026-01-12T08:00:00.000Z" + ); + }); + + it("monthly: 1st of next month when this month's already passed", () => { + // Jan 1 at 09:00 already passed by Jan 5 → Feb 1. + assert.equal( + computeNextRun({ frequency: "monthly", hour: 9, tz_offset: 0 }, fromMs), + "2026-02-01T09:00:00.000Z" + ); + }); +}); + +describe("windowed-number consistency with analytics-style queries", () => { + it("report counts equal direct windowed queries over the fixed window", () => { + const { data } = generateReport(dbModule, { + template: "weekly_health", + windowStart: WINDOW_START, + windowEnd: WINDOW_END, + tzOffset: 0, + }); + + // Sessions started in window: rs-in-1, rs-in-2 → 2 + const expSessions = db + .prepare("SELECT COUNT(*) c FROM sessions WHERE started_at >= ? AND started_at < ?") + .get(WINDOW_START, WINDOW_END).c; + assert.equal(data.total_sessions, expSessions); + assert.equal(data.total_sessions, 2); + + // Events created in window: 5 (4 normal + 1 error), out-of-window excluded + const expEvents = db + .prepare("SELECT COUNT(*) c FROM events WHERE created_at >= ? AND created_at < ?") + .get(WINDOW_START, WINDOW_END).c; + assert.equal(data.total_events, expEvents); + assert.equal(data.total_events, 5); + + // Top tools: Bash appears 3x (2 PreToolUse + 1 ToolError), Read 1, Edit 1 + const bash = data.top_tools.find((t) => t.tool_name === "Bash"); + assert.equal(bash.count, 3); + + // Agent status distribution for in-window sessions + const expAgents = db + .prepare( + `SELECT a.status status, COUNT(*) c FROM agents a JOIN sessions s ON s.id = a.session_id + WHERE s.started_at >= ? AND s.started_at < ? GROUP BY a.status` + ) + .all(WINDOW_START, WINDOW_END); + const expMap = Object.fromEntries(expAgents.map((r) => [r.status, r.c])); + assert.deepEqual(data.agents_by_status, expMap); + assert.equal(data.agents_by_status.completed, 2); + assert.equal(data.agents_by_status.error, 1); + + // Failure-prone operations: one error event on Bash + const failBash = data.failure_prone_operations.find((o) => o.operation === "Bash"); + assert.ok(failBash && failBash.count === 1); + + // Tokens: only in-window sessions counted (3M input, 0.5M output), NOT the + // 9M/9M out-of-window session. + assert.equal(data.tokens.total_input, 3_000_000); + assert.equal(data.tokens.total_output, 500_000); + // Cost mirrors calculateCost on the windowed buckets: opus-4-8 = $5/MTok in, + // $25/MTok out → 3*5 + 0.5*25 = 27.5 + assert.equal(data.cost.total_cost, 27.5); + }); +}); + +describe("POST /api/reports/:id/run", () => { + let defId; + let runId; + + it("produces a success run with downloadable html+json artifacts", async () => { + const def = ( + await fetchJson("/api/reports", { + method: "POST", + body: { + name: "Run now", + template: "weekly_health", + frequency: "daily", + hour: 9, + tz_offset: 0, + window_days: 3650, // wide enough to include the fixed window + formats: ["html", "json"], + }, + }) + ).body.definition; + defId = def.id; + + const r = await fetchJson(`/api/reports/${defId}/run`, { method: "POST" }); + assert.equal(r.status, 200); + const run = r.body.run; + runId = run.id; + assert.equal(run.status, "success"); + assert.equal(run.error, null); + assert.ok(run.summary && typeof run.summary === "object"); + assert.deepEqual(run.formats_available.sort(), ["html", "json"]); + // list/detail must NOT carry artifact bodies + assert.ok(!("artifact_html" in run)); + assert.ok(!("artifact_json" in run)); + }); + + it("downloads the JSON artifact with application/json", async () => { + const r = await fetchJson(`/api/reports/runs/${runId}/artifact?format=json`); + assert.equal(r.status, 200); + assert.match(r.headers["content-type"], /application\/json/); + assert.match(r.headers["content-disposition"] || "", /attachment/); + assert.ok(r.body && r.body.template === "weekly_health"); + }); + + it("downloads the HTML artifact with text/html and escapes a hostile session name", async () => { + const r = await fetchJson(`/api/reports/runs/${runId}/artifact?format=html`); + assert.equal(r.status, 200); + assert.match(r.headers["content-type"], /text\/html; charset=utf-8/); + assert.match(r.headers["content-disposition"] || "", /inline/); + // The hostile session name only appears (if at all) escaped — never raw. + assert.ok(!r.body.includes(''), "raw script must not appear"); + assert.ok(r.body.startsWith("")); + assert.ok(r.body.includes("@media print")); + }); + + it("lists runs for the definition (newest first, metadata only)", async () => { + const r = await fetchJson(`/api/reports/${defId}/runs`); + assert.equal(r.status, 200); + assert.ok(Array.isArray(r.body.runs) && r.body.runs.length >= 1); + assert.ok(!("artifact_html" in r.body.runs[0])); + }); + + it("404s run/runs/artifact for unknown ids and ungenerated formats", async () => { + assert.equal((await fetchJson("/api/reports/nope/run", { method: "POST" })).status, 404); + assert.equal((await fetchJson("/api/reports/nope/runs")).status, 404); + assert.equal((await fetchJson("/api/reports/runs/nope")).status, 404); + assert.equal((await fetchJson("/api/reports/runs/nope/artifact?format=html")).status, 404); + + // A definition with ONLY json → requesting html artifact 404s. + const jsonOnly = ( + await fetchJson("/api/reports", { + method: "POST", + body: { + name: "json only", + template: "token_spend", + frequency: "daily", + formats: ["json"], + window_days: 3650, + }, + }) + ).body.definition; + const run = (await fetchJson(`/api/reports/${jsonOnly.id}/run`, { method: "POST" })).body.run; + assert.deepEqual(run.formats_available, ["json"]); + const html = await fetchJson(`/api/reports/runs/${run.id}/artifact?format=html`); + assert.equal(html.status, 404); + const json = await fetchJson(`/api/reports/runs/${run.id}/artifact?format=json`); + assert.equal(json.status, 200); + }); +}); + +describe("error-run capture (no crash)", () => { + it("captures a real generation error as an error run with a populated error string", () => { + // Force a deterministic failure inside generateReport by making the pricing + // lookup throw (token_spend hits calculateCost → listPricing). The run must + // be persisted with status "error" and a populated error string — never + // thrown out of runReportForDefinition. + const realListPricing = dbModule.stmts.listPricing; + dbModule.stmts.listPricing = { + all() { + throw new Error("pricing-explode"); + }, + }; + db.prepare( + `INSERT INTO report_definitions (id, name, template, frequency, hour, tz_offset, formats, enabled, next_run_at) + VALUES ('rs-err', 'err', 'token_spend', 'daily', 9, 0, '["json"]', 1, ?)` + ).run("2026-01-01T09:00:00.000Z"); + const { runReportForDefinition } = require("../routes/reports"); + let run; + try { + run = runReportForDefinition( + db.prepare("SELECT * FROM report_definitions WHERE id = 'rs-err'").get() + ); + } finally { + dbModule.stmts.listPricing = realListPricing; + } + assert.equal(run.status, "error"); + assert.ok(run.error && run.error.includes("pricing-explode")); + assert.equal(run.artifact_json, null); + }); + + it("degrades a hand-stored out-of-range window_days to an error run (not a throw)", () => { + // The validator rejects a huge window_days, but a corrupt/hand-edited row + // shouldn't crash the run path or wedge the scheduler. Insert one directly, + // bypassing validation, and assert runReportForDefinition returns an + // error-status run rather than throwing a RangeError. + db.prepare( + `INSERT INTO report_definitions (id, name, template, frequency, hour, tz_offset, formats, enabled, next_run_at, window_days) + VALUES ('rs-bigwin', 'big', 'session_throughput', 'daily', 9, 0, '["json"]', 1, ?, 100020623)` + ).run("2026-01-01T09:00:00.000Z"); + const { runReportForDefinition } = require("../routes/reports"); + const run = runReportForDefinition( + db.prepare("SELECT * FROM report_definitions WHERE id = 'rs-bigwin'").get() + ); + assert.equal(run.status, "error"); + assert.ok(run.error, "error string is populated"); + // Schedule still advanced (next_run_at moved forward) so it can't re-fire forever. + const def = db + .prepare("SELECT next_run_at FROM report_definitions WHERE id = 'rs-bigwin'") + .get(); + assert.notEqual(def.next_run_at, "2026-01-01T09:00:00.000Z"); + }); +}); + +describe("runDueReports", () => { + it("runs a due definition and skips a not-yet-due one", () => { + const nowMs = Date.parse("2026-06-01T12:00:00.000Z"); + // Due: next_run_at in the past. + db.prepare( + `INSERT INTO report_definitions (id, name, template, frequency, hour, tz_offset, formats, enabled, next_run_at, window_days) + VALUES ('rs-due', 'due', 'session_throughput', 'daily', 9, 0, '["json"]', 1, '2026-05-01T00:00:00.000Z', 3650)` + ).run(); + // Not due: next_run_at in the future. + db.prepare( + `INSERT INTO report_definitions (id, name, template, frequency, hour, tz_offset, formats, enabled, next_run_at, window_days) + VALUES ('rs-future', 'future', 'session_throughput', 'daily', 9, 0, '["json"]', 1, '2026-07-01T00:00:00.000Z', 3650)` + ).run(); + // Disabled but due: must be skipped. + db.prepare( + `INSERT INTO report_definitions (id, name, template, frequency, hour, tz_offset, formats, enabled, next_run_at, window_days) + VALUES ('rs-disabled', 'disabled', 'session_throughput', 'daily', 9, 0, '["json"]', 0, '2026-05-01T00:00:00.000Z', 3650)` + ).run(); + + const broadcasts = []; + const produced = runDueReports(nowMs, (type, payload) => broadcasts.push({ type, payload })); + + const ids = produced.map((r) => r.definition_id); + assert.ok(ids.includes("rs-due"), "due definition ran"); + assert.ok(!ids.includes("rs-future"), "future definition skipped"); + assert.ok(!ids.includes("rs-disabled"), "disabled definition skipped"); + + // next_run_at advanced past now for the due def. + const advanced = db + .prepare("SELECT next_run_at, last_run_at FROM report_definitions WHERE id = 'rs-due'") + .get(); + assert.ok(advanced.last_run_at, "last_run_at stamped"); + assert.ok(Date.parse(advanced.next_run_at) > nowMs, "next_run_at advanced past now"); + + // broadcast emitted report_run with metadata-only payload. + assert.ok(broadcasts.some((b) => b.type === "report_run")); + const payload = broadcasts.find((b) => b.type === "report_run").payload; + assert.ok(!("artifact_json" in payload)); + }); +}); diff --git a/server/db.js b/server/db.js index f95875cd..beccffd5 100644 --- a/server/db.js +++ b/server/db.js @@ -380,6 +380,52 @@ db.exec(` CREATE INDEX IF NOT EXISTS idx_workflows_session ON workflows(session_id); CREATE INDEX IF NOT EXISTS idx_workflows_status ON workflows(status); + + -- Scheduled analytics reports. A definition is a saved schedule (template + + -- cadence + timezone); the scheduler materializes a report_runs row each time + -- a definition comes due. Definitions are user configuration and survive Clear + -- Data (like alert_rules / webhook_targets) — they hold no session FKs, so + -- they're intentionally NOT wiped by /api/settings/clear-data. window_days + -- NULL means "use the template default". + CREATE TABLE IF NOT EXISTS report_definitions ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + template TEXT NOT NULL, + frequency TEXT NOT NULL CHECK(frequency IN ('daily','weekly','monthly')), + day_of_week INTEGER, + hour INTEGER NOT NULL DEFAULT 9, + tz_offset INTEGER NOT NULL DEFAULT 0, + formats TEXT NOT NULL DEFAULT '["html","json"]', + window_days INTEGER, + enabled INTEGER NOT NULL DEFAULT 1, + last_run_at TEXT, + next_run_at TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) + ); + + -- Materialized report runs. Each row is one generation of a definition over a + -- concrete window. status is success|error; on error the error string is + -- populated and the artifacts are null. summary holds a few headline numbers + -- (JSON); artifact_html / artifact_json hold the rendered deliverables and are + -- served only via the artifact endpoint (never in list/detail payloads). + CREATE TABLE IF NOT EXISTS report_runs ( + id TEXT PRIMARY KEY, + definition_id TEXT NOT NULL, + template TEXT NOT NULL, + status TEXT NOT NULL CHECK(status IN ('success','error')), + started_at TEXT NOT NULL, + finished_at TEXT, + window_start TEXT, + window_end TEXT, + error TEXT, + summary TEXT, + artifact_html TEXT, + artifact_json TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) + ); + + CREATE INDEX IF NOT EXISTS idx_report_runs_def ON report_runs(definition_id, created_at DESC); `); // Migrate: link agent rows to a workflow run. Workflow inner-agents are already diff --git a/server/index.js b/server/index.js index 5b819a7b..99fa4342 100644 --- a/server/index.js +++ b/server/index.js @@ -53,6 +53,7 @@ const ccConfigRouter = require("./routes/cc-config"); const runRouter = require("./routes/run"); const alertsRouter = require("./routes/alerts"); const webhooksRouter = require("./routes/webhooks"); +const reportsRouter = require("./routes/reports"); function createApp() { const app = express(); @@ -77,6 +78,7 @@ function createApp() { app.use("/api/run", runRouter); app.use("/api/alerts", alertsRouter); app.use("/api/webhooks", webhooksRouter); + app.use("/api/reports", reportsRouter); app.get("/api/openapi.json", (_req, res) => { res.json(openApiSpec); }); @@ -256,6 +258,14 @@ function startBackgroundServices() { } catch (err) { console.warn("dashboard-runs reconciliation failed:", err.message); } + // Scheduled Analytics Reports: materialize a run for each enabled definition + // as it comes due (60s unref'd tick; disable with DASHBOARD_REPORTS_DISABLED). + try { + const { startReportScheduler } = require("./lib/report-scheduler"); + startReportScheduler({ broadcast }); + } catch (err) { + console.warn("report scheduler failed to start:", err.message); + } } /** diff --git a/server/lib/report-generator.js b/server/lib/report-generator.js new file mode 100644 index 00000000..27ccff41 --- /dev/null +++ b/server/lib/report-generator.js @@ -0,0 +1,463 @@ +/** + * @file Pure-ish report generator for Scheduled Analytics Reports. Owns the + * report TEMPLATES catalog, a deterministic next-run-time calculator + * (computeNextRun), and the windowed report builder (generateReport). All + * numbers mirror the /api/analytics computations (same columns/grouping) but + * are scoped to a report period. The HTML artifact is a self-contained, + * print-friendly document with every dynamic value HTML-escaped. + * @author Son Nguyen + */ + +const { calculateCost } = require("../routes/pricing"); + +/** + * Report templates. `default_window_days` seeds a definition's window when the + * user leaves window_days unset. Section coverage is decided in generateReport + * by template key (see SECTIONS_BY_TEMPLATE). + */ +const TEMPLATES = [ + { + key: "weekly_health", + label: "Weekly Health Report", + description: + "Full-spectrum snapshot: session throughput, daily events, agent status, top tools, failure-prone operations, and token spend.", + default_window_days: 7, + }, + { + key: "tool_usage", + label: "Tool Usage Report", + description: "Most-used tools and the operations most prone to failure over the period.", + default_window_days: 7, + }, + { + key: "token_spend", + label: "Token & Cost Report", + description: "Token consumption and estimated cost broken down for the period.", + default_window_days: 30, + }, + { + key: "session_throughput", + label: "Session Throughput Report", + description: "Session volume trend, daily events, and agent status distribution.", + default_window_days: 7, + }, +]; + +const TEMPLATE_KEYS = new Set(TEMPLATES.map((t) => t.key)); + +// Which structured sections each template renders. Keeping this declarative +// keeps generateReport's section assembly honest and trivially testable. +const SECTIONS_BY_TEMPLATE = { + weekly_health: ["sessions", "events", "agents", "tools", "failures", "tokens"], + tool_usage: ["tools", "failures"], + token_spend: ["tokens"], + session_throughput: ["sessions", "events", "agents"], +}; + +/** HTML-escape a value for safe interpolation into the artifact. */ +function esc(value) { + if (value === null || value === undefined) return ""; + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * Compute the next occurrence of a schedule, as an ISO-8601 (UTC, `Z`) string. + * + * The schedule is expressed in the definition's timezone: `hour` (0-23) is a + * local hour, `tz_offset` is minutes west of UTC exactly like the browser's + * `Date.prototype.getTimezoneOffset()` (e.g. 420 for PDT, -480 for CST). We + * therefore convert "now" into that local frame, find the next local trigger + * instant for the cadence, then convert back to UTC. Deterministic: pass + * `fromMs` to pin the reference instant (defaults to Date.now()). + * + * - daily → next day where local time reaches `hour`. + * - weekly → next `day_of_week` (0=Sun..6=Sat) at `hour`. + * - monthly → the 1st of the next month at `hour` (anchored to month start so + * the cadence is well-defined regardless of month length). + */ +function computeNextRun({ frequency, day_of_week, hour, tz_offset }, fromMs = Date.now()) { + const offsetMin = Number.isFinite(tz_offset) ? tz_offset : 0; + const h = Number.isFinite(hour) ? hour : 9; + // Local-frame "now": shifting by the offset lets us read UTC getters as if + // they were local-clock fields. offset is minutes WEST of UTC, so local = + // utc - offset. + const localNow = new Date(fromMs - offsetMin * 60_000); + + // Build a candidate local trigger instant and convert local→UTC by adding the + // offset back. Date.UTC keeps us in the same shifted frame (no host-TZ leak). + const toUtcMs = (y, mo, d, hr) => Date.UTC(y, mo, d, hr, 0, 0, 0) + offsetMin * 60_000; + + const y = localNow.getUTCFullYear(); + const mo = localNow.getUTCMonth(); + const d = localNow.getUTCDate(); + + if (frequency === "monthly") { + // 1st of the current month at `hour`; if already passed, 1st of next month. + let candidate = toUtcMs(y, mo, 1, h); + if (candidate <= fromMs) candidate = toUtcMs(y, mo + 1, 1, h); + return new Date(candidate).toISOString(); + } + + if (frequency === "weekly") { + const targetDow = Number.isFinite(day_of_week) ? ((day_of_week % 7) + 7) % 7 : 0; + const localDow = localNow.getUTCDay(); + let deltaDays = (targetDow - localDow + 7) % 7; + let candidate = toUtcMs(y, mo, d + deltaDays, h); + if (candidate <= fromMs) { + // Today is the target day but the hour has passed → jump a full week. + deltaDays += 7; + candidate = toUtcMs(y, mo, d + deltaDays, h); + } + return new Date(candidate).toISOString(); + } + + // daily (default) + let candidate = toUtcMs(y, mo, d, h); + if (candidate <= fromMs) candidate = toUtcMs(y, mo, d + 1, h); + return new Date(candidate).toISOString(); +} + +// ── Windowed queries — mirror the analytics statements, scoped to [start,end). +// Prepared lazily per dbModule and cached, so the generator stays a pure module +// without a hard import-order dependency on db.js. +const stmtCache = new WeakMap(); + +function windowedStmts(dbModule) { + let cached = stmtCache.get(dbModule); + if (cached) return cached; + const { db } = dbModule; + cached = { + // sessions started in the window (mirrors dailySessionCounts shape) + dailySessions: db.prepare(` + SELECT DATE(started_at, ?) as date, COUNT(*) as count + FROM sessions + WHERE started_at >= ? AND started_at < ? + GROUP BY 1 + ORDER BY date ASC + `), + sessionCount: db.prepare( + "SELECT COUNT(*) as count FROM sessions WHERE started_at >= ? AND started_at < ?" + ), + // events created in the window (mirrors dailyEventCounts shape) + dailyEvents: db.prepare(` + SELECT DATE(created_at, ?) as date, COUNT(*) as count + FROM events + WHERE created_at >= ? AND created_at < ? + GROUP BY 1 + ORDER BY date ASC + `), + eventCount: db.prepare( + "SELECT COUNT(*) as count FROM events WHERE created_at >= ? AND created_at < ?" + ), + // agent status distribution for agents in sessions started in the window + // (mirrors agentStatusCounts: SELECT status, COUNT(*) ... GROUP BY status) + agentStatusCounts: db.prepare(` + SELECT a.status as status, COUNT(*) as count + FROM agents a + JOIN sessions s ON s.id = a.session_id + WHERE s.started_at >= ? AND s.started_at < ? + GROUP BY a.status + `), + // top tools by event volume in the window (mirrors toolUsageCounts) + toolUsageCounts: db.prepare(` + SELECT tool_name, COUNT(*) as count + FROM events + WHERE tool_name IS NOT NULL AND created_at >= ? AND created_at < ? + GROUP BY tool_name + ORDER BY count DESC + LIMIT 20 + `), + // failure-prone operations: error/failed events grouped by tool, in window. + // Uses the same error convention as sessionErrorCount (event_type or summary + // prefixed with error/failed). + failureProneOps: db.prepare(` + SELECT COALESCE(tool_name, event_type) as operation, COUNT(*) as count + FROM events + WHERE created_at >= ? AND created_at < ? + AND ( + LOWER(event_type) LIKE '%error%' + OR LOWER(event_type) LIKE '%failed%' + OR LOWER(summary) LIKE 'error%' + OR LOWER(summary) LIKE 'failed%' + ) + GROUP BY operation + ORDER BY count DESC + LIMIT 20 + `), + // token buckets for sessions started in the window (effective = current + + // baseline, exactly like the analytics/pricing aggregations). + tokenBuckets: db.prepare(` + SELECT tu.model as model, tu.speed as speed, tu.inference_geo as inference_geo, + tu.service_tier as service_tier, + SUM(tu.input_tokens + tu.baseline_input) as input_tokens, + SUM(tu.output_tokens + tu.baseline_output) as output_tokens, + SUM(tu.cache_read_tokens + tu.baseline_cache_read) as cache_read_tokens, + SUM(tu.cache_write_tokens + tu.baseline_cache_write) as cache_write_tokens, + SUM(tu.cache_write_1h_tokens + tu.baseline_cache_write_1h) as cache_write_1h_tokens, + SUM(tu.web_search_requests + tu.baseline_web_search) as web_search_requests, + SUM(tu.web_fetch_requests + tu.baseline_web_fetch) as web_fetch_requests, + SUM(tu.code_execution_requests + tu.baseline_code_execution) as code_execution_requests + FROM token_usage tu + JOIN sessions s ON s.id = tu.session_id + WHERE s.started_at >= ? AND s.started_at < ? + GROUP BY tu.model, tu.speed, tu.inference_geo, tu.service_tier + `), + }; + stmtCache.set(dbModule, cached); + return cached; +} + +/** + * Generate a report over [windowStart, windowEnd). Returns + * `{ summary, data, html }`: + * - `data` structured JSON (sections vary by template). + * - `html` self-contained print-friendly document (all values escaped). + * - `summary` a few headline numbers for the run list. + * + * `tzOffset` is minutes west of UTC (browser getTimezoneOffset) and is negated + * into a SQLite modifier so daily grouping uses local dates — exactly as + * /api/analytics does. + */ +function generateReport(dbModule, { template, windowStart, windowEnd, tzOffset = 0 }) { + const stmts = windowedStmts(dbModule); + const tzModifier = Number.isFinite(tzOffset) ? `${-tzOffset} minutes` : "+0 minutes"; + const sections = SECTIONS_BY_TEMPLATE[template] || SECTIONS_BY_TEMPLATE.weekly_health; + const want = new Set(sections); + + const data = { + template, + window_start: windowStart, + window_end: windowEnd, + tz_offset: Number.isFinite(tzOffset) ? tzOffset : 0, + }; + + if (want.has("sessions")) { + data.session_volume = stmts.dailySessions.all(tzModifier, windowStart, windowEnd); + data.total_sessions = stmts.sessionCount.get(windowStart, windowEnd).count; + } + if (want.has("events")) { + data.daily_events = stmts.dailyEvents.all(tzModifier, windowStart, windowEnd); + data.total_events = stmts.eventCount.get(windowStart, windowEnd).count; + } + if (want.has("agents")) { + const rows = stmts.agentStatusCounts.all(windowStart, windowEnd); + data.agents_by_status = Object.fromEntries(rows.map((r) => [r.status, r.count])); + } + if (want.has("tools")) { + data.top_tools = stmts.toolUsageCounts.all(windowStart, windowEnd); + } + if (want.has("failures")) { + data.failure_prone_operations = stmts.failureProneOps.all(windowStart, windowEnd); + } + if (want.has("tokens")) { + const buckets = stmts.tokenBuckets.all(windowStart, windowEnd); + const pricingRules = dbModule.stmts.listPricing.all(); + const cost = calculateCost(buckets, pricingRules); + const totals = buckets.reduce( + (acc, b) => { + acc.input += b.input_tokens || 0; + acc.output += b.output_tokens || 0; + acc.cache_read += b.cache_read_tokens || 0; + acc.cache_write += b.cache_write_tokens || 0; + return acc; + }, + { input: 0, output: 0, cache_read: 0, cache_write: 0 } + ); + data.tokens = { + total_input: totals.input, + total_output: totals.output, + total_cache_read: totals.cache_read, + total_cache_write: totals.cache_write, + }; + data.cost = { + total_cost: cost.total_cost, + breakdown: cost.breakdown, + feature_costs: cost.feature_costs, + unpriced_models: cost.unpriced_models, + }; + } + + const summary = buildSummary(template, data); + const html = buildHtml(template, data, summary); + return { summary, data, html }; +} + +/** A few headline numbers, shown in the run list. */ +function buildSummary(template, data) { + const t = TEMPLATES.find((x) => x.key === template); + const summary = { + template, + template_label: t ? t.label : template, + window_start: data.window_start, + window_end: data.window_end, + }; + if (data.total_sessions != null) summary.total_sessions = data.total_sessions; + if (data.total_events != null) summary.total_events = data.total_events; + if (data.top_tools) summary.distinct_tools = data.top_tools.length; + if (data.failure_prone_operations) { + summary.total_failures = data.failure_prone_operations.reduce((s, r) => s + r.count, 0); + } + if (data.tokens) { + summary.total_tokens = + data.tokens.total_input + + data.tokens.total_output + + data.tokens.total_cache_read + + data.tokens.total_cache_write; + } + if (data.cost) summary.total_cost = data.cost.total_cost; + return summary; +} + +function table(headers, rows) { + const head = headers.map((h) => `${esc(h)}`).join(""); + const body = rows.length + ? rows.map((cells) => `${cells.map((c) => `${esc(c)}`).join("")}`).join("") + : `No data for this period.`; + return `${head}${body}
`; +} + +function num(n) { + return Number(n || 0).toLocaleString("en-US"); +} + +/** + * Build the self-contained, print-friendly HTML artifact. EVERY dynamic value + * is routed through esc() (or num()), so a hostile session/tool name cannot + * inject markup. + */ +function buildHtml(template, data, summary) { + const t = TEMPLATES.find((x) => x.key === template); + const title = t ? t.label : "Analytics Report"; + const blocks = []; + + if (data.session_volume) { + blocks.push( + `

Session Volume Trend

` + + `

${num(data.total_sessions)} sessions started

` + + table( + ["Date", "Sessions"], + data.session_volume.map((r) => [r.date, num(r.count)]) + ) + + `
` + ); + } + if (data.daily_events) { + blocks.push( + `

Daily Events

` + + `

${num(data.total_events)} events

` + + table( + ["Date", "Events"], + data.daily_events.map((r) => [r.date, num(r.count)]) + ) + + `
` + ); + } + if (data.agents_by_status) { + const rows = Object.entries(data.agents_by_status).map(([status, count]) => [ + status, + num(count), + ]); + blocks.push( + `

Agent Status Distribution

` + + table(["Status", "Agents"], rows) + + `
` + ); + } + if (data.top_tools) { + blocks.push( + `

Top Tools

` + + table( + ["Tool", "Uses"], + data.top_tools.map((r) => [r.tool_name, num(r.count)]) + ) + + `
` + ); + } + if (data.failure_prone_operations) { + blocks.push( + `

Failure-Prone Operations

` + + table( + ["Operation", "Failures"], + data.failure_prone_operations.map((r) => [r.operation, num(r.count)]) + ) + + `
` + ); + } + if (data.tokens) { + blocks.push( + `

Token Usage

` + + table( + ["Category", "Tokens"], + [ + ["Input", num(data.tokens.total_input)], + ["Output", num(data.tokens.total_output)], + ["Cache read", num(data.tokens.total_cache_read)], + ["Cache write", num(data.tokens.total_cache_write)], + ] + ) + + `
` + ); + } + if (data.cost) { + const rows = (data.cost.breakdown || []).map((b) => [b.model, `$${esc(b.cost)}`]); + blocks.push( + `

Estimated Cost

` + + `

$${esc(data.cost.total_cost)} total

` + + table(["Model", "Cost"], rows) + + `
` + ); + } + + const style = ` + :root { color-scheme: light; } + * { box-sizing: border-box; } + body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + margin: 0; padding: 32px; color: #1a1a2e; background: #ffffff; line-height: 1.5; } + header { border-bottom: 2px solid #4f46e5; padding-bottom: 16px; margin-bottom: 24px; } + h1 { font-size: 24px; margin: 0 0 4px; color: #312e81; } + h2 { font-size: 16px; margin: 0 0 12px; color: #4338ca; } + .meta { font-size: 13px; color: #555; margin: 2px 0; } + section { margin-bottom: 28px; page-break-inside: avoid; } + .metric { font-size: 15px; font-weight: 600; margin: 0 0 8px; color: #111; } + table { width: 100%; border-collapse: collapse; font-size: 13px; } + th, td { text-align: left; padding: 6px 10px; border-bottom: 1px solid #e5e7eb; } + th { background: #f3f4f6; font-weight: 600; } + td.empty { color: #9ca3af; font-style: italic; } + tbody tr:nth-child(even) { background: #fafafa; } + footer { margin-top: 32px; font-size: 11px; color: #9ca3af; border-top: 1px solid #e5e7eb; padding-top: 12px; } + @media print { + body { padding: 0; } + header { border-bottom-color: #000; } + th { background: #eee !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; } + section { page-break-inside: avoid; } + } + `; + + return ( + `\n` + + `` + + `` + + `${esc(title)}` + + `

${esc(title)}

` + + `

Window: ${esc(data.window_start)} → ${esc(data.window_end)}

` + + `

Timezone offset: ${esc(data.tz_offset)} min from UTC

` + + `
` + + blocks.join("\n") + + `
Generated by Agent Dashboard · Scheduled Analytics Reports
` + + `` + ); +} + +module.exports = { + TEMPLATES, + TEMPLATE_KEYS, + SECTIONS_BY_TEMPLATE, + computeNextRun, + generateReport, + esc, +}; diff --git a/server/lib/report-scheduler.js b/server/lib/report-scheduler.js new file mode 100644 index 00000000..38b1a762 --- /dev/null +++ b/server/lib/report-scheduler.js @@ -0,0 +1,84 @@ +/** + * @file Periodic scheduler for Scheduled Analytics Reports. On a 60s unref'd + * tick it materializes a run for every enabled definition whose next_run_at has + * come due, advancing the schedule as it goes. Reuses the exact generate-and- + * persist path from routes/reports.js (runReportForDefinition) so on-demand and + * scheduled runs are byte-for-byte identical. Env-gated by + * DASHBOARD_REPORTS_DISABLED; the timer is unref'd so it never keeps the + * process (or the test runner) alive — same pattern as the alerts sweep and the + * update scheduler. + * @author Son Nguyen + */ + +const dbModule = require("../db"); +const { runReportForDefinition, serializeRun } = require("../routes/reports"); + +const SCHEDULER_INTERVAL_MS = 60_000; + +function isDisabled() { + const v = (process.env.DASHBOARD_REPORTS_DISABLED || "").toLowerCase(); + return v === "1" || v === "true" || v === "off"; +} + +// Static — prepared once. Selects enabled definitions that are due to run. +const dueDefsStmt = dbModule.db.prepare( + "SELECT * FROM report_definitions WHERE enabled = 1 AND next_run_at IS NOT NULL AND next_run_at <= ? ORDER BY next_run_at ASC" +); + +/** + * Run every enabled definition whose next_run_at <= now. Generates + persists a + * run for each (a thrown generation error is captured as an `error`-status run + * inside runReportForDefinition — it never crashes the tick), advances the + * schedule, and broadcasts run metadata. Returns the list of run rows produced. + * Exported so tests can invoke it deterministically with a pinned `nowMs`. + */ +function runDueReports(nowMs = Date.now(), broadcast = null) { + const nowIso = new Date(nowMs).toISOString(); + let due; + try { + due = dueDefsStmt.all(nowIso); + } catch (err) { + console.warn("[REPORTS] due-definition load failed:", err?.message || err); + return []; + } + + const produced = []; + for (const def of due) { + try { + const run = runReportForDefinition(def, nowMs); + produced.push(run); + if (typeof broadcast === "function") { + broadcast("report_run", serializeRun(run)); + } + } catch (err) { + // runReportForDefinition is itself fail-safe, but guard the loop so one + // bad definition can never abort the rest of the tick. + console.warn(`[REPORTS] definition "${def.name}" run failed:`, err?.message || err); + } + } + return produced; +} + +/** + * Start the report scheduler. Returns `{ stop }`. No-op (still returns a valid + * handle) when disabled via env. + */ +function startReportScheduler({ broadcast } = {}) { + if (isDisabled()) return { stop: () => {} }; + + const timer = setInterval(() => { + try { + runDueReports(Date.now(), broadcast); + } catch (err) { + // Defensive — the tick must never throw out and kill the interval. + console.warn("[REPORTS] scheduler tick failed:", err?.message || err); + } + }, SCHEDULER_INTERVAL_MS); + if (timer.unref) timer.unref(); + + return { + stop: () => clearInterval(timer), + }; +} + +module.exports = { startReportScheduler, runDueReports, SCHEDULER_INTERVAL_MS }; diff --git a/server/routes/reports.js b/server/routes/reports.js new file mode 100644 index 00000000..3c24efe8 --- /dev/null +++ b/server/routes/reports.js @@ -0,0 +1,476 @@ +/** + * @file Express router for Scheduled Analytics Reports. CRUD for report + * definitions (saved schedules), synchronous on-demand generation, run history, + * and artifact download. Numbers mirror /api/analytics, windowed to the report + * period. Generation never throws out of the handler — a failed run is + * persisted with status "error" and a populated `error` string. The + * generate-and-persist path lives in runReportForDefinition() so the scheduler + * reuses the exact same code. + * @author Son Nguyen + */ + +const { Router } = require("express"); +const { v4: uuidv4 } = require("uuid"); +const dbModule = require("../db"); +const { db } = dbModule; +const { + TEMPLATES, + TEMPLATE_KEYS, + computeNextRun, + generateReport, +} = require("../lib/report-generator"); + +const router = Router(); + +const FREQUENCIES = ["daily", "weekly", "monthly"]; +const VALID_FORMATS = ["html", "json"]; +// ~100 years — an upper bound on the report window so `new Date(now - days)` +// stays well within the representable Date range (a larger value throws). +const MAX_WINDOW_DAYS = 36525; + +// ── Prepared statements ───────────────────────────────────────────────────── +const stmts = { + listDefs: db.prepare("SELECT * FROM report_definitions ORDER BY created_at DESC"), + getDef: db.prepare("SELECT * FROM report_definitions WHERE id = ?"), + insertDef: db.prepare(` + INSERT INTO report_definitions + (id, name, template, frequency, day_of_week, hour, tz_offset, formats, window_days, enabled, next_run_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `), + updateDef: db.prepare(` + UPDATE report_definitions SET + name = ?, template = ?, frequency = ?, day_of_week = ?, hour = ?, tz_offset = ?, + formats = ?, window_days = ?, enabled = ?, next_run_at = ?, + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE id = ? + `), + deleteDef: db.prepare("DELETE FROM report_definitions WHERE id = ?"), + touchAfterRun: db.prepare( + "UPDATE report_definitions SET last_run_at = ?, next_run_at = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?" + ), + // Most-recent run status for a definition (for last_status in the list view). + latestRunStatus: db.prepare( + "SELECT status FROM report_runs WHERE definition_id = ? ORDER BY created_at DESC, id DESC LIMIT 1" + ), + insertRun: db.prepare(` + INSERT INTO report_runs + (id, definition_id, template, status, started_at, finished_at, window_start, window_end, error, summary, artifact_html, artifact_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `), + getRun: db.prepare("SELECT * FROM report_runs WHERE id = ?"), + listRunsForDef: db.prepare( + "SELECT * FROM report_runs WHERE definition_id = ? ORDER BY created_at DESC, id DESC" + ), + deleteRunsForDef: db.prepare("DELETE FROM report_runs WHERE definition_id = ?"), +}; + +/** Wrap a route handler so any thrown error becomes a structured 500. */ +function safe(handler) { + return (req, res) => { + try { + handler(req, res); + } catch (err) { + console.error("[REPORTS] route error:", err?.message || err); + res.status(500).json({ error: { code: "INTERNAL", message: "Internal server error" } }); + } + }; +} + +function badRequest(res, message) { + return res.status(400).json({ error: { code: "INVALID_INPUT", message } }); +} + +function notFound(res, message) { + return res.status(404).json({ error: { code: "NOT_FOUND", message } }); +} + +/** Resolve the effective window length (days) for a definition. */ +function effectiveWindowDays(def) { + if (Number.isInteger(def.window_days) && def.window_days > 0) return def.window_days; + const t = TEMPLATES.find((x) => x.key === def.template); + return t ? t.default_window_days : 7; +} + +/** Serialize a definition row to the ReportDefinition contract shape. */ +function serializeDef(row) { + let formats = ["html", "json"]; + try { + const parsed = JSON.parse(row.formats || "[]"); + if (Array.isArray(parsed) && parsed.length) formats = parsed; + } catch { + /* tolerate hand-edited bad JSON — fall back to default formats */ + } + const last = stmts.latestRunStatus.get(row.id); + return { + id: row.id, + name: row.name, + template: row.template, + frequency: row.frequency, + day_of_week: row.day_of_week == null ? null : row.day_of_week, + hour: row.hour, + tz_offset: row.tz_offset, + formats, + window_days: effectiveWindowDays(row), + enabled: row.enabled === 1, + last_run_at: row.last_run_at || null, + next_run_at: row.next_run_at || null, + last_status: last ? last.status : null, + created_at: row.created_at, + updated_at: row.updated_at, + }; +} + +/** Serialize a run row to the ReportRun contract shape (NO artifact bodies). */ +function serializeRun(row) { + let summary = null; + try { + summary = row.summary ? JSON.parse(row.summary) : null; + } catch { + summary = null; + } + const formats_available = []; + if (row.artifact_html != null) formats_available.push("html"); + if (row.artifact_json != null) formats_available.push("json"); + return { + id: row.id, + definition_id: row.definition_id, + template: row.template, + status: row.status, + started_at: row.started_at, + finished_at: row.finished_at || null, + window_start: row.window_start || null, + window_end: row.window_end || null, + error: row.error || null, + summary, + formats_available, + }; +} + +/** + * Validate a (partial) definition payload. `existing` supplies fallback values + * for fields not present in the body (PATCH). Returns + * `{ ok: true, values }` (normalized) or `{ ok: false, error }`. + */ +function validateDefinition(body, existing = null) { + const b = body || {}; + const pick = (key, fallback) => (b[key] === undefined ? fallback : b[key]); + + const name = pick("name", existing ? existing.name : undefined); + if (typeof name !== "string" || !name.trim()) { + return { ok: false, error: "name must be a non-empty string" }; + } + + const template = pick("template", existing ? existing.template : undefined); + if (!TEMPLATE_KEYS.has(template)) { + return { ok: false, error: `template must be one of: ${[...TEMPLATE_KEYS].join(", ")}` }; + } + + const frequency = pick("frequency", existing ? existing.frequency : undefined); + if (!FREQUENCIES.includes(frequency)) { + return { ok: false, error: `frequency must be one of: ${FREQUENCIES.join(", ")}` }; + } + + const hourRaw = pick("hour", existing ? existing.hour : 9); + const hour = hourRaw == null ? 9 : hourRaw; + if (!Number.isInteger(hour) || hour < 0 || hour > 23) { + return { ok: false, error: "hour must be an integer in 0-23" }; + } + + let day_of_week = pick("day_of_week", existing ? existing.day_of_week : null); + if (frequency === "weekly") { + if (!Number.isInteger(day_of_week) || day_of_week < 0 || day_of_week > 6) { + return { ok: false, error: "day_of_week must be an integer in 0-6 for weekly reports" }; + } + } else { + day_of_week = null; // ignored for daily/monthly + } + + const tzRaw = pick("tz_offset", existing ? existing.tz_offset : 0); + const tz_offset = tzRaw == null ? 0 : tzRaw; + if (!Number.isInteger(tz_offset)) { + return { ok: false, error: "tz_offset must be an integer (minutes from UTC)" }; + } + + // formats: subset of VALID_FORMATS, non-empty, deduped. + let formats; + if (b.formats === undefined) { + formats = existing ? safeParseFormats(existing.formats) : ["html", "json"]; + } else { + if (!Array.isArray(b.formats) || b.formats.length === 0) { + return { ok: false, error: "formats must be a non-empty array" }; + } + formats = [...new Set(b.formats)]; + for (const f of formats) { + if (!VALID_FORMATS.includes(f)) { + return { ok: false, error: `formats must be a subset of: ${VALID_FORMATS.join(", ")}` }; + } + } + } + + let window_days = pick("window_days", existing ? existing.window_days : null); + if (window_days != null) { + // Upper bound keeps (now - window_days days) inside the valid Date range — + // a huge value would otherwise throw RangeError from toISOString(). + if (!Number.isInteger(window_days) || window_days <= 0 || window_days > MAX_WINDOW_DAYS) { + return { + ok: false, + error: `window_days must be a positive integer (<= ${MAX_WINDOW_DAYS})`, + }; + } + } else { + window_days = null; + } + + let enabled = pick("enabled", existing ? existing.enabled === 1 : true); + enabled = enabled === false ? 0 : 1; + + return { + ok: true, + values: { + name: name.trim(), + template, + frequency, + day_of_week, + hour, + tz_offset, + formats, + window_days, + enabled, + }, + }; +} + +function safeParseFormats(json) { + try { + const parsed = JSON.parse(json || "[]"); + if (Array.isArray(parsed) && parsed.length) return parsed; + } catch { + /* fall through */ + } + return ["html", "json"]; +} + +/** + * Generate a report for a definition NOW and persist a run row. Never throws — + * a generation failure is captured as an `error`-status run. Returns the + * inserted run ROW (raw); callers serialize as needed. `nowMs` pins the window + * end (defaults to Date.now()). + * + * Shared by POST /:id/run and the scheduler so both take the identical path. + */ +function runReportForDefinition(def, nowMs = Date.now()) { + const startedAt = new Date(nowMs).toISOString(); + const windowDays = effectiveWindowDays(def); + const windowEnd = new Date(nowMs).toISOString(); + const formats = safeParseFormats(def.formats); + const runId = uuidv4(); + + let status = "success"; + let error = null; + let summaryJson = null; + let artifactHtml = null; + let artifactJson = null; + // Computed inside the try below so a bad stored window_days (which would make + // `new Date(...).toISOString()` throw RangeError) degrades to an error-status + // run instead of throwing out of this function and wedging the scheduler. + let windowStart = null; + + try { + windowStart = new Date(nowMs - windowDays * 24 * 60 * 60 * 1000).toISOString(); + const { summary, data, html } = generateReport(dbModule, { + template: def.template, + windowStart, + windowEnd, + tzOffset: def.tz_offset, + }); + summaryJson = JSON.stringify(summary); + if (formats.includes("html")) artifactHtml = html; + if (formats.includes("json")) artifactJson = JSON.stringify(data); + } catch (err) { + status = "error"; + error = String(err?.message || err || "report generation failed"); + } + + const finishedAt = new Date().toISOString(); + stmts.insertRun.run( + runId, + def.id, + def.template, + status, + startedAt, + finishedAt, + windowStart, + windowEnd, + error, + summaryJson, + artifactHtml, + artifactJson + ); + + // Advance the schedule regardless of success/error so a failing definition + // doesn't wedge the scheduler on the same overdue instant forever. + const nextRun = computeNextRun( + { + frequency: def.frequency, + day_of_week: def.day_of_week, + hour: def.hour, + tz_offset: def.tz_offset, + }, + nowMs + ); + stmts.touchAfterRun.run(startedAt, nextRun, def.id); + + return stmts.getRun.get(runId); +} + +// ── Routes ────────────────────────────────────────────────────────────────── + +// GET /api/reports/templates — MUST be registered before /:id so "templates" +// isn't swallowed as an id. +router.get( + "/templates", + safe((_req, res) => { + res.json({ templates: TEMPLATES, frequencies: FREQUENCIES }); + }) +); + +// GET /api/reports — list definitions, newest first. +router.get( + "/", + safe((_req, res) => { + res.json({ definitions: stmts.listDefs.all().map(serializeDef) }); + }) +); + +// POST /api/reports — create a definition. +router.post( + "/", + safe((req, res) => { + const validated = validateDefinition(req.body, null); + if (!validated.ok) return badRequest(res, validated.error); + const v = validated.values; + const id = uuidv4(); + const nextRun = computeNextRun({ + frequency: v.frequency, + day_of_week: v.day_of_week, + hour: v.hour, + tz_offset: v.tz_offset, + }); + stmts.insertDef.run( + id, + v.name, + v.template, + v.frequency, + v.day_of_week, + v.hour, + v.tz_offset, + JSON.stringify(v.formats), + v.window_days, + v.enabled, + nextRun + ); + res.status(201).json({ definition: serializeDef(stmts.getDef.get(id)) }); + }) +); + +// GET /api/reports/runs/:runId — run detail (metadata only). Registered before +// /:id so "runs" isn't matched as a definition id. +router.get( + "/runs/:runId", + safe((req, res) => { + const run = stmts.getRun.get(req.params.runId); + if (!run) return notFound(res, "Report run not found"); + res.json({ run: serializeRun(run) }); + }) +); + +// GET /api/reports/runs/:runId/artifact?format=html|json — download/view. +router.get( + "/runs/:runId/artifact", + safe((req, res) => { + const run = stmts.getRun.get(req.params.runId); + if (!run) return notFound(res, "Report run not found"); + const format = req.query.format === "json" ? "json" : "html"; + if (format === "html") { + if (run.artifact_html == null) + return notFound(res, "HTML artifact not available for this run"); + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.setHeader("Content-Disposition", "inline"); + return res.send(run.artifact_html); + } + if (run.artifact_json == null) return notFound(res, "JSON artifact not available for this run"); + res.setHeader("Content-Type", "application/json"); + res.setHeader("Content-Disposition", `attachment; filename="report-${run.id}.json"`); + return res.send(run.artifact_json); + }) +); + +// PATCH /api/reports/:id — partial update. +router.patch( + "/:id", + safe((req, res) => { + const existing = stmts.getDef.get(req.params.id); + if (!existing) return notFound(res, "Report definition not found"); + const validated = validateDefinition(req.body, existing); + if (!validated.ok) return badRequest(res, validated.error); + const v = validated.values; + const nextRun = computeNextRun({ + frequency: v.frequency, + day_of_week: v.day_of_week, + hour: v.hour, + tz_offset: v.tz_offset, + }); + stmts.updateDef.run( + v.name, + v.template, + v.frequency, + v.day_of_week, + v.hour, + v.tz_offset, + JSON.stringify(v.formats), + v.window_days, + v.enabled, + nextRun, + req.params.id + ); + res.json({ definition: serializeDef(stmts.getDef.get(req.params.id)) }); + }) +); + +// DELETE /api/reports/:id — delete a definition and its runs. +router.delete( + "/:id", + safe((req, res) => { + const existing = stmts.getDef.get(req.params.id); + if (!existing) return notFound(res, "Report definition not found"); + stmts.deleteRunsForDef.run(req.params.id); + stmts.deleteDef.run(req.params.id); + res.json({ ok: true }); + }) +); + +// POST /api/reports/:id/run — generate now, synchronously. +router.post( + "/:id/run", + safe((req, res) => { + const def = stmts.getDef.get(req.params.id); + if (!def) return notFound(res, "Report definition not found"); + const run = runReportForDefinition(def); + res.json({ run: serializeRun(run) }); + }) +); + +// GET /api/reports/:id/runs — run history for a definition, newest first. +router.get( + "/:id/runs", + safe((req, res) => { + const def = stmts.getDef.get(req.params.id); + if (!def) return notFound(res, "Report definition not found"); + res.json({ runs: stmts.listRunsForDef.all(req.params.id).map(serializeRun) }); + }) +); + +module.exports = router; +module.exports.runReportForDefinition = runReportForDefinition; +module.exports.serializeRun = serializeRun; +module.exports._stmts = stmts;