total-recall-brain 3.41.4 → 3.41.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/bin/total-recall.mjs +10 -6
  2. package/docs/reference/cli-reference.md +18 -0
  3. package/frontend/dist/assets/index-4tGQfTa2.js +5605 -0
  4. package/frontend/dist/assets/index-DtwsDyFM.css +1 -0
  5. package/frontend/dist/index.html +2 -2
  6. package/frontend/package.json +11 -0
  7. package/frontend/src/App.tsx +47 -122
  8. package/frontend/src/components/plugins/plugin-frame.ts +1 -0
  9. package/frontend/src/index.css +152 -86
  10. package/frontend/src/pages/HelpPage.tsx +13 -10
  11. package/frontend/src/pages/MemoryPage.tsx +28 -30
  12. package/frontend/src/pages/NetworkPage.tsx +3 -3
  13. package/frontend/src/pages/PluginsPage.tsx +6 -5
  14. package/frontend/src/pages/RulesPage.tsx +16 -12
  15. package/package.json +4 -3
  16. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +3114 -36
  17. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package.json +4 -1
  18. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +3114 -36
  19. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package.json +4 -1
  20. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +3121 -33
  21. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package.json +4 -1
  22. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +3122 -34
  23. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package.json +4 -1
  24. package/scaffold/.agent/skills/total-recall/plugins/mesh/package-lock.json +3127 -49
  25. package/scaffold/.agent/skills/total-recall/plugins/mesh/package.json +5 -2
  26. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/mesh.mjs +1 -1
  27. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/capability/index.mjs +136 -7
  28. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/cli.mjs +135 -26
  29. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package-lock.json +3121 -33
  30. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package.json +5 -2
  31. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/plugin.json +228 -9
  32. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/skills/meta-harness/SKILL.md +24 -30
  33. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/alerts.mjs +29 -16
  34. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/cascade.mjs +73 -0
  35. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/coordinator.mjs +182 -365
  36. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/dispatch.mjs +33 -0
  37. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/documentation.mjs +40 -14
  38. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/executor.mjs +4 -4
  39. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/agy.mjs +187 -0
  40. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/claude.mjs +275 -0
  41. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/cline.mjs +132 -0
  42. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/codex.mjs +187 -0
  43. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/cursor.mjs +158 -0
  44. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/devin.mjs +157 -0
  45. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/dsh.mjs +183 -0
  46. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/grok.mjs +132 -0
  47. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/hermes.mjs +173 -0
  48. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/index.mjs +87 -0
  49. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/openclaw.mjs +173 -0
  50. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/pi.mjs +129 -0
  51. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate.mjs +12 -0
  52. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/json-reply.mjs +22 -0
  53. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/learner.mjs +72 -70
  54. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/model-catalog.mjs +195 -0
  55. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/paths.mjs +11 -0
  56. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/pool.mjs +36 -0
  57. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/review.mjs +169 -0
  58. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/router.mjs +136 -346
  59. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/runtime.mjs +1 -1
  60. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/ssss-store.mjs +20 -0
  61. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/telemetry.mjs +43 -0
  62. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/threads.mjs +156 -0
  63. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/worker.mjs +117 -35
  64. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/worktree.mjs +51 -0
  65. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/ui/card.js +398 -57
  66. package/scaffold/.agent/skills/total-recall/plugins/openwiki/cli.mjs +304 -46
  67. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +367 -41
  68. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +5 -2
  69. package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +33 -0
  70. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/config.mjs +2 -2
  71. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/generate.mjs +159 -0
  72. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/graph.mjs +197 -0
  73. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/lint.mjs +153 -0
  74. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/mesh-vaults.mjs +261 -0
  75. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/refresh.mjs +151 -4
  76. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/search.mjs +114 -0
  77. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/token.mjs +96 -0
  78. package/scaffold/.agent/skills/total-recall/plugins/openwiki/ui/card.js +899 -662
  79. package/scaffold/.agent/skills/total-recall/plugins/openwiki/vendor/openwiki.mjs +8987 -0
  80. package/scaffold/.agent/skills/total-recall/plugins/openwiki/vendor/skills/mermaid-diagrams/SKILL.md +44 -0
  81. package/scaffold/.agent/skills/total-recall/plugins/openwiki/vendor/skills/write-connector/SKILL.md +56 -0
  82. package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +3097 -81
  83. package/scaffold/.agent/skills/total-recall/plugins/security/package.json +4 -1
  84. package/src/cli/agent.mjs +12 -1
  85. package/src/cli/compact.mjs +108 -0
  86. package/src/cli/connect.mjs +56 -1
  87. package/src/cli/context.mjs +55 -23
  88. package/src/cli/doctor.mjs +5 -2
  89. package/src/cli/forget.mjs +2 -0
  90. package/src/cli/harness.mjs +62 -50
  91. package/src/cli/memory-entry.mjs +2 -2
  92. package/src/cli/preflight.mjs +152 -16
  93. package/src/cli/release.mjs +386 -0
  94. package/src/cli/remember.mjs +19 -1
  95. package/src/cli/rules.mjs +54 -12
  96. package/src/cli/startup.mjs +1 -1
  97. package/src/cli/status.mjs +12 -2
  98. package/src/core/claude-hooks.mjs +41 -3
  99. package/src/core/cli-commands.mjs +2 -0
  100. package/src/core/cli-semantic-index.mjs +3 -2
  101. package/src/core/conflict-detector.mjs +23 -12
  102. package/src/core/context-compactor.mjs +145 -0
  103. package/src/core/context-policy.mjs +20 -0
  104. package/src/core/duplicate-guard.mjs +350 -0
  105. package/src/core/meta-harness.mjs +31 -11
  106. package/src/core/preflight-gate.mjs +2 -2
  107. package/src/core/preflight-receipt.mjs +29 -1
  108. package/src/core/scaffold-plugins.mjs +7 -0
  109. package/src/core/stdio-flush.mjs +23 -0
  110. package/src/core/surface.mjs +33 -10
  111. package/src/server/invoke-durability.mjs +39 -0
  112. package/src/server/invoke-write-child.mjs +25 -0
  113. package/src/server/routes/invoke.mjs +7 -0
  114. package/src/server/routes/plugins.mjs +2 -2
  115. package/frontend/dist/assets/index-BIQmqDKP.css +0 -1
  116. package/frontend/dist/assets/index-CRzjQLu7.js +0 -5631
  117. package/frontend/src/components/DaemonLogsTab.tsx +0 -54
  118. package/frontend/src/components/ResearchAgendaTab.tsx +0 -712
  119. package/frontend/src/components/TaskQueueTab.tsx +0 -193
  120. package/frontend/src/components/VoiceInput.tsx +0 -112
  121. package/frontend/src/pages/ChatPage.tsx +0 -1059
  122. package/frontend/src/pages/CollabPage.tsx +0 -470
  123. package/frontend/src/pages/DesignDocsPage.tsx +0 -621
  124. package/frontend/src/pages/FilesPage.tsx +0 -393
  125. package/frontend/src/pages/HealthPage.tsx +0 -359
  126. package/frontend/src/pages/InboxPage.tsx +0 -83
  127. package/frontend/src/pages/InstructionsPage.tsx +0 -583
  128. package/frontend/src/pages/IntegrationsPage.tsx +0 -761
  129. package/frontend/src/pages/MeshPage.css +0 -58
  130. package/frontend/src/pages/MeshPage.tsx +0 -1219
  131. package/frontend/src/pages/OkfPage.tsx +0 -535
  132. package/frontend/src/pages/SandboxPage.tsx +0 -113
  133. package/frontend/src/pages/SkillsPage.tsx +0 -1994
  134. package/frontend/src/pages/TasksPage.tsx +0 -427
  135. package/frontend/src/pages/UsagePage.tsx +0 -702
  136. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/README.md +0 -65
  137. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/cli.mjs +0 -76
  138. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/package.json +0 -19
  139. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/plugin.json +0 -266
  140. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/adapters/dsh.mjs +0 -446
  141. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/documentation.mjs +0 -152
  142. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/executor.mjs +0 -71
  143. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/runtime.mjs +0 -36
  144. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/setup.mjs +0 -35
  145. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/worker.mjs +0 -175
  146. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/ui/DESIGN.md +0 -32
  147. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/ui/card.js +0 -575
@@ -1,1219 +0,0 @@
1
- import { useCallback, useEffect, useRef, useState } from 'react';
2
- import {
3
- fetchLeader,
4
- fetchNodes as fetchMeshNodes,
5
- refreshElection,
6
- fetchMeshLatency,
7
- fetchLanDiscovery,
8
- fetchMeshInterfaces,
9
- fetchDeviceIo,
10
- registerLanMeshNodes,
11
- fetchElectionHistory,
12
- logElectionObservation,
13
- fetchEnrollmentStatus,
14
- enrollThisNode,
15
- fetchAccessProposals,
16
- importAccessFromSshConfig,
17
- setNodeAccess,
18
- } from '../api/mesh';
19
- import { fetchHeadscaleNodes, fetchPreAuthKeys, fetchHeadscaleUsers, createPreAuthKey, deleteHeadscaleNode, fetchHeadscalePolicy, saveHeadscalePolicy, buildMeshSshPolicy } from '../api/headscale';
20
- import type {
21
- MeshNode,
22
- LeaderInfo,
23
- LanHost,
24
- MeshInterfaceSummary,
25
- DeviceIoProfile,
26
- EnrollmentStatus,
27
- AccessProposal,
28
- } from '../api/mesh';
29
- import type { HeadscaleNode, PreAuthKey as HeadscalePreAuthKey, HeadscaleUser } from '../api/headscale';
30
- import { MeshTopology } from '../components/MeshTopology';
31
- import { EmbeddingProviderPanel } from '../components/EmbeddingProviderPanel';
32
- import { LatencySparkline } from '../components/LatencySparkline';
33
- import './MeshPage.css';
34
-
35
- const POLL_BASE_MS = 5000;
36
- const POLL_MAX_MS = 30000;
37
- const LATENCY_HISTORY_MAX = 24;
38
-
39
- interface ElectionLogEntry {
40
- at: string;
41
- hostname: string;
42
- ip: string;
43
- note: string;
44
- }
45
-
46
- /** Editable access fields, as strings so an empty box can clear a value. */
47
- interface AccessDraft {
48
- ssh_user: string;
49
- ssh_port: string;
50
- identity_file: string;
51
- ssh_host: string;
52
- }
53
-
54
- const EMPTY_ACCESS_DRAFT: AccessDraft = {
55
- ssh_user: '',
56
- ssh_port: '',
57
- identity_file: '',
58
- ssh_host: '',
59
- };
60
-
61
- function accessDraftFromNode(node: MeshNode | null): AccessDraft {
62
- const access = node?.access;
63
- return {
64
- ssh_user: access?.ssh_user || '',
65
- ssh_port: access?.ssh_port ? String(access.ssh_port) : '',
66
- identity_file: access?.identity_file || '',
67
- ssh_host: access?.ssh_host || '',
68
- };
69
- }
70
-
71
- export function MeshPage() {
72
- const [activeTab, setActiveTab] = useState<'mesh'|'headscale-nodes'|'preauthkeys'|'users'>('mesh');
73
-
74
- // Mesh State
75
- const [meshNodes, setMeshNodes] = useState<MeshNode[]>([]);
76
- const [leader, setLeader] = useState<LeaderInfo | null>(null);
77
- // Selection is held as a hostname, not a node object: polling replaces the
78
- // node list every few seconds, and a captured object goes stale the moment it
79
- // does — the detail pane would keep showing the state at the time of click,
80
- // including right after an edit was saved.
81
- const [selectedHostname, setSelectedHostname] = useState<string | null>(null);
82
- const selectedNode = meshNodes.find((node) => node.hostname === selectedHostname) || null;
83
- const [latencyMs, setLatencyMs] = useState<Record<string, number | null>>({});
84
- /** Rolling RTT samples per hostname for sparklines (from this node). */
85
- const [latencyHistory, setLatencyHistory] = useState<Record<string, number[]>>({});
86
- const [electionLog, setElectionLog] = useState<ElectionLogEntry[]>([]);
87
- const [lanHosts, setLanHosts] = useState<LanHost[]>([]);
88
- const [localInterfaces, setLocalInterfaces] = useState<MeshInterfaceSummary[]>([]);
89
- const [lanTrCount, setLanTrCount] = useState(0);
90
- const [deviceIo, setDeviceIo] = useState<DeviceIoProfile | null>(null);
91
- const [uiHints, setUiHints] = useState<string[]>([]);
92
- const [enrollment, setEnrollment] = useState<EnrollmentStatus | null>(null);
93
- const [enrollBusy, setEnrollBusy] = useState(false);
94
- const [enrollMsg, setEnrollMsg] = useState<string | null>(null);
95
- // Mesh SSH is a property of the control server, not of this node, so it is
96
- // tracked separately from enrollment.
97
- const [sshPolicy, setSshPolicy] = useState<{ configured: boolean; fileMode?: boolean } | null>(null);
98
- const [sshBusy, setSshBusy] = useState(false);
99
- const [sshMsg, setSshMsg] = useState<string | null>(null);
100
- const [lanRegisterBusy, setLanRegisterBusy] = useState(false);
101
- const [lanRegisterMsg, setLanRegisterMsg] = useState<string | null>(null);
102
- // A node whose login account is unknown refuses every connection in exactly
103
- // the way an unreachable node does, so the gap is surfaced, not just stored.
104
- const [accessProposals, setAccessProposals] = useState<AccessProposal[]>([]);
105
- const [accessBusy, setAccessBusy] = useState(false);
106
- const [accessMsg, setAccessMsg] = useState<string | null>(null);
107
- const [accessDraft, setAccessDraft] = useState<AccessDraft>(EMPTY_ACCESS_DRAFT);
108
- const [accessSaveBusy, setAccessSaveBusy] = useState(false);
109
- const [accessSaveMsg, setAccessSaveMsg] = useState<string | null>(null);
110
- const prevLeaderRef = useRef<string | null>(null);
111
- const pollMsRef = useRef(POLL_BASE_MS);
112
- const electionHistoryLoadedRef = useRef(false);
113
-
114
- // Headscale State
115
- const [hsNodes, setHsNodes] = useState<HeadscaleNode[]>([]);
116
- const [hsKeys, setHsKeys] = useState<HeadscalePreAuthKey[]>([]);
117
- const [hsUsers, setHsUsers] = useState<HeadscaleUser[]>([]);
118
-
119
- const [error, setError] = useState<string | null>(null);
120
- const [loading, setLoading] = useState(false);
121
-
122
- // Key form state
123
- const [newKeyUser, setNewKeyUser] = useState('default');
124
- const [newKeyReusable, setNewKeyReusable] = useState(false);
125
- const [newKeyEphemeral, setNewKeyEphemeral] = useState(false);
126
- const [newKeyExpiration, setNewKeyExpiration] = useState('2099-12-31T23:59:59Z');
127
-
128
- const nodesMissingAccess = meshNodes.filter((node) => node.access_resolved?.complete === false);
129
-
130
- const recordLeaderChange = useCallback((l: LeaderInfo | null, note: string) => {
131
- if (!l) return;
132
- const key = `${l.hostname}|${l.ip}`;
133
- if (prevLeaderRef.current === key && note === 'observed') return;
134
- if (prevLeaderRef.current !== key || note !== 'observed') {
135
- const at = new Date().toISOString();
136
- setElectionLog((prev) => [
137
- { at, hostname: l.hostname, ip: l.ip, note },
138
- ...prev,
139
- ].slice(0, 50));
140
- prevLeaderRef.current = key;
141
- // Persist to SSSS event store (best-effort; de-duped server-side).
142
- logElectionObservation({ hostname: l.hostname, ip: l.ip, note }).catch(() => {});
143
- }
144
- }, []);
145
-
146
- // Selecting is the only thing that may overwrite the edit form. Doing it in a
147
- // poll-driven effect instead would wipe whatever the user was typing every
148
- // few seconds.
149
- const selectNode = useCallback((node: MeshNode) => {
150
- setSelectedHostname(node.hostname);
151
- setAccessDraft(accessDraftFromNode(node));
152
- setAccessSaveMsg(null);
153
- }, []);
154
-
155
- const pushLatencySamples = useCallback((sample: Record<string, number | null>) => {
156
- setLatencyMs(sample);
157
- setLatencyHistory((prev) => {
158
- const next = { ...prev };
159
- for (const [host, ms] of Object.entries(sample)) {
160
- if (ms == null || !Number.isFinite(ms)) continue;
161
- const series = [...(next[host] || []), ms].slice(-LATENCY_HISTORY_MAX);
162
- next[host] = series;
163
- }
164
- return next;
165
- });
166
- }, []);
167
-
168
- const loadData = useCallback(async () => {
169
- setLoading(true);
170
- setError(null);
171
- try {
172
- if (activeTab === 'mesh') {
173
- const [n, l] = await Promise.all([fetchMeshNodes(), fetchLeader()]);
174
- setMeshNodes(n);
175
- setLeader(l);
176
- recordLeaderChange(l, 'observed');
177
- // Latency is slower — best-effort, do not fail the page.
178
- fetchMeshLatency()
179
- .then((lat) => pushLatencySamples(lat.latency_ms || {}))
180
- .catch(() => { /* peers may be unreachable */ });
181
- // Load durable election history once per mount.
182
- if (!electionHistoryLoadedRef.current) {
183
- electionHistoryLoadedRef.current = true;
184
- fetchElectionHistory()
185
- .then((events) => {
186
- if (!events.length) return;
187
- setElectionLog((prev) => {
188
- const seen = new Set(prev.map((e) => `${e.at}|${e.hostname}|${e.note}`));
189
- const fromVault: ElectionLogEntry[] = events
190
- .filter((e) => e.at && (e.hostname || e.ip))
191
- .map((e) => ({
192
- at: e.at,
193
- hostname: e.hostname || e.ip || 'unknown',
194
- ip: e.ip || '',
195
- note: e.note || 'observed',
196
- }))
197
- .filter((e) => !seen.has(`${e.at}|${e.hostname}|${e.note}`));
198
- return [...fromVault, ...prev].slice(0, 50);
199
- });
200
- })
201
- .catch(() => { /* optional */ });
202
- }
203
- fetchMeshInterfaces()
204
- .then((iface) => setLocalInterfaces(iface.summary || []))
205
- .catch(() => { /* optional */ });
206
- // LAN discovery is heavier — probe TR brains on LAN; do not fail the page.
207
- fetchLanDiscovery({ probe: true, limit: 24 })
208
- .then((lan) => {
209
- setLanHosts(lan.hosts || []);
210
- setLanTrCount(lan.tr_reachable_count || 0);
211
- })
212
- .catch(() => { /* ARP may be unavailable */ });
213
- fetchDeviceIo()
214
- .then((ioRes) => {
215
- setDeviceIo(ioRes.io || null);
216
- setUiHints(ioRes.ui_hints || ioRes.io?.ui_hints || []);
217
- })
218
- .catch(() => { /* optional */ });
219
- // Neither of these should ever take the page down — both are optional
220
- // capabilities that may simply not be configured yet.
221
- void (async () => {
222
- try {
223
- const p = await fetchHeadscalePolicy();
224
- setSshPolicy({ configured: p.configured, fileMode: p.fileMode });
225
- } catch {
226
- setSshPolicy(null);
227
- }
228
- })();
229
- // Enrollment state drives the join banner; never fail the page on it.
230
- fetchEnrollmentStatus()
231
- .then(setEnrollment)
232
- .catch(() => { /* optional */ });
233
- fetchAccessProposals()
234
- .then((res) => setAccessProposals(res.proposals || []))
235
- .catch(() => { /* no ssh config on this host is normal */ });
236
- pollMsRef.current = POLL_BASE_MS;
237
- } else if (activeTab === 'headscale-nodes') {
238
- const nodes = await fetchHeadscaleNodes();
239
- setHsNodes(nodes);
240
- } else if (activeTab === 'preauthkeys') {
241
- const keys = await fetchPreAuthKeys(newKeyUser || 'default');
242
- setHsKeys(keys);
243
- } else if (activeTab === 'users') {
244
- const users = await fetchHeadscaleUsers();
245
- setHsUsers(users);
246
- }
247
- } catch (err) {
248
- setError(err instanceof Error ? err.message : `Failed to load ${activeTab} data`);
249
- // Back off polling on errors to reduce long-task pressure.
250
- pollMsRef.current = Math.min(POLL_MAX_MS, pollMsRef.current * 1.5);
251
- } finally {
252
- setLoading(false);
253
- }
254
- }, [activeTab, newKeyUser, recordLeaderChange, pushLatencySamples]);
255
-
256
- useEffect(() => {
257
- let cancelled = false;
258
- let timer: ReturnType<typeof setTimeout> | null = null;
259
-
260
- const tick = async () => {
261
- if (cancelled) return;
262
- await loadData();
263
- if (cancelled) return;
264
- timer = setTimeout(tick, pollMsRef.current);
265
- };
266
- tick();
267
-
268
- return () => {
269
- cancelled = true;
270
- if (timer) clearTimeout(timer);
271
- };
272
- }, [activeTab, loadData]);
273
-
274
- async function handleRefreshElection() {
275
- try {
276
- const l = await refreshElection();
277
- recordLeaderChange(l, 'manual refresh');
278
- await loadData();
279
- } catch (err) {
280
- setError(err instanceof Error ? err.message : 'Failed to refresh election state');
281
- }
282
- }
283
-
284
- async function handleRegisterLanPeers() {
285
- setLanRegisterBusy(true);
286
- setLanRegisterMsg(null);
287
- try {
288
- const res = await registerLanMeshNodes({ probe: true, limit: 24 });
289
- setLanRegisterMsg(
290
- `Registered ${res.registration?.written_count ?? 0} of ${res.registration?.attempted ?? 0} TR-reachable LAN hosts as mesh_node entities.`,
291
- );
292
- await loadData();
293
- } catch (err) {
294
- setError(err instanceof Error ? err.message : 'LAN register failed');
295
- } finally {
296
- setLanRegisterBusy(false);
297
- }
298
- }
299
-
300
- async function handleImportAccess() {
301
- setAccessBusy(true);
302
- setAccessMsg(null);
303
- try {
304
- const res = await importAccessFromSshConfig();
305
- setAccessMsg(
306
- res.failed
307
- ? `Recorded ${res.saved} of ${res.attempted}; ${res.failed} could not be saved.`
308
- : `Recorded a login account for ${res.saved} node(s).`,
309
- );
310
- await loadData();
311
- } catch (err) {
312
- setAccessMsg(err instanceof Error ? err.message : 'Could not import from ssh config');
313
- } finally {
314
- setAccessBusy(false);
315
- }
316
- }
317
-
318
- async function handleSaveAccess(e: React.FormEvent) {
319
- e.preventDefault();
320
- if (!selectedNode) return;
321
- setAccessSaveBusy(true);
322
- setAccessSaveMsg(null);
323
- try {
324
- await setNodeAccess({ node: selectedNode.hostname, ...accessDraft });
325
- setAccessSaveMsg('Saved to the node entity.');
326
- await loadData();
327
- } catch (err) {
328
- setAccessSaveMsg(err instanceof Error ? err.message : 'Could not save access');
329
- } finally {
330
- setAccessSaveBusy(false);
331
- }
332
- }
333
-
334
- async function handleEnableMeshSsh() {
335
- setSshBusy(true);
336
- setSshMsg(null);
337
- try {
338
- await saveHeadscalePolicy(buildMeshSshPolicy());
339
- const next = await fetchHeadscalePolicy();
340
- setSshPolicy({ configured: next.configured, fileMode: next.fileMode });
341
- setSshMsg('Mesh SSH enabled. Nodes pick this up automatically.');
342
- } catch (err) {
343
- setSshMsg(err instanceof Error ? err.message : 'Could not enable mesh SSH');
344
- } finally {
345
- setSshBusy(false);
346
- }
347
- }
348
-
349
- async function handleEnroll() {
350
- setEnrollBusy(true);
351
- setEnrollMsg(null);
352
- try {
353
- const result = await enrollThisNode();
354
- setEnrollment(result.status);
355
- if (result.ok && result.changed) {
356
- setEnrollMsg(`Enrolled on the mesh via ${result.method || 'control server'}.`);
357
- } else if (result.ok) {
358
- setEnrollMsg('This node is already enrolled.');
359
- } else if (result.auth_url) {
360
- setEnrollMsg('Approve the registration link below to finish joining.');
361
- } else {
362
- setEnrollMsg(result.hint || result.reason || 'Enrollment did not complete.');
363
- }
364
- await loadData();
365
- } catch (err) {
366
- setError(err instanceof Error ? err.message : 'Enrollment failed');
367
- } finally {
368
- setEnrollBusy(false);
369
- }
370
- }
371
-
372
- async function handleCreateKey(e: React.FormEvent) {
373
- e.preventDefault();
374
- try {
375
- await createPreAuthKey({
376
- user: newKeyUser,
377
- reusable: newKeyReusable,
378
- ephemeral: newKeyEphemeral,
379
- expiration: newKeyExpiration
380
- });
381
- await loadData();
382
- } catch (err) {
383
- setError(err instanceof Error ? err.message : 'Failed to create pre-auth key');
384
- }
385
- }
386
-
387
- async function handleDeleteNode(id: string) {
388
- if (!confirm('Are you sure you want to delete this node from Headscale?')) return;
389
- try {
390
- await deleteHeadscaleNode(id);
391
- await loadData();
392
- } catch (err) {
393
- setError(err instanceof Error ? err.message : 'Failed to delete node');
394
- }
395
- }
396
-
397
- return (
398
- <div className="page-container mesh-page">
399
- <div className="page-header">
400
- <h1>Mesh Operations Center</h1>
401
- {activeTab === 'mesh' && (
402
- <button
403
- onClick={handleRefreshElection}
404
- className="btn btn-primary"
405
- >
406
- Refresh Election
407
- </button>
408
- )}
409
- </div>
410
-
411
- <div style={{ display: 'flex', gap: '8px', marginBottom: '24px', borderBottom: '1px solid var(--border)' }}>
412
- <button
413
- className={`btn ${activeTab === 'mesh' ? 'btn-primary' : 'btn-ghost'}`}
414
- onClick={() => setActiveTab('mesh')}
415
- style={activeTab === 'mesh' ? {} : { borderBottom: 'none' }}
416
- >
417
- Daemon Status
418
- </button>
419
- <button
420
- className={`btn ${activeTab === 'headscale-nodes' ? 'btn-primary' : 'btn-ghost'}`}
421
- onClick={() => setActiveTab('headscale-nodes')}
422
- >
423
- Headscale Nodes
424
- </button>
425
- <button
426
- className={`btn ${activeTab === 'preauthkeys' ? 'btn-primary' : 'btn-ghost'}`}
427
- onClick={() => setActiveTab('preauthkeys')}
428
- >
429
- Pre-Auth Keys
430
- </button>
431
- <button
432
- className={`btn ${activeTab === 'users' ? 'btn-primary' : 'btn-ghost'}`}
433
- onClick={() => setActiveTab('users')}
434
- >
435
- Users
436
- </button>
437
- </div>
438
-
439
- {error && <div className="alert alert-error" style={{ marginBottom: '24px' }}>{error}</div>}
440
-
441
- {enrollment && !enrollment.enrolled && (
442
- <div className="alert alert-warning mesh-enrollment-banner" style={{ marginBottom: '24px' }} data-testid="enrollment-banner">
443
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, flexWrap: 'wrap' }}>
444
- <div>
445
- <strong>This node is not on the mesh</strong>
446
- <div style={{ fontSize: 13, marginTop: 4 }}>
447
- {enrollment.state === 'client_unavailable'
448
- ? 'No Tailscale client detected. Install Tailscale (or set TR_TAILSCALE_BIN) to join the mesh.'
449
- : enrollment.can_auto_enroll
450
- ? `Ready to enroll automatically against ${enrollment.login_server}.`
451
- : enrollment.login_server
452
- ? `Control server ${enrollment.login_server} — add a Headscale API key to enroll without approving in a browser.`
453
- : 'No control server configured. Add a Headscale API key with its URL, or set TR_HEADSCALE_URL.'}
454
- </div>
455
- {enrollment.auth_url && (
456
- <div style={{ fontSize: 13, marginTop: 8 }}>
457
- Pending registration:{' '}
458
- <a href={enrollment.auth_url} target="_blank" rel="noreferrer noopener">
459
- approve this node
460
- </a>
461
- </div>
462
- )}
463
- {enrollMsg && (
464
- <div style={{ fontSize: 13, marginTop: 8, color: 'var(--accent-hover)' }}>{enrollMsg}</div>
465
- )}
466
- </div>
467
- {enrollment.client_available && (
468
- <button
469
- type="button"
470
- className="btn btn-primary"
471
- onClick={handleEnroll}
472
- disabled={enrollBusy}
473
- >
474
- {enrollBusy ? 'Enrolling…' : 'Enroll this node'}
475
- </button>
476
- )}
477
- </div>
478
- </div>
479
- )}
480
-
481
- {activeTab === 'mesh' && (
482
- <div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
483
- {/* Without a policy the control server routes packets but authorises
484
- nothing, so every machine falls back to its own SSH keys. */}
485
- {sshPolicy && !sshPolicy.configured && (
486
- <div className="alert alert-warning" data-testid="mesh-ssh-banner">
487
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, flexWrap: 'wrap' }}>
488
- <div>
489
- <strong>Mesh SSH is off</strong>
490
- <div style={{ fontSize: 13, marginTop: 4 }}>
491
- {sshPolicy.fileMode
492
- ? 'The control server stores its policy in a file, so it cannot be managed here. Set policy.mode: database in the control server config to enable this.'
493
- : 'Nodes can reach each other, but each still needs its own SSH keys. Enabling mesh SSH lets the control server authorise connections instead.'}
494
- </div>
495
- {sshMsg && (
496
- <div style={{ fontSize: 13, marginTop: 8, color: 'var(--accent-hover)' }}>{sshMsg}</div>
497
- )}
498
- </div>
499
- {!sshPolicy.fileMode && (
500
- <button type="button" className="btn btn-primary" onClick={handleEnableMeshSsh} disabled={sshBusy}>
501
- {sshBusy ? 'Enabling…' : 'Enable mesh SSH'}
502
- </button>
503
- )}
504
- </div>
505
- </div>
506
- )}
507
- {sshPolicy?.configured && sshMsg && (
508
- <div className="alert alert-success" data-testid="mesh-ssh-ok">{sshMsg}</div>
509
- )}
510
- {/* The control server can say a node is up but not which account you
511
- log in as, and connecting as the wrong one is refused exactly like
512
- an unreachable host — so the gap is stated rather than discovered. */}
513
- {nodesMissingAccess.length > 0 && (
514
- <div className="alert alert-warning" data-testid="mesh-access-banner">
515
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, flexWrap: 'wrap' }}>
516
- <div>
517
- <strong>
518
- {nodesMissingAccess.length === 1
519
- ? '1 node has no recorded login account'
520
- : `${nodesMissingAccess.length} nodes have no recorded login account`}
521
- </strong>
522
- <div style={{ fontSize: 13, marginTop: 4 }}>
523
- Any node that tries to reach one of these is refused, in exactly the way an unreachable
524
- machine refuses. {accessProposals.length > 0
525
- ? `This host's ssh config can supply ${accessProposals.length} of them.`
526
- : 'Select a node below to record one.'}
527
- </div>
528
- <div style={{ fontSize: 12, marginTop: 6, color: 'var(--text-secondary)' }}>
529
- {nodesMissingAccess.map((node) => node.hostname).join(', ')}
530
- </div>
531
- {accessProposals.length > 0 && (
532
- <ul style={{ margin: '8px 0 0', paddingLeft: 18, fontSize: 12 }} data-testid="access-proposals">
533
- {accessProposals.map((proposal) => (
534
- <li key={proposal.hostname}>
535
- <strong>{proposal.hostname}</strong> → {proposal.access.ssh_user}{' '}
536
- <span style={{ color: 'var(--text-secondary)' }}>(from Host {proposal.matched_host})</span>
537
- </li>
538
- ))}
539
- </ul>
540
- )}
541
- {accessMsg && (
542
- <div style={{ fontSize: 13, marginTop: 8, color: 'var(--accent-hover)' }}>{accessMsg}</div>
543
- )}
544
- </div>
545
- {accessProposals.length > 0 && (
546
- <button
547
- type="button"
548
- className="btn btn-primary"
549
- onClick={handleImportAccess}
550
- disabled={accessBusy}
551
- >
552
- {accessBusy ? 'Importing…' : 'Import from ssh config'}
553
- </button>
554
- )}
555
- </div>
556
- </div>
557
- )}
558
- {/* Which mesh node actually serves embeddings, and which model it uses. */}
559
- <div className="card">
560
- <EmbeddingProviderPanel />
561
- </div>
562
- <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
563
- <div className="card">
564
- <h3 style={{ marginTop: 0, marginBottom: 12 }}>Topology</h3>
565
- <MeshTopology
566
- nodes={meshNodes}
567
- leader={leader}
568
- latencyMs={latencyMs}
569
- selectedHostname={selectedHostname}
570
- onSelectNode={selectNode}
571
- />
572
- </div>
573
- <div className="card">
574
- <h3 style={{ marginTop: 0, marginBottom: 12 }}>Node Detail</h3>
575
- {selectedNode ? (
576
- <dl className="mesh-node-detail" style={{ margin: 0, display: 'grid', gridTemplateColumns: '120px 1fr', gap: '8px 12px' }}>
577
- <dt style={{ color: 'var(--text-secondary)' }}>Title</dt>
578
- <dd style={{ margin: 0 }}>{selectedNode.title || selectedNode.hostname}</dd>
579
- <dt style={{ color: 'var(--text-secondary)' }}>Hostname</dt>
580
- <dd style={{ margin: 0 }}>{selectedNode.hostname}</dd>
581
- <dt style={{ color: 'var(--text-secondary)' }}>IP</dt>
582
- <dd style={{ margin: 0 }}>{selectedNode.ip || '—'}</dd>
583
- <dt style={{ color: 'var(--text-secondary)' }}>Online</dt>
584
- <dd style={{ margin: 0 }}>
585
- {selectedNode.online ? 'yes' : 'no'}
586
- {selectedNode.vault_only ? ' (vault-only entity)' : ''}
587
- </dd>
588
- <dt style={{ color: 'var(--text-secondary)' }}>OS</dt>
589
- <dd style={{ margin: 0 }}>{selectedNode.os || 'Unknown'}</dd>
590
- <dt style={{ color: 'var(--text-secondary)' }}>Election</dt>
591
- <dd style={{ margin: 0 }}>
592
- {selectedNode.hostname === leader?.hostname || selectedNode.ip === leader?.ip ? 'LEADER' : 'FOLLOWER'}
593
- </dd>
594
- <dt style={{ color: 'var(--text-secondary)' }}>Entity role</dt>
595
- <dd style={{ margin: 0 }}>{selectedNode.role || '—'}</dd>
596
- <dt style={{ color: 'var(--text-secondary)' }}>Labels</dt>
597
- <dd style={{ margin: 0 }}>
598
- {selectedNode.labels?.length ? selectedNode.labels.join(', ') : '—'}
599
- </dd>
600
- <dt style={{ color: 'var(--text-secondary)' }}>Capabilities</dt>
601
- <dd style={{ margin: 0 }}>
602
- {selectedNode.capabilities?.length ? selectedNode.capabilities.join(', ') : '—'}
603
- </dd>
604
- <dt style={{ color: 'var(--text-secondary)' }}>Notes</dt>
605
- <dd style={{ margin: 0 }}>{selectedNode.notes || selectedNode.description || '—'}</dd>
606
- <dt style={{ color: 'var(--text-secondary)' }}>Latency</dt>
607
- <dd style={{ margin: 0 }}>
608
- {selectedNode.self
609
- ? 'self'
610
- : latencyMs[selectedNode.hostname] != null
611
- ? `${latencyMs[selectedNode.hostname]} ms`
612
- : 'n/a'}
613
- </dd>
614
- <dt style={{ color: 'var(--text-secondary)' }}>Entity</dt>
615
- <dd style={{ margin: 0 }}>
616
- {selectedNode.has_entity
617
- ? selectedNode.entity_path || 'vault mesh_node'
618
- : 'live discovery only (no vault entity yet)'}
619
- </dd>
620
- <dt style={{ color: 'var(--text-secondary)' }}>Transports</dt>
621
- <dd style={{ margin: 0 }}>
622
- {selectedNode.transports?.length ? selectedNode.transports.join(', ') : '—'}
623
- </dd>
624
- <dt style={{ color: 'var(--text-secondary)' }}>LAN IP</dt>
625
- <dd style={{ margin: 0 }}>{selectedNode.lan_ip || '—'}</dd>
626
- <dt style={{ color: 'var(--text-secondary)' }}>Interfaces</dt>
627
- <dd style={{ margin: 0 }}>
628
- {(selectedNode.interfaces?.length
629
- ? selectedNode.interfaces
630
- : selectedNode.self
631
- ? localInterfaces
632
- : []
633
- ).length === 0
634
- ? '—'
635
- : (selectedNode.interfaces?.length ? selectedNode.interfaces : localInterfaces)
636
- .map((i) => `${i.name} (${i.kind})`)
637
- .join(', ')}
638
- </dd>
639
- <dt style={{ color: 'var(--text-secondary)' }}>I/O channels</dt>
640
- <dd style={{ margin: 0 }}>
641
- {(selectedNode.self ? deviceIo?.channels : selectedNode.io?.channels)?.length
642
- ? (selectedNode.self ? deviceIo?.channels : selectedNode.io?.channels)!.join(', ')
643
- : '—'}
644
- </dd>
645
- <dt style={{ color: 'var(--text-secondary)' }}>UI hints</dt>
646
- <dd style={{ margin: 0, fontSize: 12 }}>
647
- {(selectedNode.self ? uiHints : selectedNode.ui_hints || selectedNode.io?.ui_hints)?.length
648
- ? (selectedNode.self ? uiHints : selectedNode.ui_hints || selectedNode.io?.ui_hints)!.join(' · ')
649
- : '—'}
650
- </dd>
651
- <dt style={{ color: 'var(--text-secondary)' }}>Scope</dt>
652
- <dd style={{ margin: 0 }}>{selectedNode.self ? 'This node' : 'Peer'}</dd>
653
- <dt style={{ color: 'var(--text-secondary)' }}>Login</dt>
654
- <dd style={{ margin: 0, fontFamily: 'monospace', fontSize: 12 }} data-testid="node-access-target">
655
- {selectedNode.access_resolved?.complete
656
- ? selectedNode.access_resolved.target
657
- : <span className="badge badge-offline">no login recorded</span>}
658
- </dd>
659
- <dt style={{ color: 'var(--text-secondary)' }}>Access source</dt>
660
- <dd style={{ margin: 0 }}>
661
- {selectedNode.access?.source && selectedNode.access.source !== 'unknown'
662
- ? selectedNode.access.source.replace('_', ' ')
663
- : '—'}
664
- </dd>
665
- {/* Only the open-source build can serve control-server-authorised
666
- SSH; the sandboxed GUI builds never start the server, so a
667
- node running one still needs its own keys. */}
668
- <dt style={{ color: 'var(--text-secondary)' }}>Mesh SSH</dt>
669
- <dd style={{ margin: 0 }} data-testid="node-mesh-ssh">
670
- {selectedNode.access_resolved?.mesh_ssh === 'available'
671
- ? 'authorised by the control server'
672
- : selectedNode.access_resolved?.mesh_ssh === 'unsupported'
673
- ? `not served by this build (${selectedNode.access_resolved.tailscale_variant}) — needs its own keys`
674
- : 'unknown'}
675
- </dd>
676
- </dl>
677
- ) : (
678
- <p style={{ color: 'var(--text-secondary)' }}>Click a node in the topology to inspect it.</p>
679
- )}
680
-
681
- {selectedNode && (
682
- <form onSubmit={handleSaveAccess} style={{ marginTop: 16, borderTop: '1px solid var(--border)', paddingTop: 12 }}>
683
- <h4 style={{ margin: '0 0 4px' }}>Access</h4>
684
- <p style={{ fontSize: 12, color: 'var(--text-secondary)', margin: '0 0 12px' }}>
685
- Stored on the node entity, so every machine and agent resolves it the same way.
686
- Leave a box empty to clear it. The address comes from the mesh unless you override it.
687
- </p>
688
- <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
689
- <label style={{ fontSize: 12 }}>
690
- Login account
691
- <input
692
- className="input"
693
- value={accessDraft.ssh_user}
694
- placeholder="e.g. the account you ssh as"
695
- onChange={(e) => setAccessDraft({ ...accessDraft, ssh_user: e.target.value })}
696
- />
697
- </label>
698
- <label style={{ fontSize: 12 }}>
699
- Port
700
- <input
701
- className="input"
702
- value={accessDraft.ssh_port}
703
- placeholder="22"
704
- inputMode="numeric"
705
- onChange={(e) => setAccessDraft({ ...accessDraft, ssh_port: e.target.value })}
706
- />
707
- </label>
708
- <label style={{ fontSize: 12 }}>
709
- Key file
710
- <input
711
- className="input"
712
- value={accessDraft.identity_file}
713
- placeholder="~/.ssh/id_ed25519"
714
- onChange={(e) => setAccessDraft({ ...accessDraft, identity_file: e.target.value })}
715
- />
716
- </label>
717
- <label style={{ fontSize: 12 }}>
718
- Address override
719
- <input
720
- className="input"
721
- value={accessDraft.ssh_host}
722
- placeholder={selectedNode.ip || 'mesh address'}
723
- onChange={(e) => setAccessDraft({ ...accessDraft, ssh_host: e.target.value })}
724
- />
725
- </label>
726
- </div>
727
- <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 12 }}>
728
- <button type="submit" className="btn btn-primary btn-sm" disabled={accessSaveBusy}>
729
- {accessSaveBusy ? 'Saving…' : 'Save access'}
730
- </button>
731
- {accessSaveMsg && (
732
- <span style={{ fontSize: 12, color: 'var(--accent-hover)' }} data-testid="access-save-msg">
733
- {accessSaveMsg}
734
- </span>
735
- )}
736
- </div>
737
- </form>
738
- )}
739
- </div>
740
- </div>
741
-
742
- <div className="card">
743
- <h3 style={{ marginTop: 0, marginBottom: 12 }}>This host — I/O for agent UI</h3>
744
- <p style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 0 }}>
745
- Channels (screen, touch, mic, speaker, …) and UI hints so agents can generate UI for the right surfaces.
746
- Stored as mesh_node entity variables; vault overrides live detection.
747
- </p>
748
- <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 12 }}>
749
- {(deviceIo?.channels || []).map((ch) => (
750
- <span key={ch} className="badge badge-leader">{ch}</span>
751
- ))}
752
- {!deviceIo?.channels?.length && (
753
- <span style={{ color: 'var(--text-secondary)' }}>Detecting…</span>
754
- )}
755
- </div>
756
- <dl style={{ margin: 0, display: 'grid', gridTemplateColumns: '140px 1fr', gap: '6px 12px', fontSize: 13 }}>
757
- <dt style={{ color: 'var(--text-secondary)' }}>Display</dt>
758
- <dd style={{ margin: 0 }}>
759
- {deviceIo?.display?.present
760
- ? `yes${deviceIo.display.touch ? ' · touch' : ''}${deviceIo.display.width && deviceIo.display.height ? ` · ${deviceIo.display.width}×${deviceIo.display.height}` : ''}`
761
- : deviceIo?.headless
762
- ? 'headless'
763
- : '—'}
764
- </dd>
765
- <dt style={{ color: 'var(--text-secondary)' }}>Audio</dt>
766
- <dd style={{ margin: 0 }}>
767
- in: {deviceIo?.audio?.input ? 'yes' : 'no'} · out: {deviceIo?.audio?.output ? 'yes' : 'no'}
768
- </dd>
769
- <dt style={{ color: 'var(--text-secondary)' }}>Camera</dt>
770
- <dd style={{ margin: 0 }}>{deviceIo?.camera?.present ? 'yes' : 'no'}</dd>
771
- <dt style={{ color: 'var(--text-secondary)' }}>Input</dt>
772
- <dd style={{ margin: 0 }}>
773
- kb: {deviceIo?.input?.keyboard ? 'yes' : 'no'} · pointer: {deviceIo?.input?.pointer ? 'yes' : 'no'} · touch: {deviceIo?.input?.touch ? 'yes' : 'no'}
774
- </dd>
775
- <dt style={{ color: 'var(--text-secondary)' }}>UI hints</dt>
776
- <dd style={{ margin: 0 }}>{uiHints.length ? uiHints.join(' · ') : '—'}</dd>
777
- </dl>
778
- </div>
779
-
780
- <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
781
- <div className="card">
782
- <h3 style={{ marginTop: 0, marginBottom: 12 }}>Local interfaces</h3>
783
- <p style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 0 }}>
784
- Kinds are classified from OS interface names (wifi / ethernet / vpn_overlay / …). Variables of this host, not hardcoded devices.
785
- </p>
786
- <table className="data-table">
787
- <thead>
788
- <tr>
789
- <th>Name</th>
790
- <th>Kind</th>
791
- <th>IPv4</th>
792
- </tr>
793
- </thead>
794
- <tbody>
795
- {localInterfaces.map((iface) => (
796
- <tr key={iface.name}>
797
- <td>{iface.name}</td>
798
- <td><span className="badge badge-follower">{iface.kind}</span></td>
799
- <td style={{ color: 'var(--text-secondary)', fontFamily: 'monospace', fontSize: 12 }}>
800
- {(iface.ipv4 || []).join(', ') || '—'}
801
- </td>
802
- </tr>
803
- ))}
804
- {localInterfaces.length === 0 && (
805
- <tr>
806
- <td colSpan={3} style={{ color: 'var(--text-secondary)', textAlign: 'center' }}>
807
- No external interfaces reported
808
- </td>
809
- </tr>
810
- )}
811
- </tbody>
812
- </table>
813
- </div>
814
- <div className="card">
815
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 8 }}>
816
- <h3 style={{ margin: 0 }}>
817
- LAN discovery
818
- {lanTrCount > 0 ? (
819
- <span style={{ marginLeft: 8, fontSize: 13, fontWeight: 400, color: 'var(--accent-hover)' }}>
820
- {lanTrCount} Total Recall reachable
821
- </span>
822
- ) : null}
823
- </h3>
824
- <button
825
- type="button"
826
- className="btn btn-primary btn-sm"
827
- disabled={lanRegisterBusy || lanTrCount === 0}
828
- onClick={handleRegisterLanPeers}
829
- title="Create/update mesh_node vault entities for TR-reachable LAN hosts"
830
- >
831
- {lanRegisterBusy ? 'Registering…' : 'Register TR peers'}
832
- </button>
833
- </div>
834
- <p style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 0 }}>
835
- Hosts from the OS neighbor/ARP table on private LAN ranges. TR-reachable means /health answered on the brain port.
836
- Register upserts entity docs (hostname lan-&lt;ip&gt;) without hardcoding fleet names.
837
- </p>
838
- {lanRegisterMsg && (
839
- <p style={{ fontSize: 12, color: 'var(--accent-hover)' }}>{lanRegisterMsg}</p>
840
- )}
841
- <table className="data-table">
842
- <thead>
843
- <tr>
844
- <th>IP</th>
845
- <th>MAC</th>
846
- <th>IF</th>
847
- <th>TR</th>
848
- </tr>
849
- </thead>
850
- <tbody>
851
- {lanHosts.map((h) => (
852
- <tr key={h.ip}>
853
- <td style={{ fontFamily: 'monospace', fontSize: 12 }}>{h.ip}</td>
854
- <td style={{ fontFamily: 'monospace', fontSize: 11, color: 'var(--text-secondary)' }}>{h.mac}</td>
855
- <td style={{ color: 'var(--text-secondary)' }}>{h.interface || '—'}</td>
856
- <td>
857
- {h.tr_reachable ? (
858
- <span className="badge badge-online">
859
- yes{h.tr_latency_ms != null ? ` ${h.tr_latency_ms}ms` : ''}
860
- </span>
861
- ) : (
862
- <span className="badge badge-offline">no</span>
863
- )}
864
- </td>
865
- </tr>
866
- ))}
867
- {lanHosts.length === 0 && (
868
- <tr>
869
- <td colSpan={4} style={{ color: 'var(--text-secondary)', textAlign: 'center' }}>
870
- No LAN neighbors discovered (ARP empty or unavailable)
871
- </td>
872
- </tr>
873
- )}
874
- </tbody>
875
- </table>
876
- </div>
877
- </div>
878
-
879
- <div className="card" style={{ padding: 0, overflow: 'hidden' }}>
880
- <table className="data-table">
881
- <thead>
882
- <tr>
883
- <th>Hostname</th>
884
- <th>Mesh IP</th>
885
- <th>Role</th>
886
- <th>Status</th>
887
- <th>Latency</th>
888
- <th>Login</th>
889
- <th>Operating System</th>
890
- <th>Node</th>
891
- </tr>
892
- </thead>
893
- <tbody>
894
- {meshNodes.map(node => {
895
- const isNodeLeader = node.hostname === leader?.hostname || node.ip === leader?.ip;
896
- return (
897
- <tr
898
- key={node.hostname}
899
- data-testid={`node-row-${node.hostname}`}
900
- className={isNodeLeader ? 'is-leader' : ''}
901
- onClick={() => selectNode(node)}
902
- style={{ cursor: 'pointer' }}
903
- >
904
- <td style={{ fontWeight: 500, display: 'flex', alignItems: 'center', gap: '8px' }}>
905
- {isNodeLeader && <span title="Leader" style={{ color: 'var(--accent-hover)' }}>👑</span>}
906
- {node.hostname}
907
- </td>
908
- <td style={{ color: 'var(--text-secondary)' }}>{node.ip}</td>
909
- <td>
910
- <span className={`badge ${isNodeLeader ? 'badge-leader' : 'badge-follower'}`}>
911
- {isNodeLeader ? 'LEADER' : 'FOLLOWER'}
912
- </span>
913
- </td>
914
- <td>
915
- <span className={`badge ${node.online ? 'badge-online' : 'badge-offline'}`}>
916
- {node.online ? 'ONLINE' : 'OFFLINE'}
917
- </span>
918
- </td>
919
- <td style={{ color: 'var(--text-secondary)' }}>
920
- {node.self ? '—' : latencyMs[node.hostname] != null ? `${latencyMs[node.hostname]} ms` : '—'}
921
- </td>
922
- <td style={{ fontFamily: 'monospace', fontSize: 12 }}>
923
- {node.access_resolved?.complete ? (
924
- node.access_resolved.target
925
- ) : (
926
- <span className="badge badge-offline" title="Connecting will fail as though the node were unreachable">
927
- no login
928
- </span>
929
- )}
930
- </td>
931
- <td style={{ color: 'var(--text-secondary)' }}>{node.os || 'Unknown'}</td>
932
- <td style={{ color: 'var(--text-secondary)', fontSize: '13px' }}>{node.self ? 'This node' : 'Peer'}</td>
933
- </tr>
934
- );
935
- })}
936
- {meshNodes.length === 0 && !loading && (
937
- <tr>
938
- <td colSpan={8} style={{ textAlign: 'center', color: 'var(--text-secondary)', padding: '32px' }}>
939
- No mesh nodes found
940
- </td>
941
- </tr>
942
- )}
943
- </tbody>
944
- </table>
945
- </div>
946
-
947
- <div className="card">
948
- <h3 style={{ marginTop: 0, marginBottom: 8 }}>Latency matrix (from this node)</h3>
949
- <p style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 0 }}>
950
- Measured via <code>GET /api/mesh/latency</code> through the fetch gate. Full N×N requires each peer to report; this row is what <em>this</em> host measures.
951
- </p>
952
- <div className="latency-matrix-wrap">
953
- <table className="latency-matrix" data-testid="latency-matrix">
954
- <thead>
955
- <tr>
956
- <th className="from-label">From → To</th>
957
- {meshNodes.map((n) => (
958
- <th key={`col-${n.hostname}`}>{n.hostname}</th>
959
- ))}
960
- </tr>
961
- </thead>
962
- <tbody>
963
- {(() => {
964
- const selfNode = meshNodes.find((n) => n.self) || meshNodes[0];
965
- if (!selfNode) {
966
- return (
967
- <tr>
968
- <td colSpan={Math.max(meshNodes.length + 1, 2)} style={{ color: 'var(--text-secondary)' }}>
969
- No mesh nodes yet
970
- </td>
971
- </tr>
972
- );
973
- }
974
- return (
975
- <tr>
976
- <td className="from-label">{selfNode.hostname} (this)</td>
977
- {meshNodes.map((n) => {
978
- const ms = n.self ? 0 : latencyMs[n.hostname];
979
- const label =
980
- n.self
981
- ? '0 ms'
982
- : ms != null
983
- ? `${ms} ms`
984
- : n.online
985
- ? '…'
986
- : '—';
987
- return (
988
- <td
989
- key={`cell-${n.hostname}`}
990
- className={`rtt-cell${n.self ? ' is-self' : ''}`}
991
- title={n.self ? 'self' : undefined}
992
- >
993
- <div>{label}</div>
994
- {!n.self && (
995
- <LatencySparkline
996
- values={latencyHistory[n.hostname] || []}
997
- title={`RTT trend to ${n.hostname}`}
998
- />
999
- )}
1000
- </td>
1001
- );
1002
- })}
1003
- </tr>
1004
- );
1005
- })()}
1006
- </tbody>
1007
- </table>
1008
- </div>
1009
- <table className="data-table" style={{ marginTop: 16 }}>
1010
- <thead>
1011
- <tr>
1012
- <th>Peer</th>
1013
- <th>RTT</th>
1014
- <th>Trend</th>
1015
- </tr>
1016
- </thead>
1017
- <tbody>
1018
- {meshNodes.map((n) => (
1019
- <tr key={`lat-${n.hostname}`}>
1020
- <td>{n.hostname}</td>
1021
- <td>
1022
- {n.self
1023
- ? '0 ms (self)'
1024
- : latencyMs[n.hostname] != null
1025
- ? `${latencyMs[n.hostname]} ms`
1026
- : n.online
1027
- ? 'measuring…'
1028
- : 'offline'}
1029
- </td>
1030
- <td>
1031
- {n.self ? (
1032
- '—'
1033
- ) : (
1034
- <LatencySparkline
1035
- values={latencyHistory[n.hostname] || []}
1036
- title={`RTT trend to ${n.hostname}`}
1037
- />
1038
- )}
1039
- </td>
1040
- </tr>
1041
- ))}
1042
- {meshNodes.length === 0 && (
1043
- <tr>
1044
- <td colSpan={3} style={{ color: 'var(--text-secondary)', textAlign: 'center' }}>No peers</td>
1045
- </tr>
1046
- )}
1047
- </tbody>
1048
- </table>
1049
- </div>
1050
-
1051
- <div className="card">
1052
- <h3 style={{ marginTop: 0, marginBottom: 12 }}>Election history</h3>
1053
- <p style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 0 }}>
1054
- Durable SSSS events (<code>mesh_election</code>) plus this session.
1055
- </p>
1056
- {electionLog.length === 0 ? (
1057
- <p style={{ color: 'var(--text-secondary)' }}>No leader changes recorded yet.</p>
1058
- ) : (
1059
- <ul style={{ margin: 0, paddingLeft: 18, maxHeight: 220, overflowY: 'auto' }} data-testid="election-history">
1060
- {electionLog.map((e, i) => (
1061
- <li key={`${e.at}-${i}`} style={{ marginBottom: 8 }}>
1062
- <strong>{e.hostname}</strong> ({e.ip}) — {e.note}
1063
- <div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
1064
- {new Date(e.at).toLocaleString()}
1065
- </div>
1066
- </li>
1067
- ))}
1068
- </ul>
1069
- )}
1070
- </div>
1071
- </div>
1072
- )}
1073
-
1074
- {activeTab === 'headscale-nodes' && (
1075
- <div className="card" style={{ padding: 0, overflow: 'hidden' }}>
1076
- <table className="data-table">
1077
- <thead>
1078
- <tr>
1079
- <th>ID</th>
1080
- <th>Name</th>
1081
- <th>IP Addresses</th>
1082
- <th>User</th>
1083
- <th>Client Version</th>
1084
- <th>Last Seen</th>
1085
- <th>Actions</th>
1086
- </tr>
1087
- </thead>
1088
- <tbody>
1089
- {hsNodes.map(node => (
1090
- <tr key={node.id}>
1091
- <td>{node.id}</td>
1092
- <td style={{ fontWeight: 500 }}>{node.name}</td>
1093
- <td>{node.ipAddresses.join(', ')}</td>
1094
- <td>{typeof node.user === 'object' ? node.user?.name : (node.user || '')}</td>
1095
- <td style={{ color: 'var(--text-secondary)' }}>{node.clientVersion}</td>
1096
- <td style={{ color: 'var(--text-secondary)', fontSize: '13px' }}>
1097
- {new Date(node.lastSeen).toLocaleString()}
1098
- </td>
1099
- <td>
1100
- <button className="btn btn-ghost btn-sm" style={{ color: 'var(--error)' }} onClick={() => handleDeleteNode(node.id)}>
1101
- Delete
1102
- </button>
1103
- </td>
1104
- </tr>
1105
- ))}
1106
- {hsNodes.length === 0 && !loading && (
1107
- <tr>
1108
- <td colSpan={7} style={{ textAlign: 'center', color: 'var(--text-secondary)', padding: '32px' }}>
1109
- No Headscale nodes found.
1110
- </td>
1111
- </tr>
1112
- )}
1113
- </tbody>
1114
- </table>
1115
- </div>
1116
- )}
1117
-
1118
- {activeTab === 'preauthkeys' && (
1119
- <div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
1120
- <div className="card">
1121
- <h3>Generate Pre-Auth Key</h3>
1122
- <form onSubmit={handleCreateKey} style={{ display: 'flex', gap: '16px', alignItems: 'flex-end', marginTop: '16px' }}>
1123
- <div>
1124
- <label style={{ display: 'block', fontSize: '12px', marginBottom: '4px' }}>User</label>
1125
- <input className="input" value={newKeyUser} onChange={e => setNewKeyUser(e.target.value)} required />
1126
- </div>
1127
- <div>
1128
- <label style={{ display: 'block', fontSize: '12px', marginBottom: '4px' }}>Expiration</label>
1129
- <input type="datetime-local" className="input" value={newKeyExpiration.slice(0,16)} onChange={e => setNewKeyExpiration(new Date(e.target.value).toISOString())} required />
1130
- </div>
1131
- <div style={{ display: 'flex', alignItems: 'center', gap: '8px', paddingBottom: '8px' }}>
1132
- <input type="checkbox" checked={newKeyReusable} onChange={e => setNewKeyReusable(e.target.checked)} id="reusable" />
1133
- <label htmlFor="reusable" style={{ fontSize: '14px' }}>Reusable</label>
1134
- </div>
1135
- <div style={{ display: 'flex', alignItems: 'center', gap: '8px', paddingBottom: '8px' }}>
1136
- <input type="checkbox" checked={newKeyEphemeral} onChange={e => setNewKeyEphemeral(e.target.checked)} id="ephemeral" />
1137
- <label htmlFor="ephemeral" style={{ fontSize: '14px' }}>Ephemeral</label>
1138
- </div>
1139
- <button type="submit" className="btn btn-primary" disabled={loading}>Generate Key</button>
1140
- </form>
1141
- </div>
1142
- <div className="card" style={{ padding: 0, overflow: 'hidden' }}>
1143
- <table className="data-table">
1144
- <thead>
1145
- <tr>
1146
- <th>ID</th>
1147
- <th>Key</th>
1148
- <th>User</th>
1149
- <th>Reusable</th>
1150
- <th>Ephemeral</th>
1151
- <th>Used</th>
1152
- <th>Expiration</th>
1153
- </tr>
1154
- </thead>
1155
- <tbody>
1156
- {hsKeys.map(key => (
1157
- <tr key={key.id}>
1158
- <td>{key.id}</td>
1159
- <td style={{ fontFamily: 'monospace' }}>{key.key}</td>
1160
- <td>{key.user}</td>
1161
- <td>{key.reusable ? 'Yes' : 'No'}</td>
1162
- <td>{key.ephemeral ? 'Yes' : 'No'}</td>
1163
- <td>
1164
- <span className={`badge ${key.used ? 'badge-offline' : 'badge-online'}`}>
1165
- {key.used ? 'USED' : 'UNUSED'}
1166
- </span>
1167
- </td>
1168
- <td style={{ color: 'var(--text-secondary)', fontSize: '13px' }}>
1169
- {new Date(key.expiration).toLocaleString()}
1170
- </td>
1171
- </tr>
1172
- ))}
1173
- {hsKeys.length === 0 && !loading && (
1174
- <tr>
1175
- <td colSpan={7} style={{ textAlign: 'center', color: 'var(--text-secondary)', padding: '32px' }}>
1176
- No pre-auth keys found.
1177
- </td>
1178
- </tr>
1179
- )}
1180
- </tbody>
1181
- </table>
1182
- </div>
1183
- </div>
1184
- )}
1185
-
1186
- {activeTab === 'users' && (
1187
- <div className="card" style={{ padding: 0, overflow: 'hidden' }}>
1188
- <table className="data-table">
1189
- <thead>
1190
- <tr>
1191
- <th>ID</th>
1192
- <th>Name</th>
1193
- <th>Created At</th>
1194
- </tr>
1195
- </thead>
1196
- <tbody>
1197
- {hsUsers.map(user => (
1198
- <tr key={user.id}>
1199
- <td>{user.id}</td>
1200
- <td style={{ fontWeight: 500 }}>{user.name}</td>
1201
- <td style={{ color: 'var(--text-secondary)', fontSize: '13px' }}>
1202
- {new Date(user.createdAt).toLocaleString()}
1203
- </td>
1204
- </tr>
1205
- ))}
1206
- {hsUsers.length === 0 && !loading && (
1207
- <tr>
1208
- <td colSpan={3} style={{ textAlign: 'center', color: 'var(--text-secondary)', padding: '32px' }}>
1209
- No users found.
1210
- </td>
1211
- </tr>
1212
- )}
1213
- </tbody>
1214
- </table>
1215
- </div>
1216
- )}
1217
- </div>
1218
- );
1219
- }