total-recall-brain 3.41.5 → 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.
- package/bin/total-recall.mjs +7 -4
- package/frontend/dist/assets/index-4tGQfTa2.js +5605 -0
- package/frontend/dist/assets/index-DtwsDyFM.css +1 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/package.json +11 -0
- package/frontend/src/App.tsx +47 -122
- package/frontend/src/components/plugins/plugin-frame.ts +1 -0
- package/frontend/src/index.css +152 -86
- package/frontend/src/pages/HelpPage.tsx +13 -10
- package/frontend/src/pages/MemoryPage.tsx +28 -30
- package/frontend/src/pages/NetworkPage.tsx +3 -3
- package/frontend/src/pages/PluginsPage.tsx +6 -5
- package/frontend/src/pages/RulesPage.tsx +16 -12
- package/package.json +3 -2
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +8 -8
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/package.json +3 -0
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +8 -8
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package.json +3 -0
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +8 -8
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/package.json +3 -0
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +8 -8
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package.json +3 -0
- package/scaffold/.agent/skills/total-recall/plugins/mesh/package-lock.json +8 -8
- package/scaffold/.agent/skills/total-recall/plugins/mesh/package.json +4 -1
- package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/mesh.mjs +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/capability/index.mjs +136 -7
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/cli.mjs +135 -26
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package-lock.json +8 -8
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package.json +4 -1
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/plugin.json +228 -9
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/skills/meta-harness/SKILL.md +24 -30
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/alerts.mjs +29 -16
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/cascade.mjs +73 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/coordinator.mjs +182 -365
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/dispatch.mjs +33 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/documentation.mjs +9 -3
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/executor.mjs +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/agy.mjs +187 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/claude.mjs +275 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/cline.mjs +132 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/codex.mjs +187 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/cursor.mjs +158 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/devin.mjs +157 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/dsh.mjs +183 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/grok.mjs +132 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/hermes.mjs +173 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/index.mjs +87 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/openclaw.mjs +173 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/pi.mjs +129 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate.mjs +12 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/json-reply.mjs +22 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/learner.mjs +72 -70
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/model-catalog.mjs +195 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/paths.mjs +11 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/pool.mjs +36 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/review.mjs +169 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/router.mjs +136 -346
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/runtime.mjs +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/ssss-store.mjs +20 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/telemetry.mjs +43 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/threads.mjs +156 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/worker.mjs +117 -35
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/worktree.mjs +51 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/ui/card.js +398 -57
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/cli.mjs +10 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +3 -3
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +3 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +42 -190
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/vendor/openwiki.mjs +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/vendor/skills/mermaid-diagrams/SKILL.md +44 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/vendor/skills/write-connector/SKILL.md +56 -0
- package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +8 -8
- package/scaffold/.agent/skills/total-recall/plugins/security/package.json +3 -0
- package/src/cli/agent.mjs +12 -1
- package/src/cli/compact.mjs +2 -2
- package/src/cli/connect.mjs +56 -1
- package/src/cli/context.mjs +10 -7
- package/src/cli/doctor.mjs +5 -2
- package/src/cli/harness.mjs +62 -50
- package/src/cli/memory-entry.mjs +2 -2
- package/src/cli/preflight.mjs +152 -16
- package/src/cli/remember.mjs +19 -1
- package/src/cli/rules.mjs +54 -12
- package/src/cli/startup.mjs +1 -1
- package/src/cli/status.mjs +12 -2
- package/src/core/claude-hooks.mjs +41 -3
- package/src/core/cli-semantic-index.mjs +3 -2
- package/src/core/conflict-detector.mjs +23 -12
- package/src/core/context-compactor.mjs +2 -0
- package/src/core/context-policy.mjs +20 -0
- package/src/core/duplicate-guard.mjs +350 -0
- package/src/core/meta-harness.mjs +31 -11
- package/src/core/preflight-gate.mjs +2 -2
- package/src/core/preflight-receipt.mjs +28 -0
- package/src/core/scaffold-plugins.mjs +7 -0
- package/src/core/stdio-flush.mjs +23 -0
- package/src/core/surface.mjs +33 -10
- package/src/server/routes/plugins.mjs +2 -2
- package/frontend/dist/assets/index-BIQmqDKP.css +0 -1
- package/frontend/dist/assets/index-WT3gs-x6.js +0 -5631
- package/frontend/src/components/DaemonLogsTab.tsx +0 -54
- package/frontend/src/components/ResearchAgendaTab.tsx +0 -712
- package/frontend/src/components/TaskQueueTab.tsx +0 -193
- package/frontend/src/components/VoiceInput.tsx +0 -112
- package/frontend/src/pages/ChatPage.tsx +0 -1059
- package/frontend/src/pages/CollabPage.tsx +0 -470
- package/frontend/src/pages/DesignDocsPage.tsx +0 -621
- package/frontend/src/pages/FilesPage.tsx +0 -393
- package/frontend/src/pages/HealthPage.tsx +0 -359
- package/frontend/src/pages/InboxPage.tsx +0 -83
- package/frontend/src/pages/InstructionsPage.tsx +0 -583
- package/frontend/src/pages/IntegrationsPage.tsx +0 -761
- package/frontend/src/pages/MeshPage.css +0 -58
- package/frontend/src/pages/MeshPage.tsx +0 -1219
- package/frontend/src/pages/OkfPage.tsx +0 -535
- package/frontend/src/pages/SandboxPage.tsx +0 -113
- package/frontend/src/pages/SkillsPage.tsx +0 -1994
- package/frontend/src/pages/TasksPage.tsx +0 -427
- package/frontend/src/pages/UsagePage.tsx +0 -702
- package/scaffold/.agent/skills/total-recall/plugins/cli-agents/README.md +0 -65
- package/scaffold/.agent/skills/total-recall/plugins/cli-agents/cli.mjs +0 -76
- package/scaffold/.agent/skills/total-recall/plugins/cli-agents/package.json +0 -19
- package/scaffold/.agent/skills/total-recall/plugins/cli-agents/plugin.json +0 -266
- package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/adapters/dsh.mjs +0 -446
- package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/documentation.mjs +0 -152
- package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/executor.mjs +0 -71
- package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/runtime.mjs +0 -36
- package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/setup.mjs +0 -35
- package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/worker.mjs +0 -175
- package/scaffold/.agent/skills/total-recall/plugins/cli-agents/ui/DESIGN.md +0 -32
- 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-<ip>) 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
|
-
}
|