Skip to content

Commit f774dff

Browse files
committed
admin: bar charts + daily activity charts in the stat cards
Previously the Statistics + MCP Statistics sections rendered six "Top X" breakdowns as raw text rows: ``camera_id · 42``, ``user@email.com · 137``, ``2026-05-01 · 67``. That looks like a CRUD admin tool, not a product. Replaced all six with hand-rolled SVG/CSS visualisations that actually let the eye compare values. What changed: Stream Statistics Old New ───────────────── ───────────────────── ───────────────────── Top Cameras text rows horizontal bars (green) Top Viewers text rows horizontal bars (green) Daily Activity text rows vertical-bar chart (green) MCP Statistics Old New ───────────────── ───────────────────── ───────────────────── Top Tools text rows (mono) horizontal bars (purple, mono labels) By API Key text rows horizontal bars (purple) MCP Daily Activity text rows vertical-bar chart (purple) Implementation: - New ``frontend/src/components/AdminCharts.jsx`` exporting two components. ``BarList`` takes a flat ``items`` array (``{key, label, count}``), normalises against the max in the set, renders one stacked label/count head + thin track + filled segment per row. ``DailyActivityChart`` takes the ``by_day`` array directly (``{date, count}``), sorts by date, and emits an SVG with one vertical bar per day plus an MM-DD x-axis label and a baseline. - Both components use ``currentColor`` for fills so the SVG inherits the accent from CSS via ``data-accent`` (green for stream-side cards, purple for MCP-side). No hard-coded palette references in the component. - Bar widths animate via ``transition: width 0.4s cubic-bezier`` so the chart settles into place rather than jumping when the days filter changes. Daily-chart bars get a 75% opacity hover state + ``<title>`` tooltip showing the date and count. - Empty states render an italicised "No data" rather than a list with one "No data" row — consistent across both components. - Mono labels (the ``monoLabel`` flag on BarList) renders MCP tool names in JetBrains Mono so ``view_camera`` and friends still read as code identifiers, matching the prior text-list ``<code>`` treatment. What didn't change: - Total Accesses / Total MCP Calls big-number stat cards left alone — those are single scalars, not breakdowns, so a chart doesn't help. (They are slightly redundant with the KPI strip above, but the days-window selector below the strip can drift from the strip's window, so keeping them is honest.) - No new dependencies — no chart.js, no recharts, no d3. ~120 lines of inline SVG + ~90 lines of CSS for the entire visual upgrade. Verification: ``npm run build`` clean (521ms), ``npm run test`` 55/55 green. Backend untouched.
1 parent 06f8e20 commit f774dff

3 files changed

Lines changed: 256 additions & 66 deletions

File tree

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// Lightweight visualisations for the admin dashboard's stat cards.
2+
// Two components, both hand-rolled SVG / CSS — no charting library.
3+
//
4+
// BarList replaces "Top X" text rows (camera_id · 42, user · 137,
5+
// tool_name · 8) with horizontal bars proportional to the max in
6+
// the set. Visual rhythm: label on top, count on the right, thin
7+
// track underneath.
8+
//
9+
// DailyActivityChart replaces "2026-05-01: 37 / 2026-04-30: 67" text
10+
// rows with vertical bars under MM-DD x-axis labels. Uses
11+
// currentColor so the SVG inherits its accent from CSS — keeps the
12+
// component free of hard-coded palette references.
13+
14+
export function BarList({ items, accent = "green", emptyLabel = "No data", monoLabel = false }) {
15+
if (!items || items.length === 0) {
16+
return <div className="bar-list-empty">{emptyLabel}</div>
17+
}
18+
19+
const max = Math.max(...items.map((i) => Number(i.count) || 0), 1)
20+
21+
return (
22+
<ul className="bar-list" data-accent={accent}>
23+
{items.map((item, i) => {
24+
const count = Number(item.count) || 0
25+
const pct = (count / max) * 100
26+
return (
27+
<li key={item.key ?? i} className="bar-list-row">
28+
<div className="bar-list-row-head">
29+
<span
30+
className={`bar-list-label${monoLabel ? " bar-list-label-mono" : ""}`}
31+
title={item.label}
32+
>
33+
{item.label}
34+
</span>
35+
<span className="bar-list-count">{count.toLocaleString()}</span>
36+
</div>
37+
<div className="bar-list-track">
38+
<div className="bar-list-fill" style={{ width: `${pct}%` }} />
39+
</div>
40+
</li>
41+
)
42+
})}
43+
</ul>
44+
)
45+
}
46+
47+
export function DailyActivityChart({ data, accent = "green", emptyLabel = "No data" }) {
48+
if (!data || data.length === 0) {
49+
return <div className="daily-chart-empty">{emptyLabel}</div>
50+
}
51+
52+
const sorted = [...data].sort((a, b) => (a.date || "").localeCompare(b.date || ""))
53+
const counts = sorted.map((d) => Number(d.count) || 0)
54+
const max = Math.max(...counts, 1)
55+
56+
const width = 320
57+
const height = 112
58+
const pad = { top: 10, right: 6, bottom: 22, left: 6 }
59+
const innerW = width - pad.left - pad.right
60+
const innerH = height - pad.top - pad.bottom
61+
const slotW = innerW / sorted.length
62+
const barW = Math.min(slotW * 0.7, 36) // cap so 1-2 bars don't balloon
63+
const slotPad = (slotW - barW) / 2
64+
65+
return (
66+
<svg
67+
role="img"
68+
aria-label="Daily activity"
69+
width="100%"
70+
height={height}
71+
viewBox={`0 0 ${width} ${height}`}
72+
className="daily-chart"
73+
data-accent={accent}
74+
preserveAspectRatio="none"
75+
>
76+
{/* Subtle baseline so empty days still feel anchored */}
77+
<line
78+
x1={pad.left}
79+
y1={pad.top + innerH + 0.5}
80+
x2={pad.left + innerW}
81+
y2={pad.top + innerH + 0.5}
82+
className="daily-chart-baseline"
83+
/>
84+
{sorted.map((d, i) => {
85+
const c = counts[i]
86+
const h = c > 0 ? Math.max((c / max) * innerH, 3) : 0
87+
const x = pad.left + i * slotW + slotPad
88+
const y = pad.top + (innerH - h)
89+
const labelX = pad.left + i * slotW + slotW / 2
90+
const dayLabel = (d.date || "").slice(5) // MM-DD
91+
return (
92+
<g key={d.date || i}>
93+
<title>{`${d.date}: ${c.toLocaleString()}`}</title>
94+
{h > 0 && (
95+
<rect x={x} y={y} width={barW} height={h} fill="currentColor" rx="2" />
96+
)}
97+
<text
98+
x={labelX}
99+
y={pad.top + innerH + 14}
100+
textAnchor="middle"
101+
className="daily-chart-label"
102+
>
103+
{dayLabel}
104+
</text>
105+
</g>
106+
)
107+
})}
108+
</svg>
109+
)
110+
}

frontend/src/index.css

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6171,6 +6171,116 @@ body {
61716171
}
61726172
}
61736173

6174+
/* ── Admin charts: BarList + DailyActivityChart ──────────────────
6175+
Hand-rolled visualisations that replace the "label: count" text
6176+
rows in the stat cards. No charting library; SVG inherits accent
6177+
via currentColor + the data-accent attribute. */
6178+
6179+
.bar-list {
6180+
list-style: none;
6181+
margin: 0;
6182+
padding: 0;
6183+
display: flex;
6184+
flex-direction: column;
6185+
gap: 0.7rem;
6186+
}
6187+
6188+
.bar-list-row {
6189+
display: flex;
6190+
flex-direction: column;
6191+
gap: 4px;
6192+
}
6193+
6194+
.bar-list-row-head {
6195+
display: flex;
6196+
justify-content: space-between;
6197+
align-items: baseline;
6198+
gap: 0.5rem;
6199+
font-size: 0.78rem;
6200+
}
6201+
6202+
.bar-list-label {
6203+
color: var(--text-secondary);
6204+
overflow: hidden;
6205+
text-overflow: ellipsis;
6206+
white-space: nowrap;
6207+
flex: 1;
6208+
min-width: 0;
6209+
}
6210+
6211+
.bar-list-label-mono {
6212+
font-family: 'JetBrains Mono', monospace;
6213+
font-size: 0.74rem;
6214+
color: var(--text-primary);
6215+
}
6216+
6217+
.bar-list-count {
6218+
font-family: 'JetBrains Mono', monospace;
6219+
font-weight: 600;
6220+
color: var(--text-primary);
6221+
font-variant-numeric: tabular-nums;
6222+
flex-shrink: 0;
6223+
}
6224+
6225+
.bar-list-track {
6226+
height: 5px;
6227+
background: rgba(255, 255, 255, 0.05);
6228+
border-radius: 3px;
6229+
overflow: hidden;
6230+
}
6231+
6232+
.bar-list-fill {
6233+
height: 100%;
6234+
background: var(--accent-green);
6235+
border-radius: 3px;
6236+
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
6237+
}
6238+
6239+
.bar-list[data-accent="green"] .bar-list-fill { background: var(--accent-green); }
6240+
.bar-list[data-accent="purple"] .bar-list-fill { background: var(--accent-purple); }
6241+
.bar-list[data-accent="blue"] .bar-list-fill { background: var(--accent-blue); }
6242+
.bar-list[data-accent="amber"] .bar-list-fill { background: var(--accent-amber); }
6243+
6244+
.bar-list-empty,
6245+
.daily-chart-empty {
6246+
color: var(--text-muted);
6247+
font-size: 0.85rem;
6248+
padding: 1.2rem 0;
6249+
text-align: center;
6250+
font-style: italic;
6251+
}
6252+
6253+
/* Daily activity chart */
6254+
.daily-chart {
6255+
display: block;
6256+
width: 100%;
6257+
color: var(--accent-green); /* default; overridden by accent variants */
6258+
}
6259+
6260+
.daily-chart[data-accent="green"] { color: var(--accent-green); }
6261+
.daily-chart[data-accent="purple"] { color: var(--accent-purple); }
6262+
.daily-chart[data-accent="blue"] { color: var(--accent-blue); }
6263+
.daily-chart[data-accent="amber"] { color: var(--accent-amber); }
6264+
6265+
.daily-chart rect {
6266+
transition: opacity 0.15s ease;
6267+
}
6268+
6269+
.daily-chart g:hover rect {
6270+
opacity: 0.75;
6271+
}
6272+
6273+
.daily-chart-baseline {
6274+
stroke: rgba(255, 255, 255, 0.06);
6275+
stroke-width: 1;
6276+
}
6277+
6278+
.daily-chart-label {
6279+
fill: var(--text-muted);
6280+
font-family: 'JetBrains Mono', monospace;
6281+
font-size: 9px;
6282+
}
6283+
61746284
/* Audit Logs Section */
61756285
.audit-section {
61766286
background: var(--bg-secondary);

frontend/src/pages/AdminPage.jsx

Lines changed: 36 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import UpgradeModal from "../components/UpgradeModal.jsx"
88
import OrgAuditLogPanel from "../components/OrgAuditLogPanel.jsx"
99
import AdminKpiStrip from "../components/AdminKpiStrip.jsx"
1010
import AdminTabs from "../components/AdminTabs.jsx"
11+
import { BarList, DailyActivityChart } from "../components/AdminCharts.jsx"
1112

1213
function AdminPage() {
1314
const { getToken } = useAuth()
@@ -449,47 +450,31 @@ function AdminPage() {
449450

450451
<div className="stat-card">
451452
<h3>Top Cameras</h3>
452-
<ul className="stat-list">
453-
{stats?.by_camera?.slice(0, 5).map(item => (
454-
<li key={item.camera_id}>
455-
<span className="stat-name">{item.camera_id}</span>
456-
<span className="stat-count">{item.count}</span>
457-
</li>
458-
))}
459-
{(!stats?.by_camera || stats.by_camera.length === 0) && (
460-
<li><span className="stat-name">No data</span></li>
461-
)}
462-
</ul>
453+
<BarList
454+
accent="green"
455+
items={(stats?.by_camera || []).slice(0, 5).map((c) => ({
456+
key: c.camera_id,
457+
label: c.camera_id,
458+
count: c.count,
459+
}))}
460+
/>
463461
</div>
464462

465463
<div className="stat-card">
466464
<h3>Top Viewers</h3>
467-
<ul className="stat-list">
468-
{stats?.by_user?.slice(0, 5).map(item => (
469-
<li key={item.user_id}>
470-
<span className="stat-name">{item.user_email || item.user_id.substring(0, 12) + "..."}</span>
471-
<span className="stat-count">{item.count}</span>
472-
</li>
473-
))}
474-
{(!stats?.by_user || stats.by_user.length === 0) && (
475-
<li><span className="stat-name">No data</span></li>
476-
)}
477-
</ul>
465+
<BarList
466+
accent="green"
467+
items={(stats?.by_user || []).slice(0, 5).map((u) => ({
468+
key: u.user_id,
469+
label: u.user_email || (u.user_id ? u.user_id.substring(0, 12) + "..." : "—"),
470+
count: u.count,
471+
}))}
472+
/>
478473
</div>
479474

480475
<div className="stat-card">
481476
<h3>Daily Activity</h3>
482-
<ul className="stat-list">
483-
{stats?.by_day?.slice(0, 7).map(item => (
484-
<li key={item.date}>
485-
<span className="stat-name">{item.date}</span>
486-
<span className="stat-count">{item.count}</span>
487-
</li>
488-
))}
489-
{(!stats?.by_day || stats.by_day.length === 0) && (
490-
<li><span className="stat-name">No data</span></li>
491-
)}
492-
</ul>
477+
<DailyActivityChart accent="green" data={stats?.by_day} />
493478
</div>
494479
</div>
495480
)}
@@ -722,47 +707,32 @@ function AdminPage() {
722707

723708
<div className="stat-card">
724709
<h3>Top Tools</h3>
725-
<ul className="stat-list">
726-
{mcpStats?.by_tool?.slice(0, 5).map(item => (
727-
<li key={item.tool_name}>
728-
<span className="stat-name"><code>{item.tool_name}</code></span>
729-
<span className="stat-count">{item.count}</span>
730-
</li>
731-
))}
732-
{(!mcpStats?.by_tool || mcpStats.by_tool.length === 0) && (
733-
<li><span className="stat-name">No data</span></li>
734-
)}
735-
</ul>
710+
<BarList
711+
accent="purple"
712+
monoLabel
713+
items={(mcpStats?.by_tool || []).slice(0, 5).map((t) => ({
714+
key: t.tool_name,
715+
label: t.tool_name,
716+
count: t.count,
717+
}))}
718+
/>
736719
</div>
737720

738721
<div className="stat-card">
739722
<h3>By API Key</h3>
740-
<ul className="stat-list">
741-
{mcpStats?.by_key?.slice(0, 5).map(item => (
742-
<li key={item.key_name}>
743-
<span className="stat-name">{item.key_name}</span>
744-
<span className="stat-count">{item.count}</span>
745-
</li>
746-
))}
747-
{(!mcpStats?.by_key || mcpStats.by_key.length === 0) && (
748-
<li><span className="stat-name">No data</span></li>
749-
)}
750-
</ul>
723+
<BarList
724+
accent="purple"
725+
items={(mcpStats?.by_key || []).slice(0, 5).map((k) => ({
726+
key: k.key_name,
727+
label: k.key_name,
728+
count: k.count,
729+
}))}
730+
/>
751731
</div>
752732

753733
<div className="stat-card">
754734
<h3>MCP Daily Activity</h3>
755-
<ul className="stat-list">
756-
{mcpStats?.by_day?.slice(0, 7).map(item => (
757-
<li key={item.date}>
758-
<span className="stat-name">{item.date}</span>
759-
<span className="stat-count">{item.count}</span>
760-
</li>
761-
))}
762-
{(!mcpStats?.by_day || mcpStats.by_day.length === 0) && (
763-
<li><span className="stat-name">No data</span></li>
764-
)}
765-
</ul>
735+
<DailyActivityChart accent="purple" data={mcpStats?.by_day} />
766736
</div>
767737
</div>
768738
)}

0 commit comments

Comments
 (0)