feat(FN-3786): add reports dashboard view and comparison tooling
Adds a Reports dashboard view (`ReportsView`) to the reports plugin with a full supporting stack of components (`ReportComparisonDrawer`, `ReportDetailPanel`, `ReportFiltersBar`, `ReportListItem`, `ReportEmptyState`), hooks (`useReports`, `useReportPreview`, `useReportSectionDiff`, `useViewportMode` Fusion-Task-Id: FN-3786
This commit is contained in:
@@ -136,3 +136,14 @@ Emitted events:
|
||||
- `report:deleted`
|
||||
|
||||
This archive is the source of truth for downstream report HTML rendering (FN-3785) and dashboard report list/detail flows (FN-3786).
|
||||
|
||||
## Dashboard view
|
||||
|
||||
The plugin registers a primary dashboard view (`Reports`) via `dashboardViews` with `componentPath: "./dashboard-view"`.
|
||||
|
||||
The view provides:
|
||||
- History list of reports with filters (cadence, status, period date range, title search, agent filter)
|
||||
- Embedded detail preview using sandboxed iframe + preview HTML endpoint
|
||||
- Section quick-jump navigation by stable `data-section` markers
|
||||
- Side-by-side comparison drawer for two reports with section-level diff summary
|
||||
- Standalone HTML download action wired to the export endpoint
|
||||
|
||||
@@ -5,6 +5,16 @@
|
||||
"description": "Generates beautiful HTML system-activity reports with multi-agent review.",
|
||||
"author": "Fusion Team",
|
||||
"fusionVersion": ">=0.1.0",
|
||||
"dashboardViews": [
|
||||
{
|
||||
"viewId": "reports",
|
||||
"label": "Reports",
|
||||
"componentPath": "./dashboard-view",
|
||||
"icon": "FileText",
|
||||
"placement": "primary",
|
||||
"order": 35
|
||||
}
|
||||
],
|
||||
"settingsSchema": {
|
||||
"dailyEnabled": { "type": "boolean", "label": "Enable Daily Reports", "description": "Generate daily reports on schedule.", "group": "Schedules", "defaultValue": true },
|
||||
"dailyCron": { "type": "string", "label": "Daily Schedule (cron)", "description": "Cron expression for daily report generation.", "group": "Schedules", "required": true, "defaultValue": "0 8 * * *" },
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
"./render": {
|
||||
"types": "./src/render/index.ts",
|
||||
"import": "./src/render/index.ts"
|
||||
},
|
||||
"./dashboard-view": {
|
||||
"types": "./src/dashboard-view.tsx",
|
||||
"import": "./src/dashboard-view.tsx"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
@@ -20,9 +24,17 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fusion/core": "workspace:*",
|
||||
"@fusion/plugin-sdk": "workspace:*"
|
||||
"@fusion/dashboard": "workspace:*",
|
||||
"@fusion/plugin-sdk": "workspace:*",
|
||||
"lucide-react": "^0.542.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/node": "^25.5.2",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.2.4"
|
||||
|
||||
@@ -42,6 +42,20 @@ describe("reports plugin manifest", () => {
|
||||
expect(plugin.manifest.fusionVersion).toBe(manifest.fusionVersion);
|
||||
});
|
||||
|
||||
it("registers dashboard view", () => {
|
||||
expect(plugin.dashboardViews).toEqual([
|
||||
{
|
||||
viewId: "reports",
|
||||
label: "Reports",
|
||||
componentPath: "./dashboard-view",
|
||||
icon: "FileText",
|
||||
placement: "primary",
|
||||
order: 35,
|
||||
},
|
||||
]);
|
||||
expect(manifest.dashboardViews).toEqual(plugin.dashboardViews);
|
||||
});
|
||||
|
||||
it("includes full settings schema", () => {
|
||||
expect(plugin.manifest.settingsSchema).toBeDefined();
|
||||
for (const key of expectedKeys) {
|
||||
|
||||
6
plugins/fusion-plugin-reports/src/dashboard-view.tsx
Normal file
6
plugins/fusion-plugin-reports/src/dashboard-view.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { PluginDashboardViewContext } from "@fusion/dashboard/app/plugins/types";
|
||||
import { ReportsView } from "./dashboard/ReportsView.js";
|
||||
|
||||
export function ReportsDashboardView({ context }: { context?: PluginDashboardViewContext }) {
|
||||
return <ReportsView projectId={context?.projectId} addToast={context?.addToast ?? (() => undefined)} />;
|
||||
}
|
||||
78
plugins/fusion-plugin-reports/src/dashboard/ReportsView.css
Normal file
78
plugins/fusion-plugin-reports/src/dashboard/ReportsView.css
Normal file
@@ -0,0 +1,78 @@
|
||||
.reports-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
.reports-view-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.reports-filters { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: var(--space-sm); }
|
||||
.reports-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: var(--space-md); }
|
||||
.reports-list { display: flex; flex-direction: column; gap: var(--space-sm); }
|
||||
.reports-list-item {
|
||||
text-align: left;
|
||||
border: var(--btn-border-width) solid var(--border);
|
||||
transition: border-color var(--transition-fast), background var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
.reports-list-item[data-selected="true"] {
|
||||
border-color: var(--todo);
|
||||
background: var(--card-hover);
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
.reports-detail { display: flex; flex-direction: column; gap: var(--space-sm); }
|
||||
.reports-detail-header { display: flex; justify-content: space-between; align-items: center; gap: var(--space-sm); }
|
||||
.reports-detail-body { display: flex; flex-direction: column; gap: var(--space-sm); }
|
||||
.reports-detail-meta {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.reports-detail-sections {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
.reports-detail iframe, .reports-compare iframe { width: 100%; min-height: 20rem; border: var(--btn-border-width) solid var(--border); border-radius: var(--radius-md); background: var(--surface); }
|
||||
.reports-empty {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
justify-content: center;
|
||||
min-height: calc(var(--space-2xl) * 6);
|
||||
text-align: center;
|
||||
}
|
||||
.reports-empty p {
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
.reports-compare {
|
||||
width: min(100%, 64rem);
|
||||
max-height: min(100%, calc(100vh - var(--space-2xl)));
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
.reports-compare-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--card);
|
||||
z-index: 1;
|
||||
}
|
||||
.reports-compare-pickers {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
.reports-compare-frames { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-sm); }
|
||||
@media (max-width: 1024px) {
|
||||
.reports-compare-pickers,
|
||||
.reports-compare-frames { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.reports-filters { grid-template-columns: minmax(0, 1fr); }
|
||||
.reports-layout { grid-template-columns: minmax(0, 1fr); }
|
||||
.reports-compare {
|
||||
width: min(100%, calc(100vw - var(--space-lg)));
|
||||
max-height: min(100%, calc(100vh - var(--space-lg)));
|
||||
}
|
||||
}
|
||||
24
plugins/fusion-plugin-reports/src/dashboard/ReportsView.tsx
Normal file
24
plugins/fusion-plugin-reports/src/dashboard/ReportsView.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import "./ReportsView.css";
|
||||
import { ReportComparisonDrawer } from "./components/ReportComparisonDrawer.js";
|
||||
import { ReportDetailPanel } from "./components/ReportDetailPanel.js";
|
||||
import { ReportEmptyState } from "./components/ReportEmptyState.js";
|
||||
import { ReportFiltersBar } from "./components/ReportFiltersBar.js";
|
||||
import { ReportListItem } from "./components/ReportListItem.js";
|
||||
import type { ToastType } from "./types.js";
|
||||
import { useReports } from "./useReports.js";
|
||||
import { useViewportMode } from "./useViewportMode.js";
|
||||
|
||||
export function ReportsView({ projectId, addToast }: { projectId?: string; addToast: (message: string, type?: ToastType) => void }) {
|
||||
const model = useReports({ projectId, addToast });
|
||||
const { mobile } = useViewportMode();
|
||||
const agents = [...new Set(model.reports.flatMap((r) => ((r.metadata?.agentIds as string[] | undefined) ?? [])))];
|
||||
return <div className="reports-view">
|
||||
<div className="reports-view-header"><h2>Reports</h2><button className="btn btn-sm" onClick={model.enterCompareMode}>Compare</button></div>
|
||||
<ReportFiltersBar filters={model.filters} onChange={model.setFilters} agents={agents} />
|
||||
<div className="reports-layout" data-mobile={mobile ? "true" : "false"}>
|
||||
<div className="reports-list">{model.reports.length === 0 ? <ReportEmptyState /> : model.reports.map((report) => <ReportListItem key={report.id} report={report} selected={model.selectedId === report.id} onSelect={model.selectId} />)}</div>
|
||||
<ReportDetailPanel report={model.selectedReport} projectId={projectId} />
|
||||
</div>
|
||||
{model.compareMode ? <ReportComparisonDrawer reports={model.reports} leftId={model.compareA} rightId={model.compareB} onPick={model.setCompareSlot} onClose={model.closeCompareMode} projectId={projectId} /> : null}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import * as preview from "../useReportPreview.js";
|
||||
import { ReportComparisonDrawer } from "../components/ReportComparisonDrawer.js";
|
||||
|
||||
describe("ReportComparisonDrawer", () => {
|
||||
it("renders compare ui", () => {
|
||||
vi.spyOn(preview, "useReportPreview").mockReturnValue({ html: "<article />", loading: false, error: null });
|
||||
const { getByText } = render(<ReportComparisonDrawer reports={[{ id: "R-1", title: "A" }, { id: "R-2", title: "B" }] as never} leftId="R-1" rightId="R-2" onPick={vi.fn()} onClose={vi.fn()} />);
|
||||
expect(getByText("Compare reports")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import * as preview from "../useReportPreview.js";
|
||||
import { ReportDetailPanel } from "../components/ReportDetailPanel.js";
|
||||
|
||||
describe("ReportDetailPanel", () => {
|
||||
it("renders report", () => {
|
||||
vi.spyOn(preview, "useReportPreview").mockReturnValue({ html: "<article />", loading: false, error: null });
|
||||
const { getByText } = render(<ReportDetailPanel report={{ id: "R-1", title: "Report", cadence: "daily", status: "published", periodStart: "2026-01-01", periodEnd: "2026-01-02" } as never} />);
|
||||
expect(getByText("Report")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { fireEvent, render, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ReportFiltersBar } from "../components/ReportFiltersBar.js";
|
||||
|
||||
const filters = { cadence: "all", status: "all", from: "", to: "", q: "", agentId: "" } as const;
|
||||
|
||||
describe("ReportFiltersBar", () => {
|
||||
it("emits changes", async () => {
|
||||
const onChange = vi.fn();
|
||||
const { getByPlaceholderText } = render(<ReportFiltersBar filters={{ ...filters }} onChange={onChange} agents={[]} />);
|
||||
fireEvent.change(getByPlaceholderText("Search title"), { target: { value: "hello" } });
|
||||
await waitFor(() => expect(onChange).toBeCalled());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { fireEvent, render } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import * as reportsHook from "../useReports.js";
|
||||
import { ReportsView } from "../ReportsView.js";
|
||||
|
||||
describe("ReportsView", () => {
|
||||
it("renders list and compare toggle", () => {
|
||||
vi.spyOn(reportsHook, "useReports").mockReturnValue({
|
||||
filters: { cadence: "all", status: "all", from: "", to: "", q: "", agentId: "" },
|
||||
setFilters: vi.fn(),
|
||||
reports: [{ id: "R-1", title: "A", cadence: "daily", status: "published", periodStart: "2026-01-01", periodEnd: "2026-01-02", metadata: {} }],
|
||||
loading: false,
|
||||
selectedId: "R-1",
|
||||
selectedReport: { id: "R-1", title: "A", cadence: "daily", status: "published", periodStart: "2026-01-01", periodEnd: "2026-01-02", metadata: {} },
|
||||
selectId: vi.fn(),
|
||||
compareMode: false,
|
||||
compareA: undefined,
|
||||
compareB: undefined,
|
||||
enterCompareMode: vi.fn(),
|
||||
closeCompareMode: vi.fn(),
|
||||
setCompareSlot: vi.fn(),
|
||||
} as never);
|
||||
const { getByText } = render(<ReportsView addToast={vi.fn()} />);
|
||||
fireEvent.click(getByText("Compare"));
|
||||
expect(getByText("Reports")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getReportExportUrl, getReportPreviewHtml, listReports } from "../api.js";
|
||||
|
||||
describe("api", () => {
|
||||
it("lists reports", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({ reports: [{ id: "R-1" }] }) }));
|
||||
const reports = await listReports();
|
||||
expect(reports).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("reads preview html", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, text: async () => "<article/>" }));
|
||||
await expect(getReportPreviewHtml("R-1")).resolves.toContain("article");
|
||||
});
|
||||
|
||||
it("builds export url", () => {
|
||||
expect(getReportExportUrl("R-1")).toContain("/reports/R-1/export.html");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { diffReportSections } from "../useReportSectionDiff.js";
|
||||
|
||||
describe("diffReportSections", () => {
|
||||
it("classifies changed sections", () => {
|
||||
const a = { metadata: { wins: ["a"] } } as never;
|
||||
const b = { metadata: { wins: ["b"] } } as never;
|
||||
const diff = diffReportSections(a, b);
|
||||
expect(diff.changed.find((item) => item.id === "system-wins")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import * as api from "../api.js";
|
||||
import { useReports } from "../useReports.js";
|
||||
|
||||
describe("useReports", () => {
|
||||
it("loads reports", async () => {
|
||||
vi.spyOn(api, "listReports").mockResolvedValue([{ id: "R-1", title: "A" } as never]);
|
||||
vi.spyOn(api, "getReport").mockResolvedValue({ id: "R-1", title: "A" } as never);
|
||||
const { result } = renderHook(() => useReports({ addToast: vi.fn() }));
|
||||
await waitFor(() => expect(result.current.reports).toHaveLength(1));
|
||||
});
|
||||
});
|
||||
53
plugins/fusion-plugin-reports/src/dashboard/api.ts
Normal file
53
plugins/fusion-plugin-reports/src/dashboard/api.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { ReportRecord } from "./types.js";
|
||||
|
||||
const BASE = "/api/plugins/reports";
|
||||
|
||||
interface ListReportsParams {
|
||||
cadence?: string;
|
||||
status?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
q?: string;
|
||||
agentId?: string;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
function qp(params: Record<string, string | undefined>): string {
|
||||
const entries = Object.entries(params).filter(([, value]) => typeof value === "string" && value.length > 0) as Array<[string, string]>;
|
||||
if (entries.length === 0) return "";
|
||||
return `?${entries.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&")}`;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit, responseType: "json" | "text" = "json"): Promise<T> {
|
||||
const response = await fetch(`${BASE}${path}`, init);
|
||||
if (!response.ok) {
|
||||
let message = `${response.status} ${response.statusText}`;
|
||||
try {
|
||||
const data = (await response.json()) as { error?: string };
|
||||
if (data.error) message = data.error;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
if (responseType === "text") return (await response.text()) as T;
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export async function listReports(params: ListReportsParams = {}): Promise<ReportRecord[]> {
|
||||
const data = await request<{ reports: ReportRecord[] }>(`/reports${qp({ ...params })}`);
|
||||
return data.reports;
|
||||
}
|
||||
|
||||
export async function getReport(id: string, projectId?: string): Promise<ReportRecord> {
|
||||
const data = await request<{ report: ReportRecord }>(`/reports/${encodeURIComponent(id)}${qp({ projectId })}`);
|
||||
return data.report;
|
||||
}
|
||||
|
||||
export function getReportPreviewHtml(id: string, projectId?: string): Promise<string> {
|
||||
return request<string>(`/reports/${encodeURIComponent(id)}/preview.html${qp({ projectId })}`, undefined, "text");
|
||||
}
|
||||
|
||||
export function getReportExportUrl(id: string, projectId?: string): string {
|
||||
return `${BASE}/reports/${encodeURIComponent(id)}/export.html${qp({ projectId })}`;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useMemo } from "react";
|
||||
import type { ReportRecord } from "../types.js";
|
||||
import { useReportPreview } from "../useReportPreview.js";
|
||||
import { useReportSectionDiff } from "../useReportSectionDiff.js";
|
||||
|
||||
export function ReportComparisonDrawer({ reports, leftId, rightId, onPick, onClose, projectId }: { reports: ReportRecord[]; leftId?: string; rightId?: string; onPick: (slot: "a" | "b", id: string) => void; onClose: () => void; projectId?: string }) {
|
||||
const left = useMemo(() => reports.find((r) => r.id === leftId), [reports, leftId]);
|
||||
const right = useMemo(() => reports.find((r) => r.id === rightId), [reports, rightId]);
|
||||
const leftPreview = useReportPreview(leftId, projectId);
|
||||
const rightPreview = useReportPreview(rightId, projectId);
|
||||
const diff = useReportSectionDiff(left, right);
|
||||
return <div className="modal-overlay open reports-compare-overlay" role="dialog" aria-modal="true" aria-label="Compare reports">
|
||||
<div className="modal modal-lg reports-compare">
|
||||
<div className="modal-header reports-compare-header"><h3>Compare reports</h3><button className="btn btn-sm" onClick={onClose}>Close</button></div>
|
||||
<div className="reports-compare-pickers"><select className="select" value={leftId ?? ""} onChange={(e) => onPick("a", e.target.value)}>{reports.map((r) => <option key={r.id} value={r.id}>{r.title}</option>)}</select>
|
||||
<select className="select" value={rightId ?? ""} onChange={(e) => onPick("b", e.target.value)}>{reports.map((r) => <option key={r.id} value={r.id}>{r.title}</option>)}</select></div>
|
||||
<div className="reports-compare-frames"><iframe sandbox="allow-same-origin" srcDoc={leftPreview.html} title="Report A" /><iframe sandbox="allow-same-origin" srcDoc={rightPreview.html} title="Report B" /></div>
|
||||
<div>Changed: {diff.changed.map((s) => s.id).join(", ")}</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useMemo, useRef } from "react";
|
||||
import { getReportExportUrl } from "../api.js";
|
||||
import { useReportPreview } from "../useReportPreview.js";
|
||||
import type { ReportRecord } from "../types.js";
|
||||
|
||||
const SECTION_IDS = ["summary", "system-wins", "system-highlights", "system-lowlights", "system-proposals", "system-deep-dives", "agent-card", "data-coverage", "review-panel"];
|
||||
|
||||
export function ReportDetailPanel({ report, projectId }: { report?: ReportRecord; projectId?: string }) {
|
||||
const frameRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const { html, loading, error } = useReportPreview(report?.id, projectId);
|
||||
const sections = useMemo(() => SECTION_IDS, []);
|
||||
if (!report) return <div className="reports-detail card">Select a report.</div>;
|
||||
return <div className="reports-detail card">
|
||||
<div className="reports-detail-header"><h3>{report.title}</h3><a className="btn btn-sm" href={getReportExportUrl(report.id, projectId)} download>Download standalone HTML</a></div>
|
||||
<div className="reports-detail-meta">{report.cadence} • {report.status} • {report.periodStart} → {report.periodEnd}</div>
|
||||
<div className="reports-detail-body">
|
||||
<nav className="reports-detail-sections">{sections.map((section) => <button key={section} className="btn btn-sm" onClick={() => frameRef.current?.contentWindow?.document.querySelector(`[data-section="${section}"]`)?.scrollIntoView()}>{section}</button>)}</nav>
|
||||
{loading ? <div>Loading preview...</div> : null}
|
||||
{error ? <div>{error}</div> : null}
|
||||
<iframe ref={frameRef} sandbox="allow-same-origin" srcDoc={html} title="Report preview" />
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function ReportEmptyState() {
|
||||
return <div className="reports-empty card"><h3>No reports found</h3><p>Adjust filters or enable schedules in settings.</p></div>;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ReportFilters } from "../types.js";
|
||||
|
||||
export function ReportFiltersBar({ filters, onChange, agents }: { filters: ReportFilters; onChange: (next: ReportFilters) => void; agents: string[] }) {
|
||||
const [query, setQuery] = useState(filters.q);
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => onChange({ ...filters, q: query }), 250);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [query]);
|
||||
|
||||
return <div className="reports-filters">
|
||||
<select className="select" value={filters.cadence} onChange={(e) => onChange({ ...filters, cadence: e.target.value as ReportFilters["cadence"] })}><option value="all">All cadence</option><option value="daily">Daily</option><option value="weekly">Weekly</option></select>
|
||||
<select className="select" value={filters.status} onChange={(e) => onChange({ ...filters, status: e.target.value as ReportFilters["status"] })}><option value="all">All status</option><option value="generating">Generating</option><option value="review_pending">Review pending</option><option value="review_in_progress">Review in progress</option><option value="review_complete">Review complete</option><option value="approved">Approved</option><option value="published">Published</option><option value="failed">Failed</option></select>
|
||||
<input className="input" type="date" value={filters.from} onChange={(e) => onChange({ ...filters, from: e.target.value })} />
|
||||
<input className="input" type="date" value={filters.to} onChange={(e) => onChange({ ...filters, to: e.target.value })} />
|
||||
<input className="input" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search title" />
|
||||
<select className="select" value={filters.agentId} onChange={(e) => onChange({ ...filters, agentId: e.target.value })}><option value="">All agents</option>{agents.map((agent) => <option key={agent} value={agent}>{agent}</option>)}</select>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { ReportListItemVm } from "../types.js";
|
||||
|
||||
export function ReportListItem({ report, selected, onSelect }: { report: ReportListItemVm; selected: boolean; onSelect: (id: string) => void }) {
|
||||
return <button className="card reports-list-item" data-selected={selected ? "true" : "false"} onClick={() => onSelect(report.id)}>
|
||||
<div className="card-header"><span className="card-title">{report.title}</span></div>
|
||||
<div className="card-meta"><span>{report.cadence}</span><span>{report.status}</span><span>{report.periodStart} → {report.periodEnd}</span></div>
|
||||
</button>;
|
||||
}
|
||||
18
plugins/fusion-plugin-reports/src/dashboard/test-setup.ts
Normal file
18
plugins/fusion-plugin-reports/src/dashboard/test-setup.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { vi } from "vitest";
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
22
plugins/fusion-plugin-reports/src/dashboard/types.ts
Normal file
22
plugins/fusion-plugin-reports/src/dashboard/types.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { Report, ReportCadence, ReportStatus } from "../store/report-types.js";
|
||||
|
||||
export type ToastType = "success" | "error" | "info" | "warning";
|
||||
|
||||
export interface ReportFilters {
|
||||
cadence: "all" | ReportCadence;
|
||||
status: "all" | ReportStatus;
|
||||
from: string;
|
||||
to: string;
|
||||
q: string;
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
export interface SectionRef {
|
||||
id: string;
|
||||
label: string;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
export type ReportRecord = Report;
|
||||
|
||||
export type ReportListItemVm = Pick<ReportRecord, "id" | "title" | "cadence" | "status" | "periodStart" | "periodEnd" | "createdAt" | "metadata">;
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getReportPreviewHtml } from "./api.js";
|
||||
|
||||
const cache = new Map<string, string>();
|
||||
|
||||
export function useReportPreview(id?: string, projectId?: string) {
|
||||
const [html, setHtml] = useState<string>("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) {
|
||||
setHtml("");
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
const key = `${projectId ?? ""}:${id}`;
|
||||
const cached = cache.get(key);
|
||||
if (cached) {
|
||||
setHtml(cached);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
getReportPreviewHtml(id, projectId)
|
||||
.then((nextHtml) => {
|
||||
if (controller.signal.aborted) return;
|
||||
cache.set(key, nextHtml);
|
||||
setHtml(nextHtml);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setError(err instanceof Error ? err.message : "Failed to load preview");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [id, projectId]);
|
||||
|
||||
return { html, loading, error };
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useMemo } from "react";
|
||||
import type { ReportRecord, SectionRef } from "./types.js";
|
||||
|
||||
export interface SectionDiff {
|
||||
added: SectionRef[];
|
||||
removed: SectionRef[];
|
||||
changed: SectionRef[];
|
||||
unchanged: SectionRef[];
|
||||
}
|
||||
|
||||
const ALL_SECTIONS: Array<{ id: string; label: string }> = [
|
||||
{ id: "summary", label: "Summary" },
|
||||
{ id: "system-wins", label: "System wins" },
|
||||
{ id: "system-highlights", label: "System highlights" },
|
||||
{ id: "system-lowlights", label: "System lowlights" },
|
||||
{ id: "system-proposals", label: "System proposals" },
|
||||
{ id: "system-deep-dives", label: "System deep dives" },
|
||||
{ id: "agent-card", label: "Per-agent" },
|
||||
{ id: "data-coverage", label: "Data coverage" },
|
||||
{ id: "review-panel", label: "Review panel" },
|
||||
];
|
||||
|
||||
function extractValue(report: ReportRecord | undefined, id: string): unknown {
|
||||
if (!report) return undefined;
|
||||
switch (id) {
|
||||
case "summary": return report.metadata?.summary;
|
||||
case "system-wins": return report.metadata?.wins;
|
||||
case "system-highlights": return report.metadata?.highlights;
|
||||
case "system-lowlights": return report.metadata?.lowlights;
|
||||
case "system-proposals": return report.metadata?.proposals;
|
||||
case "system-deep-dives": return report.metadata?.deepDives;
|
||||
case "agent-card": return report.metadata?.perAgent;
|
||||
case "data-coverage": return report.metadata?.dataCoverage;
|
||||
case "review-panel": return report.combinedReview;
|
||||
default: return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function diffReportSections(a?: ReportRecord, b?: ReportRecord): SectionDiff {
|
||||
const added: SectionRef[] = [];
|
||||
const removed: SectionRef[] = [];
|
||||
const changed: SectionRef[] = [];
|
||||
const unchanged: SectionRef[] = [];
|
||||
|
||||
for (const section of ALL_SECTIONS) {
|
||||
const left = extractValue(a, section.id);
|
||||
const right = extractValue(b, section.id);
|
||||
const ref: SectionRef = { id: section.id, label: section.label, hash: section.id };
|
||||
if (left == null && right != null) added.push(ref);
|
||||
else if (left != null && right == null) removed.push(ref);
|
||||
else if (JSON.stringify(left) !== JSON.stringify(right)) changed.push(ref);
|
||||
else unchanged.push(ref);
|
||||
}
|
||||
return { added, removed, changed, unchanged };
|
||||
}
|
||||
|
||||
export function useReportSectionDiff(a?: ReportRecord, b?: ReportRecord): SectionDiff {
|
||||
return useMemo(() => diffReportSections(a, b), [a, b]);
|
||||
}
|
||||
71
plugins/fusion-plugin-reports/src/dashboard/useReports.ts
Normal file
71
plugins/fusion-plugin-reports/src/dashboard/useReports.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { getReport, listReports } from "./api.js";
|
||||
import type { ReportFilters, ReportRecord, ToastType } from "./types.js";
|
||||
|
||||
const DEFAULT_FILTERS: ReportFilters = { cadence: "all", status: "all", from: "", to: "", q: "", agentId: "" };
|
||||
|
||||
export function useReports({ projectId, addToast }: { projectId?: string; addToast: (message: string, type?: ToastType) => void }) {
|
||||
const [filters, setFilters] = useState<ReportFilters>(DEFAULT_FILTERS);
|
||||
const [reports, setReports] = useState<ReportRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<string | undefined>();
|
||||
const [selectedReport, setSelectedReport] = useState<ReportRecord | undefined>();
|
||||
const [compareMode, setCompareMode] = useState(false);
|
||||
const [compareA, setCompareA] = useState<string | undefined>();
|
||||
const [compareB, setCompareB] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
listReports({
|
||||
cadence: filters.cadence === "all" ? undefined : filters.cadence,
|
||||
status: filters.status === "all" ? undefined : filters.status,
|
||||
from: filters.from || undefined,
|
||||
to: filters.to || undefined,
|
||||
q: filters.q || undefined,
|
||||
agentId: filters.agentId || undefined,
|
||||
projectId,
|
||||
}).then((items) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setReports(items);
|
||||
if (!selectedId && items[0]) setSelectedId(items[0].id);
|
||||
}).catch((err: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
addToast(err instanceof Error ? err.message : "Failed to load reports", "error");
|
||||
}).finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [filters, projectId, addToast, selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedId) return;
|
||||
getReport(selectedId, projectId).then(setSelectedReport).catch((err: unknown) => {
|
||||
addToast(err instanceof Error ? err.message : "Failed to load report", "error");
|
||||
});
|
||||
}, [selectedId, projectId, addToast]);
|
||||
|
||||
const selectId = useCallback((id: string) => setSelectedId(id), []);
|
||||
const enterCompareMode = useCallback(() => setCompareMode(true), []);
|
||||
const closeCompareMode = useCallback(() => setCompareMode(false), []);
|
||||
const setCompareSlot = useCallback((slot: "a" | "b", id: string) => {
|
||||
if (slot === "a") setCompareA(id);
|
||||
else setCompareB(id);
|
||||
}, []);
|
||||
|
||||
return useMemo(() => ({
|
||||
filters,
|
||||
setFilters,
|
||||
reports,
|
||||
loading,
|
||||
selectedId,
|
||||
selectedReport,
|
||||
selectId,
|
||||
compareMode,
|
||||
compareA,
|
||||
compareB,
|
||||
enterCompareMode,
|
||||
closeCompareMode,
|
||||
setCompareSlot,
|
||||
}), [filters, reports, loading, selectedId, selectedReport, selectId, compareMode, compareA, compareB, enterCompareMode, closeCompareMode, setCompareSlot]);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useViewportMode() {
|
||||
const [mobile, setMobile] = useState(false);
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(max-width: 768px)");
|
||||
const onChange = () => setMobile(mq.matches);
|
||||
onChange();
|
||||
mq.addEventListener("change", onChange);
|
||||
return () => mq.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
return { mobile };
|
||||
}
|
||||
@@ -3,10 +3,12 @@ import { definePlugin } from "@fusion/plugin-sdk";
|
||||
import { runReviewPanel } from "./review-panel.js";
|
||||
import { ensureReportSchema } from "./report-schema.js";
|
||||
import { createReportExportRoutes } from "./routes/report-export-routes.js";
|
||||
import { createReportListRoutes } from "./routes/report-list-routes.js";
|
||||
import type { CombinedReview, ReviewPanelMember, RunReviewPanelInput } from "./review-types.js";
|
||||
import type { ReportCadence, ReportCreateInput } from "./store/report-types.js";
|
||||
import { ReportStore } from "./store/report-store.js";
|
||||
import { settingsSchema } from "./settings.js";
|
||||
export { ReportsDashboardView } from "./dashboard-view.js";
|
||||
|
||||
const plugin = definePlugin({
|
||||
manifest: {
|
||||
@@ -22,7 +24,17 @@ const plugin = definePlugin({
|
||||
hooks: {
|
||||
onSchemaInit: ensureReportSchema,
|
||||
},
|
||||
routes: createReportExportRoutes(),
|
||||
routes: [...createReportListRoutes(), ...createReportExportRoutes()],
|
||||
dashboardViews: [
|
||||
{
|
||||
viewId: "reports",
|
||||
label: "Reports",
|
||||
componentPath: "./dashboard-view",
|
||||
icon: "FileText",
|
||||
placement: "primary",
|
||||
order: 35,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export interface RunGeneratedReportReviewInput {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { PluginContext, PluginRouteDefinition, PluginRouteResponse } from "@fusion/core";
|
||||
import { ReportStore } from "../store/report-store.js";
|
||||
|
||||
interface RouteRequest {
|
||||
params: Record<string, string>;
|
||||
query?: Record<string, string | string[] | undefined>;
|
||||
}
|
||||
|
||||
const reportStoreCache = new WeakMap<object, ReportStore>();
|
||||
|
||||
function getStore(ctx: PluginContext): ReportStore {
|
||||
const key = ctx.taskStore as object;
|
||||
const cached = reportStoreCache.get(key);
|
||||
if (cached) return cached;
|
||||
const store = new ReportStore(ctx.taskStore.getDatabase());
|
||||
reportStoreCache.set(key, store);
|
||||
return store;
|
||||
}
|
||||
|
||||
function badRequest(message: string): PluginRouteResponse {
|
||||
return { status: 400, body: { error: message } };
|
||||
}
|
||||
|
||||
export function createReportListRoutes(): PluginRouteDefinition[] {
|
||||
return [
|
||||
{
|
||||
method: "GET",
|
||||
path: "/reports",
|
||||
handler: async (req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> => {
|
||||
const request = req as RouteRequest;
|
||||
const query = request.query ?? {};
|
||||
const cadence = typeof query.cadence === "string" && query.cadence.length > 0 ? query.cadence : undefined;
|
||||
const status = typeof query.status === "string" && query.status.length > 0 ? query.status : undefined;
|
||||
const periodStartFrom = typeof query.from === "string" && query.from.length > 0 ? query.from : undefined;
|
||||
const periodStartTo = typeof query.to === "string" && query.to.length > 0 ? query.to : undefined;
|
||||
const q = typeof query.q === "string" && query.q.length > 0 ? query.q.toLowerCase() : undefined;
|
||||
const agent = typeof query.agentId === "string" && query.agentId.length > 0 ? query.agentId.toLowerCase() : undefined;
|
||||
|
||||
const store = getStore(ctx);
|
||||
const reports = store.listReports({
|
||||
cadence: cadence as never,
|
||||
status: status as never,
|
||||
periodStartFrom,
|
||||
periodStartTo,
|
||||
orderBy: "periodStart",
|
||||
orderDir: "desc",
|
||||
limit: 500,
|
||||
});
|
||||
|
||||
const filtered = reports.filter((report) => {
|
||||
if (q && !report.title.toLowerCase().includes(q)) return false;
|
||||
if (agent) {
|
||||
const agentIds = ((report.metadata?.agentIds as string[] | undefined) ?? []).map((id) => id.toLowerCase());
|
||||
if (!agentIds.some((id) => id.includes(agent))) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return { status: 200, body: { reports: filtered } };
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/reports/:id",
|
||||
handler: async (req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> => {
|
||||
const request = req as RouteRequest;
|
||||
const report = getStore(ctx).getReport(request.params.id);
|
||||
if (!report) return { status: 404, body: { error: `Report ${request.params.id} not found` } };
|
||||
return { status: 200, body: { report } };
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"jsx": "react-jsx",
|
||||
"types": ["node", "vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
||||
}
|
||||
|
||||
@@ -8,12 +8,17 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@fusion/core": fileURLToPath(new URL("../../packages/core/src/index.ts", import.meta.url)),
|
||||
"@fusion/dashboard": fileURLToPath(new URL("../../packages/dashboard/src/index.ts", import.meta.url)),
|
||||
"@fusion/plugin-sdk": fileURLToPath(new URL("../../packages/plugin-sdk/src/index.ts", import.meta.url)),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
include: ["src/**/__tests__/**/*.test.{ts,tsx}", "src/**/*.test.{ts,tsx}"],
|
||||
setupFiles: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-setup.ts", import.meta.url))],
|
||||
environmentMatchGlobs: [["src/dashboard/**", "jsdom"]],
|
||||
setupFiles: [
|
||||
fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-setup.ts", import.meta.url)),
|
||||
fileURLToPath(new URL("./src/dashboard/test-setup.ts", import.meta.url)),
|
||||
],
|
||||
globalSetup: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-teardown.ts", import.meta.url))],
|
||||
pool: "threads",
|
||||
maxWorkers,
|
||||
|
||||
Reference in New Issue
Block a user