Skip to content

Commit ad60f1a

Browse files
committed
feat: add Integrations section to root navigation
New root-level section alongside Token Monitoring and Leaderboard. Shows every tool TokenHUD knows about merged across all machines, grouped by state (reading, ready, needs-setup, api-only), with per-machine status dots and setup instructions. Badge in the root rail shows reading/total (e.g. 4/26).
1 parent eb26870 commit ad60f1a

5 files changed

Lines changed: 218 additions & 0 deletions

File tree

site/src/components/Portal.jsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,19 @@ export default function Portal({ onClose, user, onUser, onSelfHost }) {
246246
return row ? row.rank : null
247247
}, [profiles, meId])
248248

249+
/* Integration summary across all snapshots for the badge. */
250+
const intSummary = useMemo(() => {
251+
const snaps = data?.latest || []
252+
let reading = 0, known = 0
253+
const seen = new Set()
254+
for (const s of snaps) {
255+
const sum = s.metrics?.integrationSummary
256+
if (sum) { reading = Math.max(reading, sum.reading || 0); known = Math.max(known, sum.known || 0) }
257+
for (const r of (s.metrics?.integrations || [])) seen.add(r.id)
258+
}
259+
return { reading, known: known || seen.size }
260+
}, [data])
261+
249262
const hasSubNav = section === 'monitoring' || section === 'leaderboard'
250263

251264
/* App is still asking Cognito whether a session exists — don't flash the
@@ -285,6 +298,7 @@ export default function Portal({ onClose, user, onUser, onSelfHost }) {
285298
badges={{
286299
monitoring: (data?.hosts || []).length || null,
287300
leaderboard: myRank ? '#' + myRank : null,
301+
integrations: intSummary.known ? intSummary.reading + '/' + intSummary.known : null,
288302
}}
289303
/>
290304

@@ -346,6 +360,9 @@ export default function Portal({ onClose, user, onUser, onSelfHost }) {
346360
onGoToMachines={() => goto('monitoring')}
347361
/>
348362
)}
363+
{section === 'integrations' && (
364+
<IntegrationsPage snapshots={data?.latest || []} />
365+
)}
349366
{section === 'settings' && (
350367
<div className="adm-page" style={{ padding: 'var(--space-xl) var(--page-gutter)' }}>
351368
<h2>Account</h2>

site/src/components/SelfHost.jsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,19 @@ export default function SelfHost({ onClose }) {
564564
return row ? row.rank : null
565565
}, [profiles, meId])
566566

567+
/* Integration summary across all snapshots for the badge. */
568+
const intSummary = useMemo(() => {
569+
const snaps = shaped?.latest || []
570+
let reading = 0, known = 0
571+
const seen = new Set()
572+
for (const s of snaps) {
573+
const sum = s.metrics?.integrationSummary
574+
if (sum) { reading = Math.max(reading, sum.reading || 0); known = Math.max(known, sum.known || 0) }
575+
for (const r of (s.metrics?.integrations || [])) seen.add(r.id)
576+
}
577+
return { reading, known: known || seen.size }
578+
}, [shaped])
579+
567580
/* ── routing: probing and offline don't get the admin shell ── */
568581
if (phase === 'probing') return <Probing />
569582
if (phase === 'offline') {
@@ -602,6 +615,7 @@ export default function SelfHost({ onClose }) {
602615
badges={{
603616
monitoring: (data?.hosts || []).length || null,
604617
leaderboard: myRank ? '#' + myRank : null,
618+
integrations: intSummary.known ? intSummary.reading + '/' + intSummary.known : null,
605619
}}
606620
/>
607621

@@ -692,6 +706,9 @@ export default function SelfHost({ onClose }) {
692706
onGoToMachines={() => goto('monitoring')}
693707
/>
694708
)}
709+
{section === 'integrations' && (
710+
<IntegrationsPage snapshots={shaped?.latest || []} />
711+
)}
695712
{section === 'settings' && (
696713
<Settings
697714
serverUrl={serverUrl.current}
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { useMemo, useState } from 'react'
2+
import { Card, Pill } from '../board/panels'
3+
4+
/* The Integrations section: every tool TokenHUD knows about, across all
5+
* machines. The board-embedded IntegrationsCard shows one machine at a time;
6+
* this page merges them so you see the fleet-wide picture: which tools are
7+
* read anywhere, which could be, and what each machine needs to get there.
8+
*
9+
* The data comes from each snapshot's `metrics.integrations` array. Each row
10+
* has: id, name, state, headline, steps[], where, fields, docs, installed,
11+
* hasData, access, confidence. */
12+
13+
const STATE_ORDER = [
14+
{ key: 'reading', label: 'Read by this board', tone: 'ok' },
15+
{ key: 'ready', label: 'Ready — nothing recorded yet', tone: 'ok' },
16+
{ key: 'needs-setup', label: 'One step away', tone: 'warn' },
17+
{ key: 'api-only', label: 'Needs an API key', tone: 'warn' },
18+
{ key: 'cloud-only', label: 'Web products — nothing local', tone: '' },
19+
{ key: 'absent', label: 'Not installed here', tone: '' },
20+
]
21+
22+
/* Merge integrations across all machines. For each tool, pick the "best"
23+
state (reading > ready > needs-setup > …) and note which machines have it. */
24+
function mergeIntegrations(snapshots) {
25+
const byId = new Map()
26+
const stateRank = { reading: 0, ready: 1, 'needs-setup': 2, 'api-only': 3, 'cloud-only': 4, absent: 5 }
27+
28+
for (const snap of snapshots) {
29+
const host = snap.host || snap.metrics?.host?.hostname || '?'
30+
const rows = snap.metrics?.integrations || []
31+
for (const row of rows) {
32+
const existing = byId.get(row.id)
33+
if (!existing) {
34+
byId.set(row.id, {
35+
...row,
36+
machines: [{ host, state: row.state, installed: row.installed, hasData: row.hasData }],
37+
bestState: row.state,
38+
})
39+
} else {
40+
existing.machines.push({ host, state: row.state, installed: row.installed, hasData: row.hasData })
41+
if ((stateRank[row.state] ?? 99) < (stateRank[existing.bestState] ?? 99)) {
42+
existing.bestState = row.state
43+
existing.headline = row.headline
44+
existing.steps = row.steps
45+
existing.where = row.where
46+
existing.fields = row.fields
47+
existing.docs = row.docs
48+
}
49+
}
50+
}
51+
}
52+
return [...byId.values()]
53+
}
54+
55+
function IntegrationRow({ row, multi }) {
56+
const [open, setOpen] = useState(false)
57+
const steps = row.steps || []
58+
const actionable = steps.length > 0
59+
60+
return (
61+
<li className="bv-int">
62+
<div className="bv-int-head">
63+
<div className="name">
64+
<span>{row.name}</span>
65+
{row.confidence === 'documented' && (
66+
<span className="bv-int-flag" title="From the tool's own documentation — not yet confirmed by opening it here.">
67+
documented
68+
</span>
69+
)}
70+
</div>
71+
{actionable && (
72+
<button className="bv-int-btn" onClick={() => setOpen(o => !o)} aria-expanded={open}>
73+
{open ? 'Hide' : row.bestState === 'reading' ? 'Details' : 'How to enable'}
74+
</button>
75+
)}
76+
</div>
77+
<p className="bv-int-note">{row.headline}</p>
78+
{multi && row.machines && row.machines.length > 0 && (
79+
<div className="bv-int-machines">
80+
{row.machines.map((m, i) => (
81+
<span key={i} className="bv-int-machine">
82+
<span className={'sh-dot sh-dot--' + (m.state === 'reading' ? 'ok' : m.state === 'ready' ? 'ok' : m.state === 'needs-setup' ? 'warn' : 'off')} />
83+
{m.host}
84+
</span>
85+
))}
86+
</div>
87+
)}
88+
{open && (
89+
<div className="bv-int-body">
90+
<ol>{steps.map((s, i) => <li key={i}>{s}</li>)}</ol>
91+
<p className="bv-sub"><b>Where:</b> {row.where}</p>
92+
<p className="bv-sub"><b>What you get:</b> {row.fields}</p>
93+
{row.docs && (
94+
<a className="bv-int-docs" href={row.docs} target="_blank" rel="noreferrer">
95+
Official documentation →
96+
</a>
97+
)}
98+
</div>
99+
)}
100+
</li>
101+
)
102+
}
103+
104+
export default function IntegrationsPage({ snapshots }) {
105+
const [showAll, setShowAll] = useState(false)
106+
107+
const rows = useMemo(() => mergeIntegrations(snapshots || []), [snapshots])
108+
const multi = (snapshots || []).length > 1
109+
110+
const summary = useMemo(() => {
111+
const count = (st) => rows.filter(r => r.bestState === st).length
112+
return {
113+
known: rows.length,
114+
reading: count('reading'),
115+
ready: count('ready'),
116+
needsSetup: count('needs-setup'),
117+
apiOnly: count('api-only'),
118+
installed: rows.filter(r => r.machines?.some(m => m.installed)).length,
119+
}
120+
}, [rows])
121+
122+
const quiet = new Set(['absent', 'cloud-only'])
123+
const groups = STATE_ORDER
124+
.map(g => ({ ...g, items: rows.filter(r => r.bestState === g.key) }))
125+
.filter(g => g.items.length && (showAll || !quiet.has(g.key)))
126+
const hidden = rows.filter(r => quiet.has(r.bestState)).length
127+
128+
if (!rows.length) {
129+
return (
130+
<div className="adm-page adm-page--wide">
131+
<Card title="Integrations" warn>
132+
<p style={{ padding: 'var(--space-md)' }}>
133+
No machines have reported integration data yet. Once a machine
134+
reports, every tool TokenHUD knows about will appear here with its
135+
status and setup steps.
136+
</p>
137+
</Card>
138+
</div>
139+
)
140+
}
141+
142+
return (
143+
<div className="adm-page adm-page--wide">
144+
<Card
145+
title="Integrations"
146+
note={`${summary.known} tools tracked · ${summary.reading} read here · ${summary.needsSetup + summary.apiOnly} could be`}
147+
right={<Pill tone="ok">{summary.installed} installed</Pill>}
148+
>
149+
<div className="bv-int-groups">
150+
{groups.map(g => (
151+
<div key={g.key} className="bv-int-group">
152+
<h3>
153+
<Pill tone={g.tone}>{g.items.length}</Pill>
154+
<span>{g.label}</span>
155+
</h3>
156+
<ul>{g.items.map(r => <IntegrationRow key={r.id} row={r} multi={multi} />)}</ul>
157+
</div>
158+
))}
159+
</div>
160+
{hidden > 0 && (
161+
<button className="bv-int-more" onClick={() => setShowAll(a => !a)}>
162+
{showAll ? 'Hide' : `Show ${hidden} more`} — tools not on {multi ? 'any machine' : 'this machine'}
163+
</button>
164+
)}
165+
</Card>
166+
</div>
167+
)
168+
}

site/src/components/admin/RootRail.jsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ export const SECTIONS = [
2828
group: 'Monitoring',
2929
hint: 'How every machine on this board compares',
3030
},
31+
{
32+
key: 'integrations',
33+
label: 'Integrations',
34+
icon: 'plug',
35+
group: 'Monitoring',
36+
hint: 'Every tool this board knows, and what it can read',
37+
},
3138
{
3239
key: 'settings',
3340
label: 'Settings',

site/src/styles.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1624,6 +1624,15 @@ a { color: inherit; text-decoration: none; }
16241624
.bv-int-body p { margin: 2px 0; }
16251625
.bv-int-docs { font-size: var(--text-xs); color: var(--color-accent); text-decoration: none; display: inline-block; margin-top: var(--space-xs); }
16261626
.bv-int-docs:hover { text-decoration: underline; }
1627+
.bv-int-machines {
1628+
display: flex; flex-wrap: wrap; gap: var(--space-xs); margin-top: 4px;
1629+
}
1630+
.bv-int-machine {
1631+
display: inline-flex; align-items: center; gap: 4px;
1632+
font-size: var(--text-xs); color: var(--color-ink-3);
1633+
background: var(--color-paper-1); border-radius: var(--radius-pill);
1634+
padding: 1px 8px 1px 4px;
1635+
}
16271636

16281637
/* ── admin shell (self-host portal) ────────────────────────── */
16291638

0 commit comments

Comments
 (0)