7357070fb9
Deletes desktop/ (Electron wrapper), deployments/ (Helm/Kustomize/ Terraform/CI for cloud deploy), and monitoring/ (Prometheus + Grafana stack) along with DESKTOP.md, DEPLOYMENT.md, docker-compose.full.yml, their npm scripts, and every dangling reference across README, ARCHITECTURE, INSTALL, SETUP, docs/, and the repeated per-file MODULE_GUIDE "Observability" boilerplate comment. The GET /api/metrics endpoint itself is untouched — it's the dashboard's own route, not part of the removed monitoring stack.
425 lines
16 KiB
TypeScript
425 lines
16 KiB
TypeScript
/**
|
|
* @file CompactionImpact.tsx
|
|
* @description Visualizes how context compaction is spread across sessions.
|
|
* Compaction is when Claude Code compresses older conversation history into a
|
|
* summary once a session's context window fills up. This panel surfaces the
|
|
* at-a-glance stats (total events, sessions affected, average and peak per
|
|
* session) and a histogram answering "how many sessions compacted N times?"
|
|
* so the distribution is legible regardless of how many sessions exist.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
/* =============================================================================
|
|
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
|
* =============================================================================
|
|
* **Purpose:** Workflow analytics visualization built on D3; consumes aggregated session/run metrics from the workflows API.
|
|
*
|
|
* ## Design constraints
|
|
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
|
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
|
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
|
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
|
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
|
*
|
|
* ## Remote data & SSH
|
|
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
|
*
|
|
* ## Internal dependencies
|
|
* - `../../lib/types`
|
|
*
|
|
* ## Public surface
|
|
* - `CompactionImpactProps` — exported API; see TSDoc on the symbol for behavior.
|
|
* - `CompactionImpact` — exported API; see TSDoc on the symbol for behavior.
|
|
*
|
|
* ## Testing pointers
|
|
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
|
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
|
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
|
*
|
|
* ## Related docs
|
|
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
|
* - `docs/API.md` — REST reference.
|
|
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
|
* ============================================================================= */
|
|
/* -----------------------------------------------------------------------------
|
|
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
|
* -----------------------------------------------------------------------------
|
|
* **CompactionImpactProps**
|
|
* Part of this module's public contract. Downstream imports should treat
|
|
* the signature and return type as stable unless release notes say otherwise.
|
|
* When behavior changes, update the `@file` overview and relevant tests.
|
|
*
|
|
* **CompactionImpact**
|
|
* Part of this module's public contract. Downstream imports should treat
|
|
* the signature and return type as stable unless release notes say otherwise.
|
|
* When behavior changes, update the `@file` overview and relevant tests.
|
|
*
|
|
* ----------------------------------------------------------------------------- */
|
|
|
|
import { useRef, useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import * as d3 from "d3";
|
|
import type { CompactionImpactData } from "../../lib/types";
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
function fmtTokens(n: number): string {
|
|
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`;
|
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
|
return String(n);
|
|
}
|
|
|
|
/** Roll the per-session list into a histogram: for each compaction count k
|
|
* (1..peak), how many sessions compacted exactly k times. */
|
|
function toHistogram(perSession: CompactionImpactData["perSession"]): Array<{
|
|
count: number;
|
|
sessions: number;
|
|
}> {
|
|
const peak = perSession.reduce((m, s) => Math.max(m, s.compactions), 0);
|
|
const buckets: Array<{ count: number; sessions: number }> = [];
|
|
for (let k = 1; k <= peak; k++) {
|
|
buckets.push({ count: k, sessions: perSession.filter((s) => s.compactions === k).length });
|
|
}
|
|
return buckets;
|
|
}
|
|
|
|
// ── Chart constants ───────────────────────────────────────────────────────────
|
|
|
|
const MARGIN = { top: 18, right: 16, bottom: 46, left: 48 };
|
|
const CHART_HEIGHT = 200;
|
|
|
|
// ── D3 renderer ───────────────────────────────────────────────────────────────
|
|
|
|
interface HistogramBucket {
|
|
count: number;
|
|
sessions: number;
|
|
}
|
|
|
|
interface HistogramOpts {
|
|
x: string;
|
|
y: string;
|
|
onHover: (e: MouseEvent, d: HistogramBucket) => void;
|
|
onMove: (e: MouseEvent) => void;
|
|
onLeave: () => void;
|
|
}
|
|
|
|
function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: HistogramOpts): void {
|
|
const container = svg.parentElement;
|
|
const width = container ? container.clientWidth : 400;
|
|
const innerW = width - MARGIN.left - MARGIN.right;
|
|
const innerH = CHART_HEIGHT - MARGIN.top - MARGIN.bottom;
|
|
|
|
const root = d3.select(svg);
|
|
root.selectAll("*").remove();
|
|
root.attr("viewBox", `0 0 ${width} ${CHART_HEIGHT}`).attr("preserveAspectRatio", "xMidYMid meet");
|
|
|
|
const defs = root.append("defs");
|
|
const grad = defs
|
|
.append("linearGradient")
|
|
.attr("id", "compact-bar-grad")
|
|
.attr("x1", "0%")
|
|
.attr("y1", "0%")
|
|
.attr("x2", "0%")
|
|
.attr("y2", "100%");
|
|
grad.append("stop").attr("offset", "0%").attr("stop-color", "#818cf8");
|
|
grad
|
|
.append("stop")
|
|
.attr("offset", "100%")
|
|
.attr("stop-color", "#3730a3")
|
|
.attr("stop-opacity", 0.7);
|
|
|
|
const g = root.append("g").attr("transform", `translate(${MARGIN.left},${MARGIN.top})`);
|
|
|
|
const maxSessions = d3.max(histo, (d) => d.sessions) ?? 1;
|
|
|
|
const xScale = d3
|
|
.scaleBand<number>()
|
|
.domain(histo.map((d) => d.count))
|
|
.range([0, innerW])
|
|
.padding(histo.length > 12 ? 0.18 : 0.34);
|
|
|
|
const yScale = d3.scaleLinear().domain([0, maxSessions]).nice().range([innerH, 0]);
|
|
|
|
// Horizontal grid lines (integer session counts)
|
|
const yTicks = yScale.ticks(Math.min(4, maxSessions)).filter((d) => Number.isInteger(d));
|
|
g.selectAll<SVGLineElement, number>(".grid-line")
|
|
.data(yTicks)
|
|
.join("line")
|
|
.attr("class", "grid-line")
|
|
.attr("x1", 0)
|
|
.attr("x2", innerW)
|
|
.attr("y1", (d) => yScale(d))
|
|
.attr("y2", (d) => yScale(d))
|
|
.attr("stroke", "#2a2a3d")
|
|
.attr("stroke-width", 1);
|
|
|
|
// Y axis (sessions)
|
|
g.append("g")
|
|
.call(
|
|
d3
|
|
.axisLeft(yScale)
|
|
.tickValues(yTicks)
|
|
.tickSize(0)
|
|
.tickPadding(8)
|
|
.tickFormat((d) => String(d))
|
|
)
|
|
.call((ax) => ax.select(".domain").remove())
|
|
.selectAll("text")
|
|
.attr("fill", "#6b7280")
|
|
.attr("font-size", 10)
|
|
.attr("font-family", "Inter, sans-serif");
|
|
|
|
// X axis (compactions per session - one tick per bucket)
|
|
g.append("g")
|
|
.attr("transform", `translate(0,${innerH})`)
|
|
.call(d3.axisBottom(xScale).tickSize(0).tickPadding(8))
|
|
.call((ax) => ax.select(".domain").remove())
|
|
.selectAll("text")
|
|
.attr("fill", "#9ca3af")
|
|
.attr("font-size", 10)
|
|
.attr("font-family", "Inter, sans-serif");
|
|
|
|
// Axis titles
|
|
g.append("text")
|
|
.attr("x", innerW / 2)
|
|
.attr("y", innerH + 38)
|
|
.attr("text-anchor", "middle")
|
|
.attr("fill", "#6b7280")
|
|
.attr("font-size", 10)
|
|
.attr("font-weight", 500)
|
|
.attr("font-family", "Inter, sans-serif")
|
|
.text(opts.x);
|
|
|
|
g.append("text")
|
|
.attr("transform", "rotate(-90)")
|
|
.attr("x", -innerH / 2)
|
|
.attr("y", -38)
|
|
.attr("text-anchor", "middle")
|
|
.attr("fill", "#6b7280")
|
|
.attr("font-size", 10)
|
|
.attr("font-weight", 500)
|
|
.attr("font-family", "Inter, sans-serif")
|
|
.text(opts.y);
|
|
|
|
// Bars + count labels + rich hover tooltip (full-height hit-area so every
|
|
// column - including empty buckets - responds, matching the other charts).
|
|
histo.forEach((d) => {
|
|
const bx = xScale(d.count);
|
|
if (bx === undefined) return;
|
|
const bw = xScale.bandwidth();
|
|
const by = yScale(d.sessions);
|
|
const barH = innerH - by;
|
|
|
|
const bg = g.append("g");
|
|
let bar: d3.Selection<SVGRectElement, unknown, null, undefined> | null = null;
|
|
|
|
if (d.sessions > 0) {
|
|
bar = bg
|
|
.append("rect")
|
|
.attr("x", bx)
|
|
.attr("y", by)
|
|
.attr("width", bw)
|
|
.attr("height", barH)
|
|
.attr("rx", Math.min(4, bw / 2))
|
|
.attr("fill", "url(#compact-bar-grad)")
|
|
.style("transition", "fill 120ms ease");
|
|
|
|
bg.append("text")
|
|
.attr("x", bx + bw / 2)
|
|
.attr("y", by - 5)
|
|
.attr("text-anchor", "middle")
|
|
.attr("fill", "#a5b4fc")
|
|
.attr("font-size", 10)
|
|
.attr("font-weight", "600")
|
|
.attr("font-family", "Inter, sans-serif")
|
|
.attr("pointer-events", "none")
|
|
.text(d.sessions);
|
|
} else {
|
|
// Empty bucket: faint baseline tick so the gap reads as "zero", not missing
|
|
bg.append("rect")
|
|
.attr("x", bx)
|
|
.attr("y", innerH - 1)
|
|
.attr("width", bw)
|
|
.attr("height", 1)
|
|
.attr("fill", "#2a2a3d");
|
|
}
|
|
|
|
// Transparent, full-height hover target on top of the bar.
|
|
bg.append("rect")
|
|
.attr("x", bx)
|
|
.attr("y", 0)
|
|
.attr("width", bw)
|
|
.attr("height", innerH)
|
|
.attr("fill", "transparent")
|
|
.style("cursor", "pointer")
|
|
.on("mouseenter", (event: MouseEvent) => {
|
|
if (bar) bar.attr("fill", "#a5b4fc");
|
|
opts.onHover(event, d);
|
|
})
|
|
.on("mousemove", (event: MouseEvent) => opts.onMove(event))
|
|
.on("mouseleave", () => {
|
|
if (bar) bar.attr("fill", "url(#compact-bar-grad)");
|
|
opts.onLeave();
|
|
});
|
|
});
|
|
}
|
|
|
|
// ── Stat box ──────────────────────────────────────────────────────────────────
|
|
|
|
interface StatBoxProps {
|
|
label: string;
|
|
value: string;
|
|
sub?: string;
|
|
accent?: string;
|
|
}
|
|
|
|
function StatBox({ label, value, sub, accent = "text-accent" }: StatBoxProps) {
|
|
return (
|
|
<div className="flex flex-col gap-1 bg-surface-3 border border-border rounded-xl px-4 py-3.5 flex-1 min-w-0">
|
|
<span className={`text-2xl font-semibold tabular-nums ${accent}`}>{value}</span>
|
|
<span className="text-[11px] font-medium text-fg-muted uppercase tracking-wider leading-tight">
|
|
{label}
|
|
</span>
|
|
{sub && <span className="text-[11px] text-fg-muted tabular-nums">{sub}</span>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Component ─────────────────────────────────────────────────────────────────
|
|
|
|
export interface CompactionImpactProps {
|
|
data: CompactionImpactData;
|
|
}
|
|
|
|
export function CompactionImpact({ data }: CompactionImpactProps) {
|
|
const { t } = useTranslation("workflows");
|
|
const svgRef = useRef<SVGSVGElement>(null);
|
|
const [tip, setTip] = useState<{ x: number; y: number; title: string; detail: string } | null>(
|
|
null
|
|
);
|
|
|
|
const hasData = data.totalCompactions > 0;
|
|
const affected = data.sessionsWithCompactions;
|
|
const sessionPct = data.totalSessions > 0 ? Math.round((affected / data.totalSessions) * 100) : 0;
|
|
const avgPerSession = affected > 0 ? data.totalCompactions / affected : 0;
|
|
const peak = data.perSession.reduce((m, s) => Math.max(m, s.compactions), 0);
|
|
|
|
useEffect(() => {
|
|
if (!svgRef.current || !hasData) return;
|
|
const histo = toHistogram(data.perSession);
|
|
const affectedN = data.sessionsWithCompactions;
|
|
renderHistogram(svgRef.current, histo, {
|
|
x: t("compaction.xAxis"),
|
|
y: t("compaction.yAxis"),
|
|
onHover: (e, d) => {
|
|
const pct = affectedN > 0 ? Math.round((d.sessions / affectedN) * 100) : 0;
|
|
setTip({
|
|
x: e.clientX,
|
|
y: e.clientY,
|
|
title: t("compaction.tipTitle", { count: d.count }),
|
|
detail: t("compaction.tipDetail", { sessions: d.sessions, pct }),
|
|
});
|
|
},
|
|
onMove: (e) => setTip((p) => (p ? { ...p, x: e.clientX, y: e.clientY } : p)),
|
|
onLeave: () => setTip(null),
|
|
});
|
|
}, [data, hasData, t]);
|
|
|
|
if (!hasData) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center py-16 gap-3 text-fg-muted">
|
|
<svg
|
|
width="40"
|
|
height="40"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.5"
|
|
aria-hidden="true"
|
|
>
|
|
<path d="M9 17H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11a2 2 0 0 1 2 2v3" />
|
|
<path d="M13 21l2-2 4 4" />
|
|
<path d="M17 21v-6" />
|
|
<path d="M21 17h-6" />
|
|
</svg>
|
|
<span className="text-sm">{t("compaction.noData")}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-5">
|
|
{/* What compaction is - one line so the numbers below make sense */}
|
|
<p className="text-xs text-fg-muted leading-relaxed">{t("compaction.help")}</p>
|
|
|
|
{/* Stat tiles */}
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
|
<StatBox
|
|
label={t("compaction.totalCompactions")}
|
|
value={data.totalCompactions.toLocaleString()}
|
|
accent="text-accent-hover"
|
|
/>
|
|
<StatBox
|
|
label={t("compaction.sessionsAffected")}
|
|
value={affected.toLocaleString()}
|
|
sub={t("compaction.ofTotal", { total: data.totalSessions.toLocaleString() })}
|
|
accent="text-violet-300"
|
|
/>
|
|
<StatBox
|
|
label={t("compaction.avgPerSession")}
|
|
value={avgPerSession.toFixed(1)}
|
|
accent="text-blue-400"
|
|
/>
|
|
<StatBox
|
|
label={t("compaction.peakSession")}
|
|
value={peak.toLocaleString()}
|
|
accent="text-status-success"
|
|
/>
|
|
</div>
|
|
|
|
{/* Histogram: sessions by compaction count */}
|
|
<div className="w-full overflow-hidden">
|
|
<p className="text-xs font-medium text-fg-muted uppercase tracking-wider mb-2">
|
|
{t("compaction.distribution")}
|
|
</p>
|
|
<svg
|
|
ref={svgRef}
|
|
className="w-full"
|
|
style={{ height: CHART_HEIGHT }}
|
|
aria-label={t("compaction.ariaLabel")}
|
|
role="img"
|
|
/>
|
|
</div>
|
|
|
|
{/* Plain-English summary + (when present) tokens freed */}
|
|
<p className="text-xs text-fg-muted leading-relaxed">
|
|
{t("compaction.summary", {
|
|
affected: affected.toLocaleString(),
|
|
total: data.totalSessions.toLocaleString(),
|
|
pct: sessionPct,
|
|
})}
|
|
{data.tokensRecovered > 0 && (
|
|
<> {t("compaction.tokensFreed", { tokens: fmtTokens(data.tokensRecovered) })}</>
|
|
)}
|
|
</p>
|
|
|
|
{/* Hover tooltip (matches the app's other chart tooltips) */}
|
|
{tip && (
|
|
<div
|
|
className="fixed z-50 pointer-events-none rounded-md border border-[#2a2a4a] bg-[#12121f] px-2.5 py-1.5 text-xs shadow-xl"
|
|
style={{
|
|
left: tip.x > window.innerWidth - 220 ? tip.x - 14 : tip.x + 14,
|
|
top: tip.y - 10,
|
|
transform: tip.x > window.innerWidth - 220 ? "translateX(-100%)" : undefined,
|
|
}}
|
|
>
|
|
<div className="font-medium text-fg-primary">{tip.title}</div>
|
|
<div className="mt-0.5 text-fg-secondary">{tip.detail}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|