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:
@@ -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;
|
||||
@@ -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 · {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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user