feat: initial miq.stats-plugin implementation

Operating-numbers view plugin (MIQ-1734): per-agent load, in-flight
per project, pending-decision age, 7-day throughput, and budget tile.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-06-27 04:00:59 +02:00
commit 40529c5991
9 changed files with 891 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
const manifest: PaperclipPluginManifestV1 = {
id: "miq.stats-plugin",
apiVersion: 1,
version: "0.1.0",
displayName: "MIQ Stats",
description:
"Operating-numbers view: per-agent load, in-flight per project, pending-decision age, last-7-days throughput, and budget summary.",
author: "MIQ",
categories: ["ui"],
capabilities: [
"ui.page.register",
"ui.sidebar.register",
"events.subscribe",
"issues.read",
"agents.read",
"projects.read",
"costs.read",
],
entrypoints: {
worker: "./dist/worker.js",
ui: "./dist/ui",
},
ui: {
slots: [
{
type: "page",
id: "stats-page",
displayName: "Stats",
exportName: "StatsPage",
routePath: "stats",
},
{
type: "sidebar",
id: "stats-sidebar",
displayName: "Stats",
exportName: "StatsSidebar",
order: -9,
},
],
},
};
export default manifest;
+714
View File
@@ -0,0 +1,714 @@
import { useEffect, useState } from "react";
import {
useHostContext,
usePluginStream,
type PluginPageProps,
type PluginSidebarProps,
} from "@paperclipai/plugin-sdk/ui";
// ── types ────────────────────────────────────────────────────────────────────
type IssueRow = {
id: string;
identifier: string;
title: string;
status: string;
priority: string;
assigneeAgentId: string | null;
assigneeUserId: string | null;
projectId: string | null;
updatedAt: string;
lastActivityAt: string | null;
};
type AgentRow = {
id: string;
name: string;
role: string | null;
title: string | null;
};
type ProjectRow = {
id: string;
name: string;
urlKey: string | null;
};
type DashboardData = {
costs?: {
monthSpendCents: number;
monthBudgetCents: number;
monthUtilizationPercent: number;
};
tasks?: {
open: number;
inProgress: number;
blocked: number;
done: number;
};
};
const IN_FLIGHT_STATUSES = "todo,in_progress,in_review,blocked";
const STREAM_NAME = "stats-refresh";
// ── helpers ──────────────────────────────────────────────────────────────────
async function jsonFetch<T>(url: string, signal?: AbortSignal): Promise<T> {
const res = await fetch(url, {
credentials: "same-origin",
headers: { Accept: "application/json" },
signal,
});
if (!res.ok) {
const text = await res.text().catch(() => res.statusText);
throw new Error(`${url}${res.status}: ${text.slice(0, 200)}`);
}
return (await res.json()) as T;
}
function formatRelative(iso: string | null): string {
if (!iso) return "";
const ts = Date.parse(iso);
if (Number.isNaN(ts)) return iso;
const delta = Date.now() - ts;
const sec = Math.round(delta / 1000);
if (sec < 60) return `${sec}s ago`;
const min = Math.round(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.round(min / 60);
if (hr < 24) return `${hr}h ago`;
const d = Math.round(hr / 24);
if (d < 30) return `${d}d ago`;
return new Date(ts).toLocaleDateString();
}
function formatDuration(iso: string | null): string {
if (!iso) return "";
const ts = Date.parse(iso);
if (Number.isNaN(ts)) return "";
const delta = Date.now() - ts;
const min = Math.round(delta / 60000);
if (min < 60) return `${min}m`;
const hr = Math.round(min / 60);
if (hr < 24) return `${hr}h`;
const d = Math.round(hr / 24);
return `${d}d`;
}
function formatCents(cents: number): string {
return `${(cents / 100).toFixed(2)}`;
}
// ── shared styles ─────────────────────────────────────────────────────────────
function panelStyle(small?: boolean): React.CSSProperties {
return {
border: "1px solid var(--pc-border, #e5e7eb)",
borderRadius: 8,
padding: small ? "10px 14px" : "12px 16px",
background: "var(--pc-bg-panel, transparent)",
};
}
function rowStyle(): React.CSSProperties {
return {
display: "flex",
alignItems: "baseline",
gap: 8,
padding: "5px 0",
borderBottom: "1px solid var(--pc-border-subtle, #f3f4f6)",
};
}
function emptyHint(text: string): React.ReactElement {
return (
<div style={{ color: "var(--pc-fg-muted, #6b7280)", fontStyle: "italic", padding: "6px 0" }}>
{text}
</div>
);
}
function badge(label: string, color: string): React.ReactElement {
return (
<span
style={{
fontSize: 11,
padding: "1px 6px",
borderRadius: 4,
border: `1px solid ${color}`,
color,
whiteSpace: "nowrap",
flexShrink: 0,
}}
>
{label}
</span>
);
}
function countBadge(n: number, color?: string): React.ReactElement {
const c = color ?? "#374151";
return (
<span
style={{
fontSize: 12,
fontWeight: 600,
padding: "1px 7px",
borderRadius: 4,
background: c,
color: "#fff",
flexShrink: 0,
}}
>
{n}
</span>
);
}
function sectionTitle(text: string, small?: boolean): React.ReactElement {
return (
<h2 style={{ marginTop: 0, marginBottom: 10, fontSize: small ? 13 : 15, fontWeight: 600 }}>
{text}
</h2>
);
}
// ── Panel 1: Per-agent load ────────────────────────────────────────────────
export function AgentLoadPanel({
companyId,
refreshTick,
}: {
companyId: string | null;
refreshTick: number;
}): React.ReactElement {
const [issues, setIssues] = useState<IssueRow[] | null>(null);
const [agents, setAgents] = useState<AgentRow[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!companyId) return;
const ctl = new AbortController();
setError(null);
Promise.all([
jsonFetch<IssueRow[]>(
`/api/companies/${encodeURIComponent(companyId)}/issues?status=${IN_FLIGHT_STATUSES}&limit=500`,
ctl.signal,
),
jsonFetch<AgentRow[]>(
`/api/companies/${encodeURIComponent(companyId)}/agents?limit=100`,
ctl.signal,
),
])
.then(([iss, ags]) => {
setIssues(Array.isArray(iss) ? iss : []);
setAgents(Array.isArray(ags) ? ags : []);
})
.catch((e: unknown) => {
if ((e as Error).name === "AbortError") return;
setError(e instanceof Error ? e.message : String(e));
});
return () => ctl.abort();
}, [companyId, refreshTick]);
const agentMap = new Map<string, AgentRow>((agents ?? []).map((a) => [a.id, a]));
const byAgent = new Map<string | null, IssueRow[]>();
for (const issue of issues ?? []) {
const key = issue.assigneeAgentId;
if (!byAgent.has(key)) byAgent.set(key, []);
byAgent.get(key)!.push(issue);
}
const rows = [...byAgent.entries()]
.sort((a, b) => (b[1].length - a[1].length))
.map(([agentId, issueList]) => {
const agent = agentId ? agentMap.get(agentId) : null;
const label = agent?.name ?? agent?.title ?? (agentId ? agentId.slice(0, 8) + "…" : "Unassigned");
return { agentId, label, count: issueList.length };
});
const maxCount = rows[0]?.count ?? 1;
return (
<section data-testid="agent-load-panel" style={panelStyle()}>
{sectionTitle("Work in flight — by agent")}
{!companyId ? (
emptyHint("No active company.")
) : error ? (
<div style={{ color: "#b91c1c" }}>Failed to load: {error}</div>
) : issues === null ? (
<div>Loading</div>
) : rows.length === 0 ? (
emptyHint("No in-flight issues.")
) : (
<div>
{rows.map(({ agentId, label, count }) => (
<div key={agentId ?? "unassigned"} style={{ ...rowStyle(), alignItems: "center" }}>
<span style={{ minWidth: 120, fontSize: 13, flex: "0 0 auto" }}>{label}</span>
<div style={{ flex: 1, height: 8, background: "var(--pc-border, #e5e7eb)", borderRadius: 4, overflow: "hidden" }}>
<div
style={{
width: `${Math.max(4, (count / maxCount) * 100)}%`,
height: "100%",
background: count >= 10 ? "#b91c1c" : count >= 5 ? "#d97706" : "#0369a1",
borderRadius: 4,
transition: "width 0.3s",
}}
/>
</div>
{countBadge(count, count >= 10 ? "#b91c1c" : count >= 5 ? "#d97706" : "#374151")}
</div>
))}
</div>
)}
</section>
);
}
// ── Panel 2: In-flight per project ─────────────────────────────────────────
export function ProjectInflightPanel({
companyId,
companyPrefix,
refreshTick,
}: {
companyId: string | null;
companyPrefix: string | null;
refreshTick: number;
}): React.ReactElement {
const [issues, setIssues] = useState<IssueRow[] | null>(null);
const [projects, setProjects] = useState<ProjectRow[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!companyId) return;
const ctl = new AbortController();
setError(null);
Promise.all([
jsonFetch<IssueRow[]>(
`/api/companies/${encodeURIComponent(companyId)}/issues?status=${IN_FLIGHT_STATUSES}&limit=500`,
ctl.signal,
),
jsonFetch<ProjectRow[]>(
`/api/companies/${encodeURIComponent(companyId)}/projects?limit=100`,
ctl.signal,
),
])
.then(([iss, prj]) => {
setIssues(Array.isArray(iss) ? iss : []);
setProjects(Array.isArray(prj) ? prj : []);
})
.catch((e: unknown) => {
if ((e as Error).name === "AbortError") return;
setError(e instanceof Error ? e.message : String(e));
});
return () => ctl.abort();
}, [companyId, refreshTick]);
const projectMap = new Map<string, ProjectRow>((projects ?? []).map((p) => [p.id, p]));
const byProject = new Map<string | null, IssueRow[]>();
for (const issue of issues ?? []) {
const key = issue.projectId;
if (!byProject.has(key)) byProject.set(key, []);
byProject.get(key)!.push(issue);
}
const rows = [...byProject.entries()]
.sort((a, b) => b[1].length - a[1].length)
.map(([projectId, issueList]) => {
const proj = projectId ? projectMap.get(projectId) : null;
const label = proj?.name ?? (projectId ? projectId.slice(0, 8) + "…" : "No project");
const href =
proj?.urlKey && companyPrefix
? `/${companyPrefix}/projects/${proj.urlKey}`
: null;
return { projectId, label, href, count: issueList.length };
});
const maxCount = rows[0]?.count ?? 1;
return (
<section data-testid="project-inflight-panel" style={panelStyle()}>
{sectionTitle("Active issues — by project")}
{!companyId ? (
emptyHint("No active company.")
) : error ? (
<div style={{ color: "#b91c1c" }}>Failed to load: {error}</div>
) : issues === null ? (
<div>Loading</div>
) : rows.length === 0 ? (
emptyHint("No in-flight issues.")
) : (
<div>
{rows.map(({ projectId, label, href, count }) => (
<div key={projectId ?? "none"} style={{ ...rowStyle(), alignItems: "center" }}>
{href ? (
<a href={href} style={{ minWidth: 140, fontSize: 13, flex: "0 0 auto", fontWeight: 500 }}>
{label}
</a>
) : (
<span style={{ minWidth: 140, fontSize: 13, flex: "0 0 auto", color: "var(--pc-fg-muted, #6b7280)" }}>
{label}
</span>
)}
<div style={{ flex: 1, height: 8, background: "var(--pc-border, #e5e7eb)", borderRadius: 4, overflow: "hidden" }}>
<div
style={{
width: `${Math.max(4, (count / maxCount) * 100)}%`,
height: "100%",
background: "#7c3aed",
borderRadius: 4,
transition: "width 0.3s",
}}
/>
</div>
{countBadge(count, "#7c3aed")}
</div>
))}
</div>
)}
</section>
);
}
// ── Panel 3: Pending-decision age ──────────────────────────────────────────
export function PendingDecisionAgePanel({
companyId,
companyPrefix,
refreshTick,
}: {
companyId: string | null;
companyPrefix: string | null;
refreshTick: number;
}): React.ReactElement {
const [issues, setIssues] = useState<IssueRow[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!companyId) return;
const ctl = new AbortController();
setError(null);
jsonFetch<IssueRow[]>(
`/api/companies/${encodeURIComponent(companyId)}/issues?status=in_review&limit=200`,
ctl.signal,
)
.then((data) => setIssues(Array.isArray(data) ? data : []))
.catch((e: unknown) => {
if ((e as Error).name === "AbortError") return;
setError(e instanceof Error ? e.message : String(e));
});
return () => ctl.abort();
}, [companyId, refreshTick]);
const sorted = (issues ?? []).slice().sort((a, b) => {
const ta = Date.parse(a.updatedAt);
const tb = Date.parse(b.updatedAt);
return ta - tb;
});
function ageColor(iso: string): string {
const hours = (Date.now() - Date.parse(iso)) / 3600000;
if (hours > 72) return "#b91c1c";
if (hours > 24) return "#d97706";
return "#059669";
}
return (
<section data-testid="pending-decision-panel" style={panelStyle()}>
{sectionTitle("Pending decisions — in_review age")}
{!companyId ? (
emptyHint("No active company.")
) : error ? (
<div style={{ color: "#b91c1c" }}>Failed to load: {error}</div>
) : issues === null ? (
<div>Loading</div>
) : sorted.length === 0 ? (
emptyHint("No issues awaiting review. ✓")
) : (
<div>
{sorted.map((issue) => {
const href = companyPrefix
? `/${companyPrefix}/issues/${issue.identifier}`
: `/issues/${issue.identifier}`;
const color = ageColor(issue.updatedAt);
const age = formatDuration(issue.updatedAt);
return (
<div key={issue.id} style={rowStyle()}>
<a href={href} style={{ fontWeight: 500, fontSize: 13, whiteSpace: "nowrap" }}>
{issue.identifier}
</a>
<span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontSize: 13 }}>
{issue.title}
</span>
{badge(age, color)}
</div>
);
})}
</div>
)}
</section>
);
}
// ── Panel 4: Last-7-days throughput ────────────────────────────────────────
export function ThroughputPanel({
companyId,
refreshTick,
}: {
companyId: string | null;
refreshTick: number;
}): React.ReactElement {
const [issues, setIssues] = useState<IssueRow[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!companyId) return;
const ctl = new AbortController();
setError(null);
jsonFetch<IssueRow[]>(
`/api/companies/${encodeURIComponent(companyId)}/issues?status=done&limit=500`,
ctl.signal,
)
.then((data) => setIssues(Array.isArray(data) ? data : []))
.catch((e: unknown) => {
if ((e as Error).name === "AbortError") return;
setError(e instanceof Error ? e.message : String(e));
});
return () => ctl.abort();
}, [companyId, refreshTick]);
const sevenDaysAgo = Date.now() - 7 * 24 * 3600000;
const recent = (issues ?? []).filter((i) => {
const ts = Date.parse(i.lastActivityAt ?? i.updatedAt);
return Number.isFinite(ts) && ts >= sevenDaysAgo;
});
const byDay = new Map<string, number>();
for (const issue of recent) {
const d = new Date(Date.parse(issue.lastActivityAt ?? issue.updatedAt)).toISOString().slice(0, 10);
byDay.set(d, (byDay.get(d) ?? 0) + 1);
}
const days: string[] = [];
for (let i = 6; i >= 0; i--) {
const d = new Date(Date.now() - i * 24 * 3600000).toISOString().slice(0, 10);
days.push(d);
}
const maxDay = Math.max(...days.map((d) => byDay.get(d) ?? 0), 1);
const rate = recent.length / 7;
return (
<section data-testid="throughput-panel" style={panelStyle()}>
{sectionTitle("Throughput — last 7 days")}
{!companyId ? (
emptyHint("No active company.")
) : error ? (
<div style={{ color: "#b91c1c" }}>Failed to load: {error}</div>
) : issues === null ? (
<div>Loading</div>
) : (
<div>
<div style={{ display: "flex", alignItems: "baseline", gap: 12, marginBottom: 10 }}>
<span style={{ fontSize: 28, fontWeight: 700, color: "#059669" }}>{recent.length}</span>
<span style={{ fontSize: 13, color: "var(--pc-fg-muted, #6b7280)" }}>
issues completed &nbsp;·&nbsp; {rate.toFixed(1)}/day avg
</span>
</div>
<div style={{ display: "flex", gap: 4, alignItems: "flex-end", height: 48 }}>
{days.map((d) => {
const count = byDay.get(d) ?? 0;
const h = Math.max(4, (count / maxDay) * 44);
return (
<div key={d} style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", gap: 2 }}>
<div
style={{
width: "100%",
height: h,
background: count > 0 ? "#059669" : "var(--pc-border, #e5e7eb)",
borderRadius: 2,
}}
title={`${d}: ${count} done`}
/>
<span style={{ fontSize: 10, color: "var(--pc-fg-muted, #6b7280)" }}>
{d.slice(8)}
</span>
</div>
);
})}
</div>
</div>
)}
</section>
);
}
// ── Panel 5: Budget (de-emphasized) ────────────────────────────────────────
export function BudgetPanel({
companyId,
refreshTick,
}: {
companyId: string | null;
refreshTick: number;
}): React.ReactElement {
const [data, setData] = useState<DashboardData | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!companyId) return;
const ctl = new AbortController();
setError(null);
jsonFetch<DashboardData>(
`/api/companies/${encodeURIComponent(companyId)}/dashboard`,
ctl.signal,
)
.then((d) => setData(d))
.catch((e: unknown) => {
if ((e as Error).name === "AbortError") return;
setError(e instanceof Error ? e.message : String(e));
});
return () => ctl.abort();
}, [companyId, refreshTick]);
const costs = data?.costs;
const pct = costs?.monthUtilizationPercent ?? 0;
const barColor = pct >= 90 ? "#b91c1c" : pct >= 70 ? "#d97706" : "#059669";
return (
<section
data-testid="budget-panel"
style={{
...panelStyle(true),
opacity: 0.75,
fontSize: 13,
}}
>
{sectionTitle("Budget — this month", true)}
{!companyId ? (
emptyHint("No active company.")
) : error ? (
<div style={{ color: "#b91c1c", fontSize: 12 }}>Failed to load: {error}</div>
) : data === null ? (
<div style={{ fontSize: 12 }}>Loading</div>
) : !costs ? (
emptyHint("No budget data.")
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<div style={{ display: "flex", alignItems: "baseline", gap: 8 }}>
<span style={{ fontWeight: 600 }}>{formatCents(costs.monthSpendCents)}</span>
{costs.monthBudgetCents > 0 && (
<span style={{ color: "var(--pc-fg-muted, #6b7280)" }}>
of {formatCents(costs.monthBudgetCents)} budget
</span>
)}
{costs.monthBudgetCents > 0 && (
<span style={{ marginLeft: "auto", fontWeight: 600, color: barColor }}>
{pct.toFixed(0)}%
</span>
)}
</div>
{costs.monthBudgetCents > 0 && (
<div style={{ height: 6, background: "var(--pc-border, #e5e7eb)", borderRadius: 3, overflow: "hidden" }}>
<div
style={{
width: `${Math.min(100, pct)}%`,
height: "100%",
background: barColor,
borderRadius: 3,
transition: "width 0.3s",
}}
/>
</div>
)}
</div>
)}
</section>
);
}
// ── Page root ─────────────────────────────────────────────────────────────────
export function StatsPage(_props: PluginPageProps): React.ReactElement {
const host = useHostContext();
const stream = usePluginStream<{ reason: string; at: string }>(STREAM_NAME, {
companyId: host.companyId ?? undefined,
});
const [refreshTick, setRefreshTick] = useState(0);
useEffect(() => {
if (stream.events.length > 0) {
setRefreshTick((t) => t + 1);
}
}, [stream.events.length]);
return (
<div
style={{
display: "grid",
gap: 16,
padding: 16,
maxWidth: 1100,
margin: "0 auto",
}}
>
<header>
<h1 style={{ margin: 0 }}>Operating Numbers</h1>
<p style={{ margin: "4px 0 0", color: "var(--pc-fg-muted, #6b7280)" }}>
Live MIQ operating metrics: load, backlog, decision latency, and throughput.
</p>
</header>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
<AgentLoadPanel companyId={host.companyId} refreshTick={refreshTick} />
<ProjectInflightPanel
companyId={host.companyId}
companyPrefix={host.companyPrefix}
refreshTick={refreshTick}
/>
</div>
<PendingDecisionAgePanel
companyId={host.companyId}
companyPrefix={host.companyPrefix}
refreshTick={refreshTick}
/>
<ThroughputPanel companyId={host.companyId} refreshTick={refreshTick} />
<div style={{ display: "grid", gridTemplateColumns: "1fr 3fr", gap: 16, opacity: 0.85 }}>
<BudgetPanel companyId={host.companyId} refreshTick={refreshTick} />
<div />
</div>
</div>
);
}
// ── Sidebar entry ──────────────────────────────────────────────────────────
export function StatsSidebar(_props: PluginSidebarProps): React.ReactElement {
const host = useHostContext();
const href = host.companyPrefix ? `/${host.companyPrefix}/stats` : "/stats";
return (
<a
href={href}
data-testid="stats-sidebar-link"
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "6px 8px",
borderRadius: 6,
textDecoration: "none",
color: "inherit",
}}
>
<span aria-hidden="true">📊</span>
<span>Stats</span>
</a>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { definePlugin, runWorker, type PluginEvent } from "@paperclipai/plugin-sdk";
const STREAM_CHANNEL = "stats-refresh";
const plugin = definePlugin({
async setup(ctx) {
const openedForCompany = new Set<string>();
function notify(reason: string, event: PluginEvent) {
const companyId = event.companyId;
if (!companyId) return;
if (!openedForCompany.has(companyId)) {
ctx.streams.open(STREAM_CHANNEL, companyId);
openedForCompany.add(companyId);
}
ctx.streams.emit(STREAM_CHANNEL, {
reason,
entityId: event.entityId ?? null,
at: new Date().toISOString(),
});
}
ctx.events.on("issue.updated", async (event) => {
notify("issue.updated", event);
});
ctx.events.on("issue.comment.created", async (event) => {
notify("issue.comment.created", event);
});
},
async onHealth() {
return { status: "ok", message: "Stats plugin worker is running" };
},
});
export default plugin;
runWorker(plugin, import.meta.url);