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:
5
.changeset/fn-3786-reports-dashboard-view.md
Normal file
5
.changeset/fn-3786-reports-dashboard-view.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add Reports plugin dashboard view with list/history, filters, detail viewer, and period comparison.
|
||||||
@@ -7,5 +7,16 @@ The reports plugin renders deterministic HTML via `src/render/html-template.ts`
|
|||||||
Standalone exports are produced by `renderStandaloneReportHtml` in `src/render/standalone-html.ts`. The export is fully self-contained (single document, inlined `<style>`, no remote CSS/fonts, and no non-allowlisted external `href/src` URLs). Exported HTML is cached back into the report store (`rendered_html`, `rendered_html_generated_at`) on first export.
|
Standalone exports are produced by `renderStandaloneReportHtml` in `src/render/standalone-html.ts`. The export is fully self-contained (single document, inlined `<style>`, no remote CSS/fonts, and no non-allowlisted external `href/src` URLs). Exported HTML is cached back into the report store (`rendered_html`, `rendered_html_generated_at`) on first export.
|
||||||
|
|
||||||
HTTP endpoints:
|
HTTP endpoints:
|
||||||
- `GET /api/plugins/fusion-plugin-reports/reports/:id/export.html` → attachment download (`Content-Disposition` + `text/html`)
|
- `GET /api/plugins/reports/reports/:id/export.html` → attachment download (`Content-Disposition` + `text/html`)
|
||||||
- `GET /api/plugins/fusion-plugin-reports/reports/:id/preview.html` → body-only HTML fragment for embedded preview viewers
|
- `GET /api/plugins/reports/reports/:id/preview.html` → body-only HTML fragment for embedded preview viewers
|
||||||
|
|
||||||
|
## Dashboard view
|
||||||
|
|
||||||
|
The Reports dashboard view is registered as a primary plugin destination (`viewId: "reports"`).
|
||||||
|
|
||||||
|
UX includes:
|
||||||
|
- Reports history list with cadence/status/date/title/agent filtering
|
||||||
|
- Detail viewer with sandboxed iframe `srcDoc` preview from `/reports/:id/preview.html`
|
||||||
|
- Section quick-jump sidebar based on stable rendering-contract `data-section` markers
|
||||||
|
- Compare drawer with two report selectors and section-level diff buckets (added/removed/changed/unchanged)
|
||||||
|
- Download action that opens `/reports/:id/export.html` for standalone HTML export
|
||||||
|
|||||||
@@ -136,3 +136,14 @@ Emitted events:
|
|||||||
- `report:deleted`
|
- `report:deleted`
|
||||||
|
|
||||||
This archive is the source of truth for downstream report HTML rendering (FN-3785) and dashboard report list/detail flows (FN-3786).
|
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.",
|
"description": "Generates beautiful HTML system-activity reports with multi-agent review.",
|
||||||
"author": "Fusion Team",
|
"author": "Fusion Team",
|
||||||
"fusionVersion": ">=0.1.0",
|
"fusionVersion": ">=0.1.0",
|
||||||
|
"dashboardViews": [
|
||||||
|
{
|
||||||
|
"viewId": "reports",
|
||||||
|
"label": "Reports",
|
||||||
|
"componentPath": "./dashboard-view",
|
||||||
|
"icon": "FileText",
|
||||||
|
"placement": "primary",
|
||||||
|
"order": 35
|
||||||
|
}
|
||||||
|
],
|
||||||
"settingsSchema": {
|
"settingsSchema": {
|
||||||
"dailyEnabled": { "type": "boolean", "label": "Enable Daily Reports", "description": "Generate daily reports on schedule.", "group": "Schedules", "defaultValue": true },
|
"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 * * *" },
|
"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": {
|
"./render": {
|
||||||
"types": "./src/render/index.ts",
|
"types": "./src/render/index.ts",
|
||||||
"import": "./src/render/index.ts"
|
"import": "./src/render/index.ts"
|
||||||
|
},
|
||||||
|
"./dashboard-view": {
|
||||||
|
"types": "./src/dashboard-view.tsx",
|
||||||
|
"import": "./src/dashboard-view.tsx"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -20,9 +24,17 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fusion/core": "workspace:*",
|
"@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": {
|
"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",
|
"@types/node": "^25.5.2",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
|
|||||||
@@ -42,6 +42,20 @@ describe("reports plugin manifest", () => {
|
|||||||
expect(plugin.manifest.fusionVersion).toBe(manifest.fusionVersion);
|
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", () => {
|
it("includes full settings schema", () => {
|
||||||
expect(plugin.manifest.settingsSchema).toBeDefined();
|
expect(plugin.manifest.settingsSchema).toBeDefined();
|
||||||
for (const key of expectedKeys) {
|
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 { runReviewPanel } from "./review-panel.js";
|
||||||
import { ensureReportSchema } from "./report-schema.js";
|
import { ensureReportSchema } from "./report-schema.js";
|
||||||
import { createReportExportRoutes } from "./routes/report-export-routes.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 { CombinedReview, ReviewPanelMember, RunReviewPanelInput } from "./review-types.js";
|
||||||
import type { ReportCadence, ReportCreateInput } from "./store/report-types.js";
|
import type { ReportCadence, ReportCreateInput } from "./store/report-types.js";
|
||||||
import { ReportStore } from "./store/report-store.js";
|
import { ReportStore } from "./store/report-store.js";
|
||||||
import { settingsSchema } from "./settings.js";
|
import { settingsSchema } from "./settings.js";
|
||||||
|
export { ReportsDashboardView } from "./dashboard-view.js";
|
||||||
|
|
||||||
const plugin = definePlugin({
|
const plugin = definePlugin({
|
||||||
manifest: {
|
manifest: {
|
||||||
@@ -22,7 +24,17 @@ const plugin = definePlugin({
|
|||||||
hooks: {
|
hooks: {
|
||||||
onSchemaInit: ensureReportSchema,
|
onSchemaInit: ensureReportSchema,
|
||||||
},
|
},
|
||||||
routes: createReportExportRoutes(),
|
routes: [...createReportListRoutes(), ...createReportExportRoutes()],
|
||||||
|
dashboardViews: [
|
||||||
|
{
|
||||||
|
viewId: "reports",
|
||||||
|
label: "Reports",
|
||||||
|
componentPath: "./dashboard-view",
|
||||||
|
icon: "FileText",
|
||||||
|
placement: "primary",
|
||||||
|
order: 35,
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
export interface RunGeneratedReportReviewInput {
|
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": {
|
"compilerOptions": {
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
|
"jsx": "react-jsx",
|
||||||
"types": ["node", "vitest/globals"]
|
"types": ["node", "vitest/globals"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"]
|
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,17 @@ export default defineConfig({
|
|||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@fusion/core": fileURLToPath(new URL("../../packages/core/src/index.ts", import.meta.url)),
|
"@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)),
|
"@fusion/plugin-sdk": fileURLToPath(new URL("../../packages/plugin-sdk/src/index.ts", import.meta.url)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
include: ["src/**/__tests__/**/*.test.{ts,tsx}", "src/**/*.test.{ts,tsx}"],
|
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))],
|
globalSetup: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-teardown.ts", import.meta.url))],
|
||||||
pool: "threads",
|
pool: "threads",
|
||||||
maxWorkers,
|
maxWorkers,
|
||||||
|
|||||||
24
pnpm-lock.yaml
generated
24
pnpm-lock.yaml
generated
@@ -818,13 +818,37 @@ importers:
|
|||||||
'@fusion/core':
|
'@fusion/core':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/core
|
version: link:../../packages/core
|
||||||
|
'@fusion/dashboard':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/dashboard
|
||||||
'@fusion/plugin-sdk':
|
'@fusion/plugin-sdk':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/plugin-sdk
|
version: link:../../packages/plugin-sdk
|
||||||
|
lucide-react:
|
||||||
|
specifier: ^0.542.0
|
||||||
|
version: 0.542.0(react@19.2.4)
|
||||||
|
react:
|
||||||
|
specifier: ^19.0.0
|
||||||
|
version: 19.2.4
|
||||||
|
react-dom:
|
||||||
|
specifier: ^19.2.4
|
||||||
|
version: 19.2.4(react@19.2.4)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@testing-library/jest-dom':
|
||||||
|
specifier: ^6.6.3
|
||||||
|
version: 6.9.1
|
||||||
|
'@testing-library/react':
|
||||||
|
specifier: ^16.3.2
|
||||||
|
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
'@testing-library/user-event':
|
||||||
|
specifier: ^14.6.1
|
||||||
|
version: 14.6.1(@testing-library/dom@10.4.1)
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^25.5.2
|
specifier: ^25.5.2
|
||||||
version: 25.5.2
|
version: 25.5.2
|
||||||
|
'@types/react':
|
||||||
|
specifier: ^19.0.0
|
||||||
|
version: 19.2.14
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.7.0
|
specifier: ^5.7.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|||||||
Reference in New Issue
Block a user